Log Entry

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.

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.

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.