RPG in 2026: Modern Free-Format RPG IV, Prototyped Procedures, SQL Integration, and What Current RPG Actually Looks Like

The previous post covered Control Language — the command layer that controls the IBM i environment. This post moves to the language that handles the business logic: RPG.

RPG has a reputation problem. Developers who have never worked on IBM i hear “RPG” and picture punch-card-era fixed-format code that counts columns and uses cryptic single-letter opcodes. That code exists in legacy systems, but it is not what RPG looks like today. Modern free-format RPG IV is a structured, readable language with prototyped procedures, embedded SQL, qualified data structures, and a binding model that supports proper modular design. The comparison is closer to COBOL versus modern Java than people expect.

This post covers RPG from a current perspective: free-format structure, declarations, data structures, prototyped procedures, SQL integration, error handling, and what a modern RPG codebase actually looks like.

A brief history worth knowing

RPG has gone through several significant versions:

RPG II / RPG III — fixed-format, column-dependent. Every specification type (F-spec, D-spec, C-spec, O-spec) occupied specific columns. Opcodes were two to six characters, padded. Readable only if you know the column positions. Still present in unmaintained legacy code.

RPG IV (RPG/400, then ILE RPG) — introduced in V3R1. Still fixed-format by default but with longer names, better types, and the ILE binding model. Most IBM i shops have this vintage of RPG in production.

Free-format RPG IV — introduced in stages from V5R1 onward, fully free-format (including H, F, D, P specs) from IBM i 7.1 TR7 / 7.2 TR3. This is current RPG. Source compiles with CRTBNDRPG or CRTRPGMOD using SRCSTMF for IFS-based source. File extension convention: .rpgle for fixed-format, .sqlrpgle for embedded SQL source.

For any new development, write fully free-format RPG IV. Everything in this post uses free-format syntax.

Program structure

A minimal free-format RPG program:

**FREE
ctl-opt dftactgrp(*no) actgrp('ORDGRP') option(*srcstmt *nodebugio);

dcl-f ORDHDR disk(*ext) keyed usage(*input);

dcl-s ordNum  char(7);
dcl-s custNum char(10);

//---------------------------------------------------------
// mainline
//---------------------------------------------------------
ordNum = 'ORD0143';
chain (ordNum) ORDHDR;

if %found(ORDHDR);
  custNum = OHCUST;
  dsply ('Order found for customer: ' + %trim(custNum));
endif;

*inlr = *on;
return;

Key structural elements:

  • **FREE — signals fully free-format source; must be on line 1, column 1
  • ctl-opt — control options (replaces H-spec). dftactgrp(*no) is required for ILE programs; actgrp sets the activation group
  • dcl-f — file declaration (replaces F-spec)
  • dcl-s — standalone variable declaration (replaces D-spec with blank subtype)
  • *inlr = *on — “last record” indicator; setting it on before return closes files and ends the program cleanly

Data types and declarations

**FREE

// Standalone variables
dcl-s custName   char(40);
dcl-s ordAmt     packed(11:2);
dcl-s lineCount  int(10);
dcl-s isActive   ind;              // indicator (boolean)
dcl-s ordDate    date(*iso);
dcl-s procTime   time(*iso);
dcl-s ordStamp   timestamp;
dcl-s bigText    varchar(1000);
dcl-s rawBytes   char(512) ccsid(*utf8);

Common RPG data types:

  • char(n) — fixed-length character, blank-padded
  • varchar(n) — variable-length character; length prefix stored with data
  • packed(p:d) — packed decimal, p digits total, d decimal places
  • zoned(p:d) — zoned decimal (less common in new code)
  • int(n) — integer; valid sizes are 3, 5, 10, 20 (bytes: 1, 2, 4, 8)
  • uns(n) — unsigned integer
  • float(n) — floating point; 4 or 8 bytes
  • ind — indicator; single character, *on (true) or *off (false)
  • date, time, timestamp — date/time types with format options
  • pointer — system pointer (for API calls)

Data structures

Data structures are one of RPG’s most powerful features. A data structure groups related fields and allows them to be treated as a unit.

Basic data structure:

dcl-ds orderHeader qualified;
  ordNum    char(7);
  custNum   char(10);
  ordDate   date(*iso);
  ordAmt    packed(11:2);
  ordStatus char(2);
end-ds;

