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.