SQL Cursors on IBM i
A professional deep dive into SQL cursors on IBM i -- the cursor lifecycle, DECLARE options, scrollable cursors, WHERE CURRENT OF, multi-row FETCH, SQLERRD(3), WITH HOLD, cursor-specific SQLCODE handling, and production practices.
Who this is for
This Deep Dive builds on SQL on IBM i and Embedded SQL in RPGLE, and the SQLTUTOR schema/data both create -- work through those first if you haven't. The Embedded SQL in RPGLE Deep Dive already introduced the standard cursor loop; this one goes deep on everything a cursor can do beyond that: scrolling backward and forward through a result set, updating rows through the cursor itself, fetching many rows per round trip for performance, and keeping a cursor open across a COMMIT.
By the end you'll be able to:
- Explain what a cursor actually is and why SQL -- a set-based language -- needs one at all
- Walk through a cursor's full lifecycle, from
DECLAREtoCLOSE - Choose the right cursor sensitivity and scrollability for a given job
- Scroll forward and backward through a result set with
FETCH FIRST/LAST/PRIOR/NEXT/ABSOLUTE - Update or delete the current cursor row with
WHERE CURRENT OF, and know when aSELECTisn't updatable at all - Fetch multiple rows per round trip for real performance gains, and read
SQLERRD(3)correctly - Keep a cursor open across a
COMMITwithWITH HOLD-- and recognize theSQLCODE -501you get when you forget it - Recognize when you don't need a cursor at all
You'll need an IBM i partition to compile and run the programs below -- read through for the logic even if you don't have one handy yet.
1. What a cursor actually is
SQL is set-based -- a SELECT describes what rows you want, not a loop to get them one at a time. But RPGLE (and most host languages) can only consume data one row at a time. A cursor is the bridge: a named pointer into a result set that you position and move through explicitly, row by row, from host-language code.
You already used one in the Embedded SQL in RPGLE Deep Dive without dwelling on it:
Exec Sql Declare OrdCurs Cursor For
Select Order_Id, Order_Date, Status From SQLTUTOR.ORDERS Where Cust_Id = :CustId;
Exec Sql Open OrdCurs;
Exec Sql Fetch OrdCurs Into :OrderId, :OrderDate, :OrderStatus;
Dow SqlCode = 0;
// process one row
Exec Sql Fetch OrdCurs Into :OrderId, :OrderDate, :OrderStatus;
EndDo;
Exec Sql Close OrdCurs;
That's the default cursor: forward-only, one row at a time, read-only unless Db2 for i can tell otherwise. Everything below is a variation on DECLARE that changes what the cursor can do.
2. Cursor lifecycle: DECLARE, OPEN, FETCH, CLOSE
Before the options table, it's worth being precise about what each keyword actually does -- a lot of cursor confusion comes from treating these four as interchangeable ceremony instead of four distinct steps:
DECLARE
| registers the cursor's name and query -- does not run anything yet
v
OPEN
| runs the query, positions the cursor before the first row
v
FETCH (NEXT / FIRST / PRIOR / ABSOLUTE ...)
| moves the cursor and copies the row's column values into host variables
v
Current row available
| SQLCODE 0 -- a row is now the cursor's "current" row
| SQLCODE +100 -- no row / end of data; there is no current row
v
Optional positioned UPDATE/DELETE ... WHERE CURRENT OF
| acts on exactly the row the cursor is parked on right now
v
CLOSE
releases the cursor and its resources
The one thing worth internalizing here: WHERE CURRENT OF only makes sense between a successful FETCH and the next FETCH or CLOSE. Once SQLCODE comes back +100, there is no current row -- there's nothing for WHERE CURRENT OF to act on, and the cursor needs a fresh OPEN (or to simply be closed) before it can be used again.
3. The DECLARE CURSOR options, all in one place
DECLARE cursor-name
[ INSENSITIVE | SENSITIVE | ASENSITIVE ]
[ SCROLL ]
CURSOR [ WITH HOLD ]
FOR select-statement
[ FOR READ ONLY | FOR UPDATE OF column-list ]
| Option | Meaning |
|---|---|
INSENSITIVE |
Db2 for i takes a snapshot of the result set at OPEN time. Changes made anywhere afterward -- even by your own program -- are never seen. Always read-only. |
SENSITIVE |
The cursor reflects changes made through itself (and, depending on the query, possibly other changes too). Needed if you want to scroll and update. |
ASENSITIVE (default) |
Db2 for i picks SENSITIVE or INSENSITIVE automatically based on whether the cursor is updatable. Fine for most cases -- be explicit only when you specifically require one behavior over the other. |
SCROLL |
Allows FETCH FIRST/LAST/PRIOR/ABSOLUTE/RELATIVE, not just NEXT. Omit it and you get a lighter-weight forward-only cursor. |
WITH HOLD |
Keeps the cursor open (and positioned) across a COMMIT. See Section 10. |
FOR READ ONLY |
You will only FETCH, never UPDATE/DELETE, through this cursor. |
FOR UPDATE OF column-list |
You intend to modify these specific columns through the cursor via WHERE CURRENT OF. See Section 5. |
A few practical notes for IBM i work specifically:
- A plain forward-only cursor (no
SCROLL, noSENSITIVE, noFOR UPDATE) is the normal default. Reach for the others because a specific job genuinely needs backward movement or positioned changes, not out of habit. FOR READ ONLYis worth adding even when it isn't strictly required -- it states your intent up front and helps prevent an accidental positioned update later, e.g. after some unrelated code change.FOR UPDATE OFshould list only the columns you actually intend to update. Naming fewer columns is more restrictive, not more permissive, and that restriction is useful documentation of intent.ASENSITIVE's automatic choice is fine for most day-to-day cursors. You'll know when you need to override it -- typically when a query's exactSENSITIVE/INSENSITIVEbehavior matters for a scrollable, updatable cursor, and even then, verify the specific behavior for your query shape rather than assuming.
The rule of thumb: declare the plainest cursor that does the job. SCROLL, SENSITIVE, and FOR UPDATE all cost Db2 for i more work to support.
4. Hands-on: a scrollable, read-only cursor
The lifecycle in Section 2 only ever moves NEXT. A SCROLL cursor can move in any direction -- useful for things like an interactive "page back" UI, or simply inspecting a result set out of order.
Member: BROWSEORD, type SQLRPGLE
**FREE
Ctl-Opt DftActGrp(*No) ActGrp(*New);
Dcl-S OrderId Int(10);
Dcl-S OrdCustId Int(10);
Dcl-S OrderDate Date;
Dcl-S OrderStatus Char(1);
Exec Sql
Declare OrdCurs Sensitive Scroll Cursor For
Select Order_Id, Cust_Id, Order_Date, Status
From SQLTUTOR.ORDERS
Order By Order_Id
For Read Only;
Exec Sql Open OrdCurs;
Exec Sql Fetch First From OrdCurs
Into :OrderId, :OrdCustId, :OrderDate, :OrderStatus;
Dsply ('FIRST -> Order ' + %Char(OrderId));
Exec Sql Fetch Next From OrdCurs
Into :OrderId, :OrdCustId, :OrderDate, :OrderStatus;
Dsply ('NEXT -> Order ' + %Char(OrderId));
Exec Sql Fetch Last From OrdCurs
Into :OrderId, :OrdCustId, :OrderDate, :OrderStatus;
Dsply ('LAST -> Order ' + %Char(OrderId));
Exec Sql Fetch Prior From OrdCurs
Into :OrderId, :OrdCustId, :OrderDate, :OrderStatus;
Dsply ('PRIOR -> Order ' + %Char(OrderId));
Exec Sql Fetch Absolute 2 From OrdCurs
Into :OrderId, :OrdCustId, :OrderDate, :OrderStatus;
Dsply ('ABS(2) -> Order ' + %Char(OrderId));
Exec Sql Close OrdCurs;
*InLR = *On;
With the SQLTUTOR sample data (ORDER_ID 5001-5004, sorted ascending), the trace is:
| Fetch | Order returned | Why |
|---|---|---|
FIRST |
5001 | 1st row in the ordered result set |
NEXT |
5002 | one past FIRST |
LAST |
5004 | 4th (last) row |
PRIOR |
5003 | one before LAST |
ABSOLUTE 2 |
5002 | jumps directly to row 2, regardless of current position |
ABSOLUTE n and RELATIVE n are the two you'll use for actual "jump to page N" logic; FIRST/LAST/NEXT/PRIOR cover simple back-and-forth browsing.
5. Updatable cursors: WHERE CURRENT OF
A cursor isn't limited to reading. Declare it FOR UPDATE OF specific columns, and you can modify or delete the row the cursor is currently sitting on -- without having to know or re-supply its key.
These examples intentionally modify SQLTUTOR sample data. Run them only in a practice schema, not against anything you rely on. If you want to rerun earlier Deep Dives' examples exactly afterward, reload the
SQLTUTORsample data from SQL on IBM i first.
Member: BUMPPRICE, type SQLRPGLE
**FREE
Ctl-Opt DftActGrp(*No) ActGrp(*New);
Dcl-S ProdId Int(10);
Dcl-S ProdName Varchar(50);
Dcl-S UnitPrice Packed(9:2);
Dcl-S QtyHand Int(10);
Dcl-S NewPrice Packed(9:2);
Exec Sql
Declare ProdCurs Cursor For
Select Prod_Id, Prod_Name, Unit_Price, Qty_On_Hand
From SQLTUTOR.PRODUCT
Where Qty_On_Hand > 50
For Update Of Unit_Price;
Exec Sql Open ProdCurs;
Exec Sql Fetch ProdCurs Into :ProdId, :ProdName, :UnitPrice, :QtyHand;
Dow SqlCode = 0;
NewPrice = UnitPrice * 1.05;
Exec Sql
Update SQLTUTOR.PRODUCT
Set Unit_Price = :NewPrice
Where Current Of ProdCurs;
Dsply (%Trim(ProdName) + ': ' + %Char(UnitPrice) + ' -> ' + %Char(NewPrice));
Exec Sql Fetch ProdCurs Into :ProdId, :ProdName, :UnitPrice, :QtyHand;
EndDo;
Exec Sql Close ProdCurs;
Exec Sql Commit;
*InLR = *On;
Against the SQLTUTOR sample data, this raises Steel Bracket (qty 1200, 4.50 -> 4.725) and Hydraulic Valve (qty 75, 89.00 -> 93.45) -- Control Panel is skipped because its QTY_ON_HAND (12) doesn't pass the cursor's WHERE.
WHERE CURRENT OF ProdCurs is the whole point here: no WHERE Prod_Id = :ProdId needed on the UPDATE -- Db2 for i already knows exactly which row the cursor is parked on.
Not every SELECT is updatable
A cursor's SELECT has to be simple enough for Db2 for i to know unambiguously which single underlying row a positioned UPDATE/DELETE should touch. A positioned update can fail or be disallowed when the cursor's query includes things like:
- joins across multiple tables
GROUP BYDISTINCTUNION- aggregate expressions (
SUM,COUNT, and similar) - derived/calculated columns
- a read-only view
- any other shape where the "current row" doesn't map unambiguously back to one row in one base table
For updatable cursors, keep the SELECT simple -- a single table, no aggregation -- and declare FOR UPDATE OF only the specific columns you intend to change.
Positioned DELETE
WHERE CURRENT OF isn't only for UPDATE -- the same clause works with DELETE:
DELETE FROM SQLTUTOR.PRODUCT
WHERE CURRENT OF ProdCurs;
This deletes the row the cursor is currently positioned on, the same way the UPDATE above changed it -- shown here for the syntax rather than as a program you should actually run, since deleting through ProdCurs mid-loop would then invalidate that row for any further processing in the same pass. If you tried the equivalent against SQLTUTOR.ORDERS instead, remember SQLTUTOR.ORDER_DETAIL has a foreign key back to it (see SQL on IBM i's referential-integrity section) -- a positioned DELETE on an order header with existing detail lines fails for exactly the same reason a plain DELETE would.
6. Multi-row FETCH: fewer round trips
A plain FETCH ... INTO :host-variables gets one row per call -- one round trip between your program and the database engine per row. For large result sets, that adds up. Db2 for i lets you fetch a batch of rows at once into arrays.
Member: RPTDETAIL, type SQLRPGLE
**FREE
Ctl-Opt DftActGrp(*No) ActGrp(*New);
Dcl-S ProdIdArr Int(10) Dim(3);
Dcl-S QtyArr Int(10) Dim(3);
Dcl-S PriceArr Packed(9:2) Dim(3);
Dcl-S RowsFetched Int(10);
Dcl-S i Int(10);
Exec Sql
Declare DetCurs Cursor For
Select Prod_Id, Qty, Unit_Price
From SQLTUTOR.ORDER_DETAIL
Order By Order_Id, Line_No
For Read Only;
Exec Sql Open DetCurs;
Exec Sql
Fetch Next From DetCurs For 3 Rows
Into :ProdIdArr, :QtyArr, :PriceArr;
Dow SqlCode = 0 Or SqlCode = 100;
RowsFetched = SqlErrd(3);
If RowsFetched = 0;
Leave;
EndIf;
For i = 1 To RowsFetched;
Dsply ('Prod ' + %Char(ProdIdArr(i)) + ' Qty ' + %Char(QtyArr(i))
+ ' @ ' + %Char(PriceArr(i)));
EndFor;
If SqlCode = 100;
Leave;
EndIf;
Exec Sql
Fetch Next From DetCurs For 3 Rows
Into :ProdIdArr, :QtyArr, :PriceArr;
EndDo;
Exec Sql Close DetCurs;
*InLR = *On;
A few details that matter more than the FOR 3 ROWS syntax itself:
- The arrays must be dimensioned at least as large as the row count you request.
FOR 3 ROWSinto an arrayDim(3)is the minimum correct sizing -- requesting more rows than the arrays can hold is a bug, not something the runtime silently truncates safely for you. SQLERRD(3)(the third element of the SQLCA's error-descriptor array, automatically available once you're using embedded SQL) tells you how many rows the last fetch actually returned -- never assume the arrays are fully populated, and only process elements 1 throughSQLERRD(3). On the final batch, if fewer rows remain than the array size, the leftover array elements still hold data from the previous fetch -- reading pastSQLERRD(3)silently reprocesses stale rows.SQLCODE 100can still carry rows. If the last batch is partial, Db2 for i returnsSQLCODE = 100and however many rows it found in that final batch -- which is why the loop condition above isSqlCode = 0 Or SqlCode = 100, and why it checksRowsFetchedbefore deciding there's nothing left, rather than exiting the instant it sees100.- Multi-row fetch is for when you still need per-row RPGLE logic but the result set is large -- it cuts round trips without giving up the ability to process each row in RPGLE. If there's no per-row logic at all, a set-based SQL statement (one
UPDATE/INSERT/SELECTwith aggregation) is almost always the better tool -- see the note at the end of Section 5.
With the SQLTUTOR sample data (6 rows in ORDER_DETAIL, fetched 3 at a time), this happens to divide evenly into two full batches -- try changing FOR 3 ROWS to FOR 4 ROWS to see a genuine partial final batch (4, then 2) and confirm SQLERRD(3) correctly reports 2 on that last fetch.
7. Cursor SQLCODE troubleshooting
Beyond the general embedded SQL codes already covered in the Embedded SQL in RPGLE Deep Dive, a few are specific to cursor mechanics:
| SQLCODE | Meaning |
|---|---|
0 |
Success. |
+100 |
No row found / end of cursor. On a multi-row FETCH, may still carry a partial batch -- see Section 6. |
-501 |
The cursor referenced in a FETCH or CLOSE is not open -- see Section 10 for the most common cause (a COMMIT closing it). |
-502 |
The cursor is already open -- an OPEN was issued twice without an intervening CLOSE. |
-508 |
The cursor is not positioned on a row, so a positioned UPDATE/DELETE (WHERE CURRENT OF) has nothing to act on -- typically means SQLCODE was already +100 (or the cursor was never successfully fetched) before the positioned statement ran. |
-913 |
Lock/deadlock/timeout. Another job may be holding a conflicting lock on the row a positioned update is trying to change -- see Section 10's concurrency note. |
In production, don't stop at the bare code: log SQLCODE, SQLSTATE, the message text, the cursor name, the operation being attempted, and safe key values. "Cursor error" with no other context is exactly as unhelpful here as it would be for any other embedded SQL failure.
8. GET DIAGNOSTICS for cursor errors
SQLCODE alone is often not enough to know why a cursor operation failed -- the same GET DIAGNOSTICS pattern introduced in Embedded SQL in RPGLE applies here too, and is worth reaching for on any cursor error you can't immediately explain from the code alone:
Dcl-S SqlMsg Varchar(500);
Exec Sql
Get Diagnostics Condition 1
:SqlMsg = Message_Text;
Dsply ('Cursor error ' + %Char(SqlCode) + ': ' + %Trim(SqlMsg));
This isn't a full error-handling topic on its own -- see the Embedded SQL in RPGLE Deep Dive for the complete treatment -- just a reminder that it exists and applies just as well to a failed FETCH, OPEN, or positioned UPDATE/DELETE as it does to any other embedded SQL statement.
9. Closing cursors on all exit paths
Every example above calls CLOSE once, on the way out -- but real programs have more than one way out. The pattern to actually follow:
OPEN cursor
Dow (processing loop)
...
If (unexpected error) ;
CLOSE cursor <- close before leaving early, not just at the bottom
Return / handle error
EndIf;
...
EndDo;
CLOSE cursor <- the "happy path" close
- Close every cursor you open, on every path -- the happy path at the bottom of the program is the easy one to remember; the early-exit and error branches are the ones that get forgotten.
WITH HOLD(Section 10) does not mean "leave it open forever." It means the cursor survives aCOMMITinside its own processing loop -- it still needs an explicitCLOSEonce that loop is actually done.- A long-running job that opens cursors and never closes them accumulates open cursor resources for as long as that job runs -- harmless for a short batch program, a real problem for something that stays active for hours.
10. WITH HOLD: surviving a COMMIT
By default, a COMMIT closes every open cursor in the job running under real commitment control. If your program processes a large result set and commits periodically to keep transactions short, the next FETCH after that COMMIT fails with:
SQLCODE = -501 SQLSTATE 24501
"The cursor specified in a FETCH or CLOSE statement is not open."
WITH HOLD is the fix -- it tells Db2 for i to keep the cursor open and correctly positioned across the COMMIT.
Member: PROCORDS, type SQLRPGLE -- walks every order, marks open orders as shipped, and commits every 2 rows to keep the transaction short:
**FREE
Ctl-Opt DftActGrp(*No) ActGrp(*New);
Dcl-S OrderId Int(10);
Dcl-S OrdCustId Int(10);
Dcl-S OrderStatus Char(1);
Dcl-S RowCount Int(10);
RowCount = 0;
Exec Sql
Declare AllOrdCurs Cursor With Hold For
Select Order_Id, Cust_Id, Status
From SQLTUTOR.ORDERS
For Update Of Status;
Exec Sql Open AllOrdCurs;
Exec Sql Fetch AllOrdCurs Into :OrderId, :OrdCustId, :OrderStatus;
Dow SqlCode = 0;
If OrderStatus = 'O';
Exec Sql
Update SQLTUTOR.ORDERS
Set Status = 'S'
Where Current Of AllOrdCurs;
EndIf;
RowCount += 1;
If %Rem(RowCount : 2) = 0;
Exec Sql Commit;
Dsply ('Committed after ' + %Char(RowCount) + ' rows');
EndIf;
Exec Sql Fetch AllOrdCurs Into :OrderId, :OrdCustId, :OrderStatus;
EndDo;
Exec Sql Commit;
Exec Sql Close AllOrdCurs;
*InLR = *On;
This only demonstrates the intended lesson if you compile with real commitment control -- COMMIT(*CHG), not *NONE:
CRTSQLRPGI OBJ(MYLIB/PROCORDS) SRCFILE(MYLIB/QRPGLESRC) SRCMBR(PROCORDS) +
COMMIT(*CHG)
Under COMMIT(*NONE) (see Embedded SQL in RPGLE), EXEC SQL COMMIT doesn't provide real commit/rollback behavior, so the cursor is never actually at risk of closing in the first place and you wouldn't see the point of WITH HOLD. Try removing With Hold from the DECLARE above and recompiling with COMMIT(*CHG) -- the program now fails on the FETCH immediately after the first COMMIT, with SQLCODE -501.
One more edge case worth knowing: ROLLBACK closes held cursors too -- WITH HOLD only protects against COMMIT, not ROLLBACK. That follows from what ROLLBACK means: it undoes everything since the last commit point, including the cursor's progress through the result set.
Concurrency and locking
An updatable cursor's positioned changes interact with IBM i's normal record locking, the same as any other update:
- A positioned
UPDATE/DELETEcan wait on, or fail against, a row another job already has locked -- that'sSQLCODE -913from Section 7. - A long cursor loop that touches many rows can hold locks (and other resources) for longer than a quick, targeted statement would -- especially if it's also holding a transaction open across those rows.
- Keep the transaction scope small: commit regularly, but only where your business rules actually allow a partial batch to be considered "done." Don't turn a cursor loop into something that holds interactive users hostage waiting on locks a large update loop is sitting on.
This is intentionally a short practical note, not the full picture -- record locking and commitment control both deserve (and will get) their own dedicated Deep Dives.
11. Cursors outside RPGLE
Everything above is embedded-SQL/host-language territory -- in interactive SQL (ACS Run SQL Scripts), you never DECLARE/FETCH by hand; results just stream straight to the results grid. Cursors do show up in one more place on IBM i: SQL PL (stored procedures, functions, triggers) has a simplified FOR loop that declares, opens, fetches, and closes a cursor for you automatically --
FOR row_var AS SELECT ... DO
-- row_var.column_name available here, one row at a time
END FOR;
-- which is the SQL PL equivalent of everything in Section 4's manual pattern, condensed to a few lines. The full SQL PL/stored-procedures picture is its own future Deep Dive (see the list at the end of this guide); for now, the manual DECLARE/OPEN/FETCH/CLOSE pattern from this Deep Dive and Embedded SQL in RPGLE is what you'll use in every RPGLE program.
12. Production checklist
- Use a plain forward-only cursor unless you specifically need
SCROLL,SENSITIVE, orFOR UPDATE. - Add
FOR READ ONLYwhen a cursor is only ever fetched from -- it states intent and helps prevent an accidental positioned update. - Use
FOR UPDATE OFnaming only the columns you actually intend to change. - Prefer a single set-based
UPDATE/DELETE/MERGEover a cursor loop whenever the same result is expressible in pure SQL. - Handle
SQLCODE 100correctly -- it's "no more rows," not an error, and can still carry a partial multi-row batch. - Close every cursor you open, on every exit path, not just the happy one.
- After a multi-row
FETCH, process only elements1throughSQLERRD(3). - Use
WITH HOLDonly on cursors that genuinely need to survive aCOMMITinside their own loop -- and stillCLOSEthem when the loop is done. - Keep transactions short; commit only where your business rules allow a partial batch.
- Log
SQLCODE,SQLSTATE, message text (viaGET DIAGNOSTICS), cursor name, and operation on any cursor failure. - Watch for locks and long-running loops -- a positioned update can wait on or fail against another job's lock.
13. Common mistakes
| Mistake | What actually happens |
|---|---|
Declaring SCROLL (or SENSITIVE, or FOR UPDATE) out of habit on every cursor |
Extra overhead Db2 for i doesn't need to pay for a plain forward-only read -- costs real performance at scale |
Writing a cursor loop to do what a single UPDATE/DELETE could do |
Slower (row-by-row round trips instead of one set-based pass) and more code to maintain |
| Trusting a multi-row fetch's arrays are always full | The last batch is often partial; unread SQLERRD(3) means you either miss rows or reprocess stale ones from the previous fetch |
Committing inside a fetch loop without WITH HOLD |
Next FETCH fails with SQLCODE -501, "cursor not open" |
Assuming WITH HOLD protects against ROLLBACK too |
It doesn't -- only COMMIT |
Forgetting FOR UPDATE OF column-list on a cursor you intend to use with WHERE CURRENT OF |
The positioned UPDATE/DELETE fails -- Db2 for i needs to know up front which columns are updatable through this cursor |
Declaring a cursor over a join/aggregate query and expecting WHERE CURRENT OF to work |
Positioned updates need an unambiguous single-row target -- see Section 5 |
14. Practice exercises
Using SQLTUTOR:
- Modify
BROWSEORDto alsoFETCH RELATIVE -2after theABSOLUTE 2fetch, and work out on paper whichORDER_IDyou expect back before running it. - Write a cursor-based program,
CANCELOLD, that finds every order withSTATUS = 'O'and a specificORDER_IDless than a given value, settingSTATUS = 'C'throughWHERE CURRENT OF. Then write the pure-SQL one-liner that does the same thing, and compare. - Change
RPTDETAIL's batch size fromFOR 3 ROWStoFOR 4 ROWSand predict whatSQLERRD(3)will report on each of the two fetches before running it. - Remove
With HoldfromPROCORDS, compile it withCOMMIT(*CHG), run it, and confirm you getSQLCODE -501-- then putWith Holdback and confirm it runs clean.
- #1:
ABSOLUTE 2lands on order 5002 (per the Section 4 trace).RELATIVE -2moves two rows back from the current position -- so from row 2, that lands before row 1, which meansSQLCODE = 100(no row), not a wraparound to the end. - #2: The cursor version needs
Where Current Ofplus the same structure asBUMPPRICE; the pure-SQL version isUPDATE SQLTUTOR.ORDERS SET STATUS = 'C' WHERE STATUS = 'O' AND ORDER_ID < :cutoff;-- no cursor required, same lesson as Section 5. - #3: 6 total rows / batches of 4 -- first fetch returns 4 rows (
SQLERRD(3) = 4), second fetch returns the remaining 2 (SQLERRD(3) = 2,SQLCODE = 100). - #4: The failure happens on the
FETCHimmediately following the firstEXEC SQL COMMITinside the loop -- that's the concrete, reproducible version of the "whyWITH HOLDexists" explanation in Section 10.
15. Interview questions
- What is a SQL cursor?
- Why does RPGLE need cursors if SQL is set-based?
- What is the difference between
DECLARE,OPEN,FETCH, andCLOSE? - What does
SQLCODE +100mean in a cursor loop? - What is the difference between a forward-only cursor and a
SCROLLcursor? - When would you use
FETCH ABSOLUTEorFETCH RELATIVE? - What does
WHERE CURRENT OFdo? - Why should
FOR UPDATE OFlist only intended columns? - Why can a positioned update fail?
- What is multi-row
FETCHand why is it useful? - Why is
SQLERRD(3)important after a multi-rowFETCH? - What problem does
WITH HOLDsolve? - Why is a cursor loop sometimes worse than one set-based
UPDATE?
16. Ask the AI Tutor
Good follow-up prompts once you've worked through the material above:
- "Explain SQL cursors on IBM i with a simple RPGLE example."
- "Show me the standard DECLARE, OPEN, FETCH, CLOSE cursor pattern."
- "Explain SQLCODE +100 in a cursor loop."
- "Explain SCROLL cursor with FETCH FIRST, LAST, PRIOR, NEXT, ABSOLUTE, and RELATIVE."
- "Help me debug SQLCODE -501 after COMMIT."
- "Explain WHERE CURRENT OF with a positioned update."
- "Explain multi-row FETCH and SQLERRD(3)."
- "When should I avoid a cursor and use set-based SQL instead?"
- "Explain WITH HOLD in embedded SQL on IBM i."
- "Give me practice questions on SQL cursors using SQLTUTOR."
17. Key takeaways
- A cursor is the row-by-row bridge between SQL's set-based results and host-language code that can only consume one row at a time.
- The lifecycle is always
DECLARE->OPEN->FETCH-> (optional positionedUPDATE/DELETE) ->CLOSE-- andWHERE CURRENT OFonly makes sense between a successfulFETCHand the next one. SCROLLcursors can moveFIRST/LAST/NEXT/PRIOR/ABSOLUTE/RELATIVE; plain cursors are forward-only and lighter-weight -- default to plain unless you need to scroll.FOR UPDATE OFplusWHERE CURRENT OFmodifies or deletes exactly the row a cursor is parked on -- but not everySELECTis updatable, and a single set-basedUPDATE/DELETE/MERGEis often the better tool anyway.- Multi-row
FETCH ... FOR n ROWScuts round trips for large result sets -- always trustSQLERRD(3)for the real row count, especially on the last, possibly-partial batch. WITH HOLDkeeps a cursor open acrossCOMMIT(notROLLBACK) -- essential for any loop that commits periodically while still fetching, and still needs an explicitCLOSEonce the loop ends.- Close every cursor on every exit path, watch for lock contention on positioned updates, and keep transaction scope small.
What's next: future Deep Dives
This Deep Dive deliberately stays focused on cursor mechanics -- it does not go deep on SQL PL stored procedures, triggers, detailed SQLCODE/SQLSTATE handling, native I/O comparisons, or commitment control internals. Those are natural, planned follow-ups:
- Stored Procedures on IBM i
- Database Triggers on IBM i
- SQL Error Handling with SQLCODE and SQLSTATE
- Native I/O vs SQL Decision Guide
- Commitment Control with SQL and RPGLE
- SQL Performance Basics on IBM i
Check the Deep Dives catalog for their current status.
Related lessons
Related Deep Dives
- Embedded SQL in RPGLEAvailable
- SQL Error Handling with SQLCODE and SQLSTATEComing soon
Have a question?
The AI Tutor is for educational guidance only. It cannot connect to a real IBM i system, execute code, or analyze production code. See the “Ask the AI Tutor” section above for good prompts to start with.
Open the AI Tutor