The qualified keyword means fields are accessed as orderHeader.ordNum, preventing naming conflicts in programs with multiple data structures.

Data structure based on a file’s record format:

dcl-ds ordHdrRec likerec(ORDHDR:*input) qualified;

likerec creates a data structure with the same fields as the specified file’s record format — eliminates manual field declarations for database record work.

Data structure based on another data structure (template):

dcl-ds orderTemplate qualified template;
  ordNum    char(7);
  custNum   char(10);
  ordAmt    packed(11:2);
end-ds;

// Create instances based on the template
dcl-ds currentOrder likeds(orderTemplate);
dcl-ds previousOrder likeds(orderTemplate);

Arrays of data structures:

dcl-ds lineItem qualified dim(100);
  itemNum  char(10);
  qty      int(10);
  price    packed(9:2);
  extended packed(11:2);
end-ds;

// Access via index
lineItem(1).itemNum = 'ITM00001';
lineItem(1).qty     = 3;
lineItem(1).price   = 29.99;

Subfield overlays (for data conversion and legacy interfaces):

dcl-ds dateFields;
  fullDate  char(8);
  dateYear  char(4) pos(1);
  dateMon   char(2) pos(5);
  dateDay   char(2) pos(7);
end-ds;

pos(n) positions the subfield at a specific offset within the structure — useful when interfacing with fixed-format legacy record layouts.

Prototyped procedures

Prototyped procedures are the foundation of modular RPG design. A prototype (dcl-pr) declares the interface; a procedure interface (dcl-pi) defines it in the implementation.

Declaring and defining a subprocedure:

**FREE
ctl-opt dftactgrp(*no) actgrp('ORDGRP') option(*srcstmt);

// Prototype (declaration)
dcl-pr calcDiscount packed(7:2) extproc('CALCDISCOUNT');
  ordAmt    packed(11:2) const;
  custClass char(2)      const;
end-pr;

//=============================================================
// Subprocedure implementation
//=============================================================
dcl-proc calcDiscount;
  dcl-pi *n packed(7:2);
    ordAmt    packed(11:2) const;
    custClass char(2)      const;
  end-pi;

  dcl-s discountRate packed(5:4);
  dcl-s discountAmt  packed(7:2);

  select;
    when custClass = 'AA';
      discountRate = 0.15;
    when custClass = 'BB';
      discountRate = 0.10;
    other;
      discountRate = 0.05;
  endsl;

  discountAmt = ordAmt * discountRate;
  return discountAmt;

end-proc;

Key procedure concepts:

  • const on a parameter means the procedure cannot modify the caller’s variable — the RPG equivalent of pass-by-value for the caller’s perspective
  • Without const, parameters are passed by reference; the procedure can modify the caller’s variable
  • options(*omit) makes a parameter optional
  • value passes by value (a copy is made)
  • Procedures return a value via the dcl-pi return type and return statement

Calling a procedure in a service program:

// Prototype for external procedure in service program ORDUTILS
dcl-pr getCustomerClass char(2) extproc(*dclcase);
  custNum char(10) const;
end-pr;

// Call is identical to a local procedure
dcl-s custClass char(2);
custClass = getCustomerClass('CUST00001');

extproc(*dclcase) tells the compiler the external procedure name matches the prototype name exactly (respecting case), rather than being forced to uppercase.

Control flow

Modern free-format RPG uses readable structured control flow:

// IF / ELSEIF / ELSE
if ordAmt > 10000;
  discountRate = 0.15;
elseif ordAmt > 5000;
  discountRate = 0.10;
else;
  discountRate = 0.05;
endif;

// SELECT / WHEN (like a switch statement)
select;
  when ordStatus = 'OP';
    processOpenOrder();
  when ordStatus = 'CL';
    processClosedOrder();
  when ordStatus = 'HL';
    processHeldOrder();
  other;
    logUnknownStatus(ordStatus);
endsl;

// DOW (do while)
dow lineCount > 0;
  processLine(lineCount);
  lineCount -= 1;
enddo;

// FOR (counted loop)
for idx = 1 to %elem(lineItem);
  if lineItem(idx).qty > 0;
    processLineItem(lineItem(idx));
  endif;
endfor;

