Using SQL inside RPGLE programs
Plain-English IBM i learning notes with examples, comparisons, and official references.
1. SQL control options and compile intent
Set the SQL naming, commitment, date, and error-handling choices deliberately before writing statements.
- SQL precompiler options such as COMMIT, NAMING, CLOSQLCSR, and DATFMT affect how embedded SQL is interpreted and how resources behave. Confirm the option names and release defaults in the IBM reference for your build command.
- Use a qualified naming strategy when a program must not depend on a changing library list. Keep compile options with the source and build record.
- Choose *NONE or a commitment-control level from the transaction requirement; do not enable a mode without understanding journaling and rollback expectations.
**FREE ctl-opt option(*srcstmt : *nodebugio); // Build-time SQL options belong in the compile command or source member. // Keep naming, commitment, and cursor behavior documented with the program.
2. Read one row into RPG host variables
Use a singleton SELECT when the predicate is intended to return zero or one row.
- Declare RPG variables with types that match the SQL columns and prefix them with a colon in embedded SQL.
- Handle SQLSTATE 02000 before using the target variables. A no-row result must not leave the previous customer in the output fields.
- If several rows are valid, use a cursor or multi-row fetch rather than hiding the cardinality error.
exec sql
select NAME, STATUS
into :customerName, :customerStatus
from MYLIB.CUSTOMER
where CUSTOMER_ID = :customerId;
if SQLSTATE = '02000';
// not found branch
elseif SQLSTATE <> '00000';
// diagnostic branch
endif;3. Read a file with a cursor loop
Declare, open, fetch, test end-of-data, and close in a predictable lifecycle.
- The cursor query should have a stable ORDER BY when the program depends on processing order.
- After each FETCH, check SQLSTATE or the SQL communication area before processing host variables.
- Close the cursor in normal and error cleanup paths. Keep the loop bounded or observable for batch workloads.
exec sql
declare cOrders cursor for
select ORDER_ID, TOTAL
from MYLIB.ORDERS
where CUSTOMER_ID = :customerId
order by ORDER_ID;
exec sql open cOrders;
// Pseudocode skeleton:
// dow SQLSTATE <> '02000';
// exec sql fetch cOrders into :orderId, :total;
// if SQLSTATE = '00000'; process the row; endif;
// enddo;
exec sql close cOrders;4. Insert, update, and delete from RPGLE
Use parameterized values and make affected-row expectations explicit.
- Name target columns in INSERT statements and bind values rather than concatenating text.
- After UPDATE or DELETE, check the expected row count and distinguish zero rows from a successful change.
- For a duplicate or constraint error, preserve the SQLSTATE and message text so the caller can choose a business response.
exec sql insert into MYLIB.ORDER_LINE (ORDER_ID, SKU, QUANTITY) values (:orderId, :sku, :quantity); if SQLSTATE = '00000'; // continue elseif SQLSTATE = '23505'; // duplicate business key else; // preserve diagnostic endif;
5. Nullable columns and indicator variables
Keep NULL, zero, blank, and not-found as separate states.
- A null-capable result needs a null indicator or an equivalent nullable host structure.
- Check the indicator before formatting or calculating with the host variable.
- If the application needs a default, make that conversion explicit in SQL with COALESCE or in RPG after the null state is known.
exec sql
select SHIPPED_DATE
into :shipDate :shipDateNull
from MYLIB.ORDERS
where ORDER_ID = :orderId;
if shipDateNull < 0;
// not scheduled
else;
// use shipDate
endif;6. Commit, rollback, and error cleanup
Group related changes and leave the database in a known state after failure.
- Make the transaction boundary visible: begin or use the configured commitment scope, perform related statements, then commit once the checks pass.
- On a handled failure, roll back the unit and return the original diagnostic. Do not commit a marker separately from the business update when replay is possible.
- Test the failure between statements and after a process interruption, not only the happy path.
exec sql insert into MYLIB.ORDER_HEADER (...) values (...); exec sql insert into MYLIB.ORDER_DETAIL (...) values (...); if SQLSTATE = '00000'; exec sql commit; else; exec sql rollback; endif;
7. Calling stored procedures and SQL routines
Treat a routine call as an interface with typed inputs, outputs, and diagnostics.
- Document parameter order, data types, nullability, and expected SQLSTATE values at the RPG boundary.
- Use host variables for IN, OUT, and INOUT values and validate outputs before using them.
- Keep authorization and transaction behavior explicit; a routine can participate in the caller's transaction.
exec sql call MYLIB.CALCULATE_TOTAL(:orderId, :total :totalNull); if SQLSTATE <> '00000'; // preserve the routine diagnostic endif;
8. Set-based SQL versus RPG row loops
Choose whether the database or the RPG program should own the work.
- A set-based UPDATE or aggregate can avoid moving every row through RPG, but it must have a reviewed predicate and measured plan.
- Use an RPG loop when each row needs procedural validation, external calls, or a detailed recovery path.
- When mixing the two, define the transaction, ordering, restart marker, and idempotency behavior.
exec sql
update MYLIB.ORDER_LINE
set EXTENDED = QUANTITY * UNIT_PRICE
where ORDER_ID = :orderId;
// Prefer one measured set operation when row-level procedural work is not required.9. Keyed lookup: CHAIN thinking with SELECT INTO
Use a singleton SELECT for a business key that should return zero or one row, then branch before using host variables.
- The colon prefixes RPG host variables. The SQL statement stays parameterized; do not build the key into SQL text.
- SQLSTATE 02000 is the normal not-found branch. It is not a successful row read.
- If duplicate rows are possible, repair the data rule or use a cursor; do not hide an unexpected many-row result.
**free
dcl-s customerId packed(9:0);
dcl-s customerName varchar(80);
dcl-s customerStatus char(1);
exec sql
select NAME, STATUS
into :customerName, :customerStatus
from MYLIB.CUSTOMER
where CUSTOMER_ID = :customerId;
select;
when SQLSTATE = '00000';
// Use customerName and customerStatus.
when SQLSTATE = '02000';
// Same business branch as CHAIN with %FOUND = *off.
other;
// Preserve SQLCODE and SQLSTATE for the caller.
endsl;10. Existence check without fetching the row
Ask the database whether a row exists when the program does not need the record fields.
- This is the embedded SQL equivalent of a lightweight SETLL/%EQUAL-style existence decision, not a replacement for a later validated update.
- Use FETCH FIRST 1 ROW ONLY so the statement expresses that one answer is sufficient.
- A later UPDATE must still include its business condition because another job can change the row after this check.
**free
dcl-s rowExists int(5) inz(0);
exec sql
select 1
into :rowExists
from MYLIB.CUSTOMER
where CUSTOMER_ID = :customerId
fetch first 1 row only;
if SQLSTATE = '00000';
// The key exists.
elseif SQLSTATE = '02000';
// The key does not exist.
else;
// An error happened; do not treat it as not-found.
endif;11. Read all equal-key rows with a cursor
Use a cursor when a customer can own many order rows and the RPG procedure must process each one.
- The WHERE predicate represents READE's equal-key boundary; ORDER BY makes the processing order explicit.
- FETCH writes into host variables. Process them only after SQLSTATE 00000, and exit on 02000.
- Always close the cursor, including on an error path, so the program does not retain cursor resources longer than intended.
**free
dcl-s orderId packed(9:0);
dcl-s total packed(11:2);
exec sql
declare orderCursor cursor for
select ORDER_ID, TOTAL
from MYLIB.ORDERS
where CUSTOMER_ID = :customerId
order by ORDER_ID;
exec sql open orderCursor;
dou SQLSTATE = '02000';
exec sql fetch next from orderCursor into :orderId, :total;
if SQLSTATE = '00000';
// Process this order row.
elseif SQLSTATE <> '02000';
leave; // Route diagnostics after cleanup.
endif;
enddo;
exec sql close orderCursor;12. Read the next ordered page
Use a stable order and keyset boundary when the program needs the next small group of rows.
- A predicate such as ORDER_ID > :lastOrderId describes the boundary explicitly, unlike relying on an earlier cursor position in another call.
- FETCH FIRST limits the result set. The program can move the boundary only after it has successfully processed each row.
- For changing data, keyset paging is often easier to reason about than a large OFFSET, but choose after measuring the real workload.
**free
dcl-s lastOrderId packed(9:0);
dcl-s orderId packed(9:0);
exec sql
declare nextOrders cursor for
select ORDER_ID
from MYLIB.ORDERS
where CUSTOMER_ID = :customerId
and ORDER_ID > :lastOrderId
order by ORDER_ID
fetch first 25 rows only;
exec sql open nextOrders;
// FETCH, test SQLSTATE, process, then set lastOrderId = orderId.
exec sql close nextOrders;13. Join tables instead of nested file reads
Let Db2 match related rows when the procedure needs columns from multiple files or tables.
- A LEFT JOIN keeps an order even when its optional shipment row is absent. Use a null indicator for nullable right-side columns.
- Name the columns and qualify them with aliases so a later schema change or join does not make the result ambiguous.
- If the business request needs many result rows, move this SELECT into a cursor rather than assuming one order is returned.
**free
dcl-s orderTotal packed(11:2);
dcl-s shippedDate date;
dcl-s shippedDateNull int(5);
exec sql
select o.TOTAL, s.SHIPPED_DATE
into :orderTotal, :shippedDate :shippedDateNull
from MYLIB.ORDERS o
left join MYLIB.SHIPMENT s
on s.ORDER_ID = o.ORDER_ID
where o.ORDER_ID = :orderId;
if SQLSTATE = '00000' and shippedDateNull < 0;
// The order exists but has not shipped.
endif;14. Update and delete with a final business predicate
Make the change condition visible in the SQL statement so the row is not changed merely because it was read earlier.
- The version or status predicate is an optimistic-concurrency check. A zero-row update is a business outcome that needs a deliberate branch.
- Use one transaction for related header, detail, and status changes. Do not commit a partial business action.
- For DELETE, add the required status or ownership condition rather than deleting solely by key when the rule depends on current state.
**free
exec sql
update MYLIB.ORDERS
set STATUS = 'COMPLETE',
VERSION = VERSION + 1
where ORDER_ID = :orderId
and VERSION = :expectedVersion
and STATUS = 'READY';
if SQLSTATE = '00000';
// Confirm the affected-row expectation, then continue.
elseif SQLSTATE = '02000';
// No qualifying row: stale version or changed status.
else;
// Preserve diagnostics and roll back if this is one unit of work.
endif;15. Use a CTE when the business step has a name
A common table expression makes a multi-step query readable without creating a permanent table or view.
- WITH creates a result name for the one SQL statement. It is useful when an aggregate, filter, or join needs to be understood before the final SELECT.
- A CTE is not a promise that Db2 materializes a temporary table; the optimizer chooses how to execute the statement.
- In RPGLE, a singleton final SELECT still needs the zero-row and error branches shown in the earlier examples.
**free
dcl-s customerTotal packed(13:2);
exec sql
with CustomerTotals as (
select CUSTOMER_ID, sum(AMOUNT) as TOTAL_AMOUNT
from MYLIB.ORDERS
group by CUSTOMER_ID
)
select TOTAL_AMOUNT
into :customerTotal
from CustomerTotals
where CUSTOMER_ID = :customerId
and TOTAL_AMOUNT > 10000;
if SQLSTATE = '02000';
// No qualifying customer total.
endif;