Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

Short answer: Apex Cursors let you page through a large query result inside ordinary synchronous or queueable Apex, fetching up to 2,000 rows at a time from a stable server-side result set, without the ceremony of a Batchable class. Reach for them when you need Batch Apex’s row capacity but not its scheduling model. Keep using Batch Apex when you actually want the platform to manage chunking, retries and parallelism for you.

The mechanism, plainly

You call Database.getCursor(query) and get back a Database.Cursor that holds a server-side position in a result set. You then ask it for slices with fetch(position, count). The cursor’s contents are fixed at creation, so rows inserted after you opened it do not appear, which is the property that makes paging correct.

Database.Cursor cur = Database.getCursor(
    'SELECT Id, Name, Status__c FROM Shipment__c WHERE Status__c = \'Pending\''
);

Integer total = cur.getNumRecords();
Integer fetched = 0;

while (fetched < total) {
    List<Shipment__c> slice = (List<Shipment__c>) cur.fetch(fetched, 200);
    // work on this slice
    fetched += slice.size();
}

That loop is illustrative rather than advisable — run it synchronously over a large cursor and you will hit heap or CPU long before you finish. The realistic shape is one slice per transaction, with a queueable chaining itself and carrying the position forward.

The limits that decide whether this fits

Constraint Value
Rows per fetch call 2,000
Rows fetched per transaction Counts against the 50,000 query-rows limit
Cursor lifetime Two days from creation
Where cursors can be created Synchronous and asynchronous Apex, not from a Batchable start

The row-count charge is the part that surprises people. A fetch is a query as far as the governor is concerned, so twenty-five fetches of 2,000 rows exhausts the transaction’s 50,000 rows exactly as a single large SOQL would. Cursors move the ceiling on how much you can address, not on how much you can process at once.

The pattern that actually works: cursor plus queueable chain

public class ShipmentCursorJob implements Queueable {

    private final Database.Cursor cur;
    private final Integer position;

    public ShipmentCursorJob(Database.Cursor cur, Integer position) {
        this.cur = cur;
        this.position = position;
    }

    public void execute(QueueableContext ctx) {
        Integer total = cur.getNumRecords();
        Integer size  = Math.min(200, total - position);
        if (size <= 0) { return; }

        List<Shipment__c> slice = (List<Shipment__c>) cur.fetch(position, size);

        for (Shipment__c s : slice) {
            s.Status__c = 'Processed';
        }
        update slice;

        Integer next = position + size;
        if (next < total && !Test.isRunningTest()) {
            System.enqueueJob(new ShipmentCursorJob(cur, next));
        }
    }
}

The cursor serialises with the job, so the chain resumes from a stable result set rather than re-running the query and picking up rows that changed underneath it. That is the concrete advantage over “query with an Id watermark and re-query each time”, which is the pattern this replaces.

Note the Test.isRunningTest() guard. Chaining from a test throws, and this is the line everyone forgets.

Cursors versus Batch Apex: how to choose

Apex Cursors Batch Apex
Chunking You control position and size Platform decides, you set scope
Concurrency Serial, unless you build otherwise Platform may run chunks in parallel
Failure handling Yours to write Per-chunk isolation for free
Result set stability Fixed at cursor creation Fixed at QueryLocator creation
Startup cost Immediate Queued behind other async work
Row ceiling Cursor size, 2,000 per fetch 50 million via QueryLocator

My rule: if the work is naturally a pipeline where each step decides what the next step does, use a cursor and a chain. If the work is “apply the same operation to every row and I do not care about order”, use Batch Apex and let the platform parallelise it.

The tradeoff running through that table is control against free resilience. Batch Apex gives you per-chunk isolation and retry behaviour you would otherwise write yourself. Cursors give you exact control over slice boundaries and immediate execution. Choosing cursors means accepting that error handling is now your code.

Where cursors genuinely win

You need results now, not when the queue gets to you. Batch jobs sit behind whatever else the org has queued. A cursor in a queueable starts far sooner, which matters for user-initiated bulk operations.

Slice size has to vary. If each record may trigger a callout, you need small slices. If most records are no-ops, large ones. A Database.Cursor lets you compute the next size from what you just saw. Batch scope is fixed for the life of the job.

The operation needs a stable snapshot across transactions. Re-querying between transactions gives you a moving target. A cursor does not.

Where they lose

Past a few hundred thousand rows, the queueable chain becomes long and serial, and a two-day cursor lifetime starts to matter. Batch Apex with a QueryLocator addresses up to 50 million rows and may run chunks concurrently. For a genuine bulk sweep, Batch Apex remains the better tool and cursors are not a replacement for it.

Frequently asked

Do fetched rows count against the 50,000-row governor limit?

Yes. Each fetch adds its rows to the transaction’s total, so plan slice sizes against 50,000 per transaction exactly as you would with ordinary SOQL.

What happens if I fetch past the end of the cursor?

You get an exception rather than an empty list, so compute the remaining count with getNumRecords() and clamp the size before every fetch. That clamp is the single most important line in a cursor loop.

Do rows inserted after I create the cursor show up?

No. The result set is fixed when the cursor is created. That is usually what you want, but it does mean a long-running chain works from a snapshot that gets staler as it goes — say so in the design rather than discovering it in reconciliation.

Can I create a cursor inside a Batch Apex execute method?

Creating one from a Batchable start method is not supported. Nesting a cursor inside batch execution is usually a sign the job is doing two different things and should be two jobs.

How do I test a cursor without chaining forever?

Guard the System.enqueueJob call with Test.isRunningTest() and assert on the first slice, then unit-test the per-slice logic directly with an ordinary list. Testing the chain end to end is not possible in a single test context and is not worth contorting the design for.


Reference: Apex Cursors and Asynchronous Apex, Salesforce Apex Developer Guide; Execution Governors and Limits.