// ITER and LEAVE (continue and break)
for idx = 1 to %elem(lineItem);
  if lineItem(idx).qty = 0;
    iter;          // skip to next iteration
  endif;
  if lineItem(idx).itemNum = *blanks;
    leave;         // exit the loop
  endif;
  processLineItem(lineItem(idx));
endfor;

Built-in functions

RPG’s built-in functions (BIFs) handle the operations that would require utility procedures in other languages:

// String operations
dcl-s fullName  varchar(80);
dcl-s firstName char(40);
dcl-s lastName  char(40);

fullName  = %trim(firstName) + ' ' + %trim(lastName);
lineCount = %len(%trim(fullName));

// Numeric conversion
dcl-s amtStr char(15);
dcl-s amt    packed(11:2);

amtStr = %char(amt);           // numeric to char
amt    = %dec(amtStr : 11 : 2);// char to packed

// Date operations
dcl-s today     date(*iso);
dcl-s dueDate   date(*iso);
dcl-s daysUntil int(10);

today     = %date();
dueDate   = today + %days(30);
daysUntil = %diff(dueDate : today : *days);

// Array and data structure
dcl-s arrSize int(10);
arrSize = %elem(lineItem);     // number of elements

// Error checking
if %error();                   // true if last I/O or conversion had an error
  // handle error
endif;

if %found(ORDHDR);             // true if last keyed read found a record
  // process record
endif;

Embedded SQL (SQLRPGLE)

When the source member type is SQLRPGLE (or the IFS file extension is .sqlrpgle), the SQL precompiler processes the source before the RPG compiler. This allows SQL statements to be embedded directly in RPG.

Single-row fetch into host variables:

**FREE
ctl-opt dftactgrp(*no) actgrp('ORDGRP') option(*srcstmt);

dcl-s custNum  char(10);
dcl-s custName varchar(40);
dcl-s creditLim packed(11:2);
dcl-s sqlCode  int(10);

custNum = 'CUST00001';

exec sql
  select CUSTNAME, CREDITLIM
  into   :custName, :creditLim
  from   CUSTLIB.CUSTOMER
  where  CUSTNUM = :custNum;

sqlCode = SQLCODE;

if sqlCode = 0;
  dsply ('Customer: ' + %trim(custName));
elseif sqlCode = 100;
  dsply 'Customer not found';
else;
  dsply ('SQL error: ' + %char(sqlCode));
endif;

*inlr = *on;

Host variables in SQL statements are prefixed with a colon (:custNum). The SQL precompiler replaces them with proper parameter markers at compile time.

Cursor for multi-row processing:

dcl-s ordNum   char(7);
dcl-s ordAmt   packed(11:2);
dcl-s ordDate  date(*iso);
dcl-s totalAmt packed(13:2) inz(0);

exec sql declare ordCursor cursor for
  select ORDNUM, ORDAMT, ORDDATE
  from   ORDLIB.ORDHDR
  where  ORDSTS = 'OP'
    and  CUSTNUM = :custNum
  order  by ORDDATE;

exec sql open ordCursor;

dow sqlcode = 0;
  exec sql fetch ordCursor into :ordNum, :ordAmt, :ordDate;
  if sqlcode  0;
    leave;
  endif;
  totalAmt += ordAmt;
  processOrder(ordNum);
enddo;

exec sql close ordCursor;

Using SQLSTATE for portable error checking:

dcl-s sqlState char(5);

exec sql
  update ORDLIB.ORDHDR
  set    ORDSTS = 'CL',
         CLSDAT = current_date
  where  ORDNUM = :ordNum;

sqlState = SQLSTATE;

select;
  when sqlState = '00000';
    // success
  when sqlState = '02000';
    logMsg('Order not found for close: ' + ordNum);
  other;
    logMsg('SQL error ' + sqlState + ' closing order ' + ordNum);
    // escalate
endsl;

Declaring a result set variable (for stored procedure output):

dcl-ds custRow qualified;
  custNum   char(10);
  custName  varchar(40);
  creditLim packed(11:2);
  custClass char(2);
end-ds;

exec sql
  select CUSTNUM, CUSTNAME, CREDITLIM, CUSTCLASS
  into   :custRow
  from   CUSTLIB.CUSTOMER
  where  CUSTNUM = :searchCust
  fetch first 1 rows only;

Using a qualified data structure as a single SQL host variable is a clean pattern — all columns map to structure subfields without listing each variable separately.

