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 1ctl-opt— control options (replaces H-spec).dftactgrp(*no)is required for ILE programs;actgrpsets the activation groupdcl-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-paddedvarchar(n)— variable-length character; length prefix stored with datapacked(p:d)— packed decimal, p digits total, d decimal placeszoned(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 integerfloat(n)— floating point; 4 or 8 bytesind— indicator; single character,*on(true) or*off(false)date,time,timestamp— date/time types with format optionspointer— 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:
conston 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 optionalvaluepasses by value (a copy is made)- Procedures return a value via the
dcl-pireturn type andreturnstatement
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.