Apex Cursors Explained: How to Handle Massive Datasets Without Batch Apex



Process millions of records in controlled chunks — no start/execute/finish lifecycle required

If you've hit the SOQL row limit trying to process large datasets, your first instinct was probably Batch Apex. It works, but it comes with overhead — the start/execute/finish lifecycle, limited chaining, and fixed chunk sizes. Apex Cursors offer a lighter, more flexible alternative. Here's how they work and when to use them.

What Are Apex Cursors?

According to the official Apex Developer Guide, Apex cursors let you break up the processing of a SOQL query result into pieces that can be processed within the bounds of a single transaction. They give you the ability to work with large query result sets without actually returning the entire result set at once.

In practice: you run a SOQL query, get back a cursor pointing to the full result set, then pull records out in small batches — 200 at a time, 500 at a time, whatever fits your use case.

The Two Methods You Need to Know

You create a cursor using Database.getCursor() and retrieve records using cursor.fetch(). The cursor.getNumRecords() method tells you the total record count so you know when to stop.

Apex
// Create the cursor
Database.Cursor locator = Database.getCursor(
    'SELECT Id, Name FROM Account WHERE AnnualRevenue > 1000000'
);

// How many records total?
Integer total = locator.getNumRecords();

// Fetch the first 200 starting at position 0
List<Account> batch = locator.fetch(0, 200);

For dynamic queries with bind variables, use Database.getCursorWithBinds() instead, which accepts a query string and a map of bind variables.

Real-World Example: Queueable + Cursor

Cursors are designed to be used with Queueable Apex. You store the cursor and current position as instance variables, process a batch in each execute() call, and re-enqueue the job if records remain. The official Salesforce Developer Guide shows exactly this pattern:

Apex
public class QueryChunkingQueueable implements Queueable {

    private Database.Cursor locator;
    private Integer position;

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

    public void execute(QueueableContext ctx) {

        // Fetch the next 200 records from the current position
        List<Contact> scope = locator.fetch(position, 200);
        position += scope.size();

        // Your business logic here
        for (Contact c : scope) {
            // process each record
        }

        // If more records remain, enqueue the next chunk
        if (position < locator.getNumRecords()) {
            System.enqueueJob(this);
        }
    }
}

💡 How the position tracking worksThe cursor itself is stateless — it doesn't remember where you left off. You track the offset yourself using the position variable. Each time execute() runs, you advance position by the number of records returned, then pass it back into fetch() on the next call.

Cursor vs. Batch Apex — When to Use Which

ScenarioUse CursorUse Batch Apex
Need to chain jobs✅ Yes — works with Queueable chaining❌ Batch chaining is limited
Need flexible chunk sizes✅ Yes — you control the fetch count❌ Fixed scope size
Simple fire-and-forget processing⚠️ More setup required✅ Simpler lifecycle
Need start/finish callbacks❌ Not available✅ Built in
High-volume async processing✅ Up to 50M rows per cursor✅ Also handles large volumes

Governor Limits to Know

Apex Cursors have their own set of limits, separate from standard SOQL limits:

  • Maximum rows per cursor: 50 million (sync and async)
  • Maximum fetch calls per transaction: 10
  • Maximum cursors per day: 10,000
  • Maximum rows per day (aggregate): 100 million

You can monitor usage in code using the new Limits class methods: Limits.getApexCursorRows()Limits.getLimitApexCursorRows()Limits.getFetchCallsOnApexCursor(), and Limits.getLimitFetchCallsOnApexCursor().

Gotchas to Watch Out For

⚠️ 1. Maximum 10 fetch calls per transactionEach call to cursor.fetch() counts against the limit of 10 fetch calls per transaction. Design your chunk size accordingly — fetching 200 records per call means you can process up to 2,000 records per transaction before needing to re-enqueue.
⚠️ 2. The cursor is stateless — you own the positionThe cursor does not track where you are. If you lose the position variable between enqueue calls, you'll start over from 0. Make sure position is an instance variable on your Queueable class, not a local variable.
⚠️ 3. Handle TransientCursorExceptionSystem.TransientCursorException indicates a temporary issue. The Apex Developer Guide notes this transaction can be retried — build retry logic into your Queueable if you're processing critical data.
⚠️ 4. Cursor results are available for 2 daysAccording to the Salesforce Limits documentation, cursors and their related query results are available for 2 days. If your processing job runs longer than that, the cursor will no longer be valid.

Summary & Key Takeaways

🚀 Apex Cursors — Quick Reference
  • Create with Database.getCursor(query) or Database.getCursorWithBinds(query, binds)
  • Fetch records with cursor.fetch(position, count) — you manage the position offset
  • Check total records with cursor.getNumRecords()
  • Designed to be used with Queueable Apex for chained async processing
  • Up to 50M rows per cursor, 10 fetch calls per transaction, 100M rows per day
  • Cursor results expire after 2 days
  • Monitor limits via Limits.getApexCursorRows() and Limits.getFetchCallsOnApexCursor()

Apex Cursors sit nicely between synchronous SOQL and the full Batch Apex lifecycle. If you find yourself writing Batch Apex just to loop through a large dataset and process it in chunks — and you don't need the start/finish callbacks — Cursors with Queueable are worth looking at.

📄 Sources: Apex Cursors — Apex Developer Guide  |  API Query Cursor Limits — Salesforce Developer Limits  |  Summer '24 Developer Guide — Salesforce Developer Blog

 

 If you have any question please leave a comment below.

If you would like to add something to this post please leave a comment below.
Share this blog with your friends if you find it helpful somehow !

Thanks

Post a Comment

0 Comments