Error handling in RPG

RPG uses a combination of indicators, built-in functions, and the monitor group for error handling.

Monitor group (structured exception handling):

monitor;
  // Code that might fail
  chain (ordNum) ORDHDR;
  if not %found(ORDHDR);
    logMsg('Order not found: ' + ordNum);
    return;
  endif;

  // Process the record
  processOrderHeader(ordHdrRec);

on-error *file;
  logMsg('File error accessing ORDHDR for order: ' + ordNum);
  // do not re-raise — caller handles absence of result

on-error *program;
  logMsg('Program error in order processing');
  // re-raise so caller knows something went wrong
  callp %error();  // this form re-raises the current exception

on-error;
  // catch-all for any other error
  logMsg('Unexpected error');
endmon;

Error subfile with %error() and noopt:

dcl-s errFound ind;

// Use *noopt annotation to prevent optimizer from removing error checks
read(e) ORDHDR;      // (e) extender — error indicator instead of halt
errFound = %error();

if errFound;
  logMsg('Read error on ORDHDR');
  *inlr = *on;
  return;
endif;

The (e) operation extender tells RPG to set the %error() BIF on I/O errors instead of halting the program. This gives more control than the default behaviour of abnormal program end on file errors.

Service programs

A service program is a compiled RPG module (or set of modules) bound together into a shareable library of procedures. It is the IBM i equivalent of a shared library or DLL.

Creating a service program:

// ORDUTILS.rpgle — module source
**FREE
ctl-opt nomain;   // no main procedure — this is a module, not a program

dcl-pr getCustomerClass char(2) extproc(*dclcase);
  custNum char(10) const;
end-pr;

dcl-pr formatOrderNum char(10) extproc(*dclcase);
  numericId int(10) const;
end-pr;

dcl-proc getCustomerClass export;
  dcl-pi *n char(2);
    custNum char(10) const;
  end-pi;
  // ... implementation ...
  return 'AA';
end-proc;

dcl-proc formatOrderNum export;
  dcl-pi *n char(10);
    numericId int(10) const;
  end-pi;
  return 'ORD' + %editc(numericId : 'X');
end-proc;

nomain on ctl-opt means the module has no mainline — it is a library of procedures only. export on each dcl-proc makes it available to bound programs.

Binding to a service program:

// CRTRPGMOD to compile the module
CRTRPGMOD MODULE(ORDLIB/ORDUTILS) SRCSTMF('/repo/src/rpgle/ORDUTILS.rpgle') DBGVIEW(*SOURCE)

// CRTSRVPGM to create the service program
CRTSRVPGM SRVPGM(ORDLIB/ORDUTILS) MODULE(ORDLIB/ORDUTILS) EXPORT(*ALL)

// CRTBNDRPG to create a calling program bound to the service program
CRTBNDRPG PGM(ORDLIB/CRTORD) SRCSTMF('/repo/src/rpgle/CRTORD.rpgle') +
          BNDDIR(ORDLIB/ORDUTILS)

The binding directory (BNDDIR) points to a list of service programs to bind against. When the calling program is activated, the service program is also activated, and its exported procedures are available.

Common patterns in modern RPG

Passing data structures between procedures (avoiding long parameter lists):

dcl-ds orderRequest qualified template;
  custNum   char(10);
  itemNum   char(10);
  qty       int(10);
  reqDate   date(*iso);
  warehouse char(4);
end-ds;

dcl-pr createOrder char(7) extproc(*dclcase);
  request likeds(orderRequest) const;
end-pr;

Grouping parameters into a data structure is the standard RPG pattern for procedures with many inputs. It is also forward-compatible — adding a field to the structure does not break the procedure’s signature.

Null-capable host variables for SQL:

dcl-s shipDate   date(*iso);
dcl-s shipDateNull int(5);   // null indicator: 0 = not null, -1 = null

exec sql
  select SHIPDATE
  into   :shipDate :shipDateNull
  from   ORDLIB.ORDHDR
  where  ORDNUM = :ordNum;

if shipDateNull = -1;
  // order not yet shipped
else;
  dsply ('Shipped: ' + %char(shipDate));
endif;

The null indicator variable follows the host variable with a colon separator in the SQL statement. A value of -1 means the column is null.

Reading all records from a file with %eof:

