Platform
Apex Cursors: processing millions of records without Batch Apex, and the limits that still bite
Spring ’26 made Apex Cursors generally available, and the reflexive reaction is "Batch Apex is dead." It is not, but there is now a better tool for a large class of high-volume jobs. Here is what a cursor is, the Queueable pattern that makes it work, the four governor limits nobody reads until they hit one, and when to still reach for Batch.
For fifteen years, the answer to “I need to process more records than a single transaction allows” was Batch Apex. You implemented Database.Batchable, wrote a start method that returned a QueryLocator, and handed the platform the whole problem: it chunked your query, spun up execute contexts, and metered each one against fresh governor limits. It worked, and it still works. But anyone who has shipped a non-trivial batch job knows the tax: the class boilerplate, the Database.Stateful gymnastics to carry a running total between chunks, the scheduling and monitoring, and the flex-queue contention when five batch jobs all want to run at 2 a.m.
Spring ’26 made Apex Cursors generally available, and the reflexive take across the ecosystem was “Batch Apex is dead.” That is wrong, and reading it that way will get you into trouble. What is true is more useful: for a large and common class of high-volume jobs, there is now a lower-friction, more precise tool, and the teams who understand where its limits sit will reach for it correctly. This is what a cursor is, how to drive one across a chain of Queueables, and the four limits that decide whether your design survives contact with production data.
What a cursor is
A cursor is a pointer to the result set of a SOQL query, not the result set itself. You hand the platform a query, it holds the materialized results server-side, and it hands you back a lightweight handle. You then pull records off that handle in slices, a few hundred at a time, instead of loading the whole thing into your transaction’s heap at once.
The API is small. You create a cursor with Database.getCursor (or Database.getCursorWithBinds when your query uses bind variables), ask it how many records it points at with getNumRecords(), and pull a slice with fetch(position, count):
// Point a cursor at a large result set, nothing is loaded yet.
// Use getCursorWithBinds if your query needs bind variables.
Database.Cursor cur = Database.getCursor(
'SELECT Id, Amount, CloseDate FROM Opportunity WHERE StageName = \'Closed Won\''
);
Integer total = cur.getNumRecords(); // total rows the cursor points at
Integer position = 0;
Integer pageSize = 2000;
// Pull one slice into heap, process it, move the pointer.
List<Opportunity> slice = cur.fetch(position, pageSize);
// ... do work on this slice ...
position += slice.size();
The distinction from a plain [SELECT ...] query is the whole point. A normal SOQL query returning two million rows never runs. You hit the 50,000-row-per-transaction limit and get a System.LimitException long before your logic executes. A cursor lets that same query exist against all two million rows, and you decide how much of it to bring into memory and when. The heap pressure and the row limit that kill a naive query become something you meter yourself.
Two things a cursor is not. It is not the same feature as the Apex pagination cursors that landed alongside it for Lightning Web Components: those are tuned for infinite-scroll UI and are explicitly not safe for large background exports. And it is not a magic wand that dissolves every governor limit, which is the assumption that gets people burned. Keep those two facts in your head and the rest follows.
The pattern that makes it work: cursor plus Queueable
A cursor by itself lives inside one transaction, and one transaction cannot process millions of records: the CPU and DML limits alone forbid it. The pattern that unlocks the volume is a cursor carried across a chain of Queueable jobs, where each link in the chain is a fresh transaction with fresh governor limits.
The cursor is serializable, so you store it as an instance field on the Queueable, process one slice per execution, advance your position, and re-enqueue yourself if records remain:
public class OppCursorJob implements Queueable {
private final Database.Cursor cur;
private final Integer position;
private static final Integer PAGE = 2000;
// Kick off: build the cursor once, start at position 0.
public OppCursorJob(String stage) {
this.cur = Database.getCursor(
'SELECT Id, Amount FROM Opportunity WHERE StageName = \'' +
String.escapeSingleQuotes(stage) + '\''
);
this.position = 0;
}
// Continue: reuse the same cursor from the previous link.
private OppCursorJob(Database.Cursor cur, Integer position) {
this.cur = cur;
this.position = position;
}
public void execute(QueueableContext ctx) {
Integer total = cur.getNumRecords();
if (position >= total) return;
Integer count = Math.min(PAGE, total - position);
List<Opportunity> slice = (List<Opportunity>) cur.fetch(position, count);
// ... your per-slice work: transform, DML, callout, etc. ...
Integer next = position + slice.size();
if (next < total && !Test.isRunningTest()) {
System.enqueueJob(new OppCursorJob(cur, next)); // fresh limits
}
}
}
The mechanics that matter here:
- The cursor is created once, at the head of the chain, and passed forward. You do not re-query on every link. You re-use the same server-side result set, which is why you get a consistent view of the data across the whole run even if records change underneath you mid-job.
- You own the position. The cursor does not remember where you left off; you track the offset and pass it to the next Queueable. Get this wrong and you either skip records or reprocess them.
- Each link is a clean transaction. CPU time, DML rows, heap, all reset when the next Queueable starts. That is what lets the chain scale past what any single execution could do.
If that shape looks familiar, it should: it is the same “do a bounded chunk, then hand off” discipline that keeps Flow, Apex, or Agentforce automation inside limits, just applied to your own hand-rolled loop instead of a platform-managed one.
The four limits nobody reads until they hit one
This is the section that separates a cursor design that ships from one that fails a load test. The GA limits are generous, but they are specific, and three of them are aggregate, meaning they are shared across your whole org for a rolling window, not scoped to your one job.
1. 50 million rows per cursor. A single cursor can point at up to 50 million rows, synchronous or asynchronous. This is the headline number and it is real. It is what lets a cursor address datasets that Batch Apex’s own scale would struggle with. It is also a ceiling: a query that would return more than 50 million rows cannot be a cursor.
2. Ten fetch calls per transaction. Here is the one that surprises people. Within a single transaction you may call fetch at most ten times. Not ten thousand, ten. This is precisely why the Queueable chain is not optional for large jobs: if your slices are 2,000 records, ten fetches is 20,000 records per transaction, and then you must hand off to a new transaction to reset the counter. Design your page size and your chunk-per-transaction around this number, not around wishful thinking. You can inspect it at runtime with Limits.getFetchCallsOnApexCursor() against Limits.getLimitFetchCallsOnApexCursor().
3. Fetched rows still count against your SOQL row limit. A cursor lets the query exceed 50,000 rows, but the rows you fetch into a transaction still count toward that transaction’s 50,000-SOQL-rows governor limit. Fetch a 2,000-row slice and you have spent 2,000 of your 50,000 for that transaction. Track it with Limits.getApexCursorRows() and Limits.getLimitApexCursorRows(). This is the subtlety that turns “cursors bypass governor limits” into the more honest “cursors let you manage governor limits across transactions.”
4. The daily aggregates: 10,000 cursors and 100 million rows per day, expiring after two days. Your org can create up to 10,000 cursors per day and fetch up to 100 million cursor rows per day in aggregate across every job that uses them. A cursor’s server-side results also expire after roughly two days, a chained job that stalls in the flex queue for that long will find its cursor gone. If you are running many cursor-based jobs, these org-wide ceilings, not the per-cursor 50 million, are the ones you will hit first.
The mental model: the per-cursor limit is huge, the per-transaction limits are small and force the chaining discipline, and the daily aggregates are the ones a busy org bumps into. Size your page count against limit #2, and your job cadence against limit #4.
One more thing cursors do not do: they do not reset CPU time or DML statement limits within a transaction. They relieve heap pressure and the query-row ceiling, which is a big deal for read-heavy transforms, but if your per-record work is DML-heavy or CPU-heavy, you still bound the slice size for those reasons, exactly as you would in any governor-limit-aware Apex.
Cursors versus Batch Apex: the honest decision
Batch Apex is not dead. Here is where each one wins.
Reach for a cursor when:
- The job is fundamentally a read-and-transform over a large result set, and you want a consistent snapshot across the run.
- You want the processing loop in your own code (the control flow, the position, the chunking) instead of surrendered to a platform-managed
executecadence. - You are already living in Queueable land and want one asynchronous idiom for the whole pipeline rather than mixing Queueable and Batchable.
- You need to interleave cursor reads with other logic that Batch Apex’s rigid
start/execute/finishshape makes awkward.
Stay with Batch Apex when:
- You want the platform to manage chunking and parallelism for you and you do not want to hand-roll the offset bookkeeping, for a straightforward “touch every record in this query” job,
Database.Batchableis still less code and less to get wrong. - You rely on
Database.Stateful, batch chaining, or thefinishhook for post-processing and notifications. - Your job is scheduled, long-running, and you want the mature monitoring, retry, and Apex-job UI the batch framework already gives you.
The trap in “Batch Apex is dead” is that it pushes teams to rewrite working batch jobs into cursor-plus-Queueable chains for no benefit, adding hand-managed position tracking where the platform used to handle it. Rewrite when the new tool removes real friction (a snapshot-consistent read, a Queueable-native pipeline, control the batch shape denied you) not because a release note is exciting.
Where this fits in a modern org
Cursors are a data-volume tool, and data volume is a symptom worth reading. If you are reaching for a 50-million-row cursor to sweep an object every night, the prior question is whether all fifty million rows should be in your transactional store at all, a lot of that is a candidate for archiving to Big Objects or for living in a warehouse you query with zero-copy from Data 360 instead of hauling through Apex. Cursors make the big sweep survivable; they do not make it wise by default, and unbounded data growth is one of the quieter forms of Salesforce technical debt.
There is also an AI angle that makes this timely. As agents start operating over large record sets, the same territory Agentforce Grid is built for, the underlying “process N thousand records without a batch job” problem shows up in the actions those agents invoke. A cursor is a clean primitive for an Apex invocable action that has to reason over more data than a single query can return, and for pulling large result sets into Apex from Data 360 for downstream work.
The takeaways, compressed:
- A cursor is a server-side pointer to a SOQL result set;
getCursor,getNumRecords, andfetch(position, count)are the whole surface you need. - The volume unlock is cursor plus a Queueable chain: one cursor, carried forward, one slice per transaction, you own the position.
- Respect the four limits: 50M rows per cursor, 10 fetches per transaction, fetched rows counting against the 50K SOQL limit, and the 10,000-cursor / 100M-row daily aggregates, and instrument them with the new
Limitsmethods before you trust a design. - Cursors relieve heap and query-row pressure, not CPU or DML, size your slices accordingly.
- Batch Apex is not dead. Use a cursor when you want snapshot-consistent reads and control of the loop; keep Batch when you want the platform to manage chunking, state, and monitoring for you.