XSCREENSAVER / 3D PIPES

[readonly] markdown buffer

Apex Cursors

Feb 18, 2026 · 6 min read

I have been playing with Apex cursors now that they are generally available, and they solve a practical problem: handling large SOQL result sets without forcing everything into one rigid processing pattern.

Before this, the choice was often:

  • batch Apex everywhere,
  • custom query loops with manual paging,
  • or brittle retry logic when async runs failed halfway.

Cursors give me a cleaner model. I can fetch in chunks, keep a clear checkpoint, and continue predictably.

I use two cursor APIs for two different outcomes:

  • Database.Cursor when I need async, chunked backend processing.
  • Database.PaginationCursor when I need predictable list pages in the UI.

Standard cursor: when I use it, and why

I use Database.Cursor when the workload is too large or too uneven for a one-pass transaction.

What this gives me:

  • I can process records in bounded chunks instead of hoping one job run can finish everything.
  • I can resume from a known position if the next queueable hop is needed.
  • I can reduce duplicate/skip bugs because continuation state is explicit (position).

Typical use cases for me are cleanup jobs, archival jobs, and high-volume update passes where each record can take different effort.

The cursor gives me the ordered rows. I keep two pieces of state:

  • locator (the cursor),
  • position (the next row index to read).
public class ContactCleanupWorker implements Queueable {
    private Database.Cursor locator;
    private Integer position;
    private static final Integer CHUNK_SIZE = 200;

    public ContactCleanupWorker() {
        locator = Database.getCursor(
            'SELECT Id, LastActivityDate FROM Contact WHERE LastActivityDate = LAST_N_DAYS:400'
        );
        position = 0;
    }

    private ContactCleanupWorker(Database.Cursor locator, Integer position) {
        this.locator = locator;
        this.position = position;
    }

    public void execute(QueueableContext ctx) {
        List<Contact> scope = locator.fetch(position, CHUNK_SIZE);
        if (scope.isEmpty()) {
            return;
        }

        for (Contact c : scope) {
            c.Description = 'Processed by queueable cursor';
        }
        update scope;

        Integer nextPosition = position + scope.size();
        if (nextPosition < locator.getNumRecords()) {
            System.enqueueJob(new ContactCleanupWorker(locator, nextPosition));
        }
    }
}

What I check in this pattern:

  • position is always advanced by exactly the number of rows processed.
  • the next chunk only runs if the cursor still has rows.

If position is wrong, production gets silent correctness bugs: duplicates or skips that are painful to unwind.

traceview://apex-cursor
cursorContact cleanup
chunk200 rows
checkpointposition 0
  1. 0000
  2. 0200
  3. 0400
  4. 0600
  5. 0800
  6. 1000
  7. 1200
  8. 1400
  9. 1600
  10. 1800
  11. 2000
  12. 2200
  13. 2400
  14. 2600
  15. 2800
  16. 3000

fetch rows 0—199

failure at rows 400—599 resume from the saved position no skipped or repeated chunk
Each fetch advances one bounded window. If a job fails, the durable position tells the next attempt exactly where to resume.

Pagination cursor: when I use it, and why

I use this where the consumer is a list in the UI and users expect page-by-page consistency.

Database.PaginationCursor pagCursor = Database.getPaginationCursor(
    'SELECT Id, Name FROM Account ORDER BY CreatedDate DESC'
);
Database.CursorFetchResult page = pagCursor.fetchPage(0, 20);

List<Account> rows = page.getRecords();
Integer next = page.getNextIndex();

while (!page.isDone()) {
    page = pagCursor.fetchPage(next, 20);
    rows.addAll(page.getRecords());
    next = page.getNextIndex();
}

This is useful in UI scenarios because pages stay complete when records are deleted between fetches.

In practical terms, that means the user sees a stable page size instead of gaps.

traceview://pagination-stability
between page fetchesrecord 03 is deleted
page 01 already shown
  1. 01
  2. 02
  3. 03
  4. 04
  5. 05
offset-style next page
  1. 07
  2. 08
  3. 09
  4. 10
06 skipped as positions shifted
pagination cursor
  1. 06
  2. 07
  3. 08
  4. 09
  5. 10
the requested page remains complete
Deleting a record after the first page shifts ordinary positions and can create a skip. The pagination cursor continues with a complete next page.

Which one I pick first

I reach for Database.Cursor first when I need explicit continuation control across queueable jobs.

I reach for Database.PaginationCursor when page behavior is the requirement and queueable orchestration is not.

For flows that need fixed batch lifecycle semantics (start/execute/finish behavior), I still use batch Apex.

Notes

  • API version: 66.0+.
  • Standard cursor result cap: 50,000,000.
  • Pagination cursor result cap: 100,000.
  • Pagination page size max: 2000.