read ORDHDR;
dow not %eof(ORDHDR);
  processRecord();
  read ORDHDR;
enddo;

What modern RPG actually looks like

The narrative that RPG is unreadable does not survive contact with a modern SQLRPGLE program. Here is a realistic excerpt:

**FREE
ctl-opt dftactgrp(*no) actgrp('ORDGRP') option(*srcstmt *nodebugio);

/copy ORDLIB/QRPGLECPY,ORDTYPES    // shared type definitions

dcl-s custNum  char(10);
dcl-s today    date(*iso);
dcl-s totalAmt packed(13:2) inz(0);
dcl-s errMsg   varchar(200);

dcl-ds orderSummary qualified;
  ordCount  int(10) inz(0);
  totalAmt  packed(13:2) inz(0);
  latestOrd char(7);
end-ds;

//=============================================================
// Main entry
//=============================================================
dcl-proc main export;
  dcl-pi *n ind;
    inCustNum char(10) const;
  end-pi;

  custNum = inCustNum;
  today   = %date();

  if not loadCustomerOrders();
    return *off;
  endif;

  return *on;
end-proc;

//=============================================================
// Load open orders for a customer
//=============================================================
dcl-proc loadCustomerOrders;
  dcl-pi *n ind;
  end-pi;

  dcl-s ordNum  char(7);
  dcl-s ordAmt  packed(11:2);

  exec sql declare custOrdCsr cursor for
    select ORDNUM, ORDAMT
    from   ORDLIB.ORDHDR
    where  CUSTNUM = :custNum
      and  ORDSTS  = 'OP'
    order  by ORDDAT desc;

  exec sql open custOrdCsr;

  dow sqlcode = 0;
    exec sql fetch custOrdCsr into :ordNum, :ordAmt;
    if sqlcode = 100;
      leave;
    elseif sqlcode  0;
      logError('Cursor fetch error: ' + %char(sqlcode));
      exec sql close custOrdCsr;
      return *off;
    endif;

    orderSummary.ordCount += 1;
    orderSummary.totalAmt += ordAmt;
    orderSummary.latestOrd = ordNum;
  enddo;

  exec sql close custOrdCsr;
  return *on;
end-proc;

This is readable, structured, and directly comparable to code you would see in any other ILE language. The columns are gone. The cryptic opcodes are gone. The logic reads in sequence.

Compiling modern RPG from the IFS

In a proper DevOps pipeline (as covered in Post 17), RPG source lives in the IFS under Git and is compiled with commands that reference the IFS path:

// Compile RPG module from IFS source
CRTRPGMOD MODULE(ORDLIB/CRTORD) +
           SRCSTMF('/home/build/project-repo/src/rpgle/CRTORD.rpgle') +
           DBGVIEW(*SOURCE) +
           OPTION(*SRCSTMT)

// Compile bound RPG program (module + bind in one step)
CRTBNDRPG PGM(ORDLIB/CRTORD) +
           SRCSTMF('/home/build/project-repo/src/rpgle/CRTORD.rpgle') +
           DBGVIEW(*SOURCE) +
           BNDDIR(ORDLIB/ORDUTILS)

// Compile SQL RPG
CRTSQLRPGI OBJ(ORDLIB/ORDSUMRY) +
            SRCSTMF('/home/build/project-repo/src/sqlrpgle/ORDSUMRY.sqlrpgle') +
            COMMIT(*NONE) +
            DBGVIEW(*SOURCE) +
            OBJTYPE(*PGM)

DBGVIEW(*SOURCE) embeds the source mapping in the compiled object so the IBM i debugger can show the actual source lines. OPTION(*SRCSTMT) enables statement-level debugging from IFS source. Both are essential for productive development.

RPG is not the language that gets described when people say “we need to modernise away from AS/400.” It is a capable ILE language with SQL integration, modern control flow, and a proper binding model. The modernisation question for RPG shops is not what language to replace it with — it is whether the existing RPG code uses the current language features, or whether it is fixed-format OPM code that could benefit from being rewritten in free-format ILE RPG with proper SQL integration and a clean service program architecture.

Next post: DB2 for i — how IBM i’s integrated database actually works, why it is not just “a database running on AS/400,” SQL system naming versus library naming, physical and logical files versus SQL tables and views, and the query optimizer features specific to DB2 for i.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top