Apex

Apex Cursors: The End of Batch Apex Pain

If you’ve ever written a Batch Apex class just to process a few thousand records with some control over pagination, you know the pain. The boilerplate. The state management. The Database.Stateful. The 5-minute minimum wait between test runs.

Spring ‘26 changes everything. Apex Cursors let you process up to 50 million records with fine-grained, bidirectional access — no batch class required.


What’s Wrong with Batch Apex?

Batch Apex has been the go-to for large-scale processing since 2010. But it has real friction:

Pain PointBatch ApexApex Cursors
Boilerplate3 methods required (start, execute, finish)Single cursor object
NavigationForward-only, 200 records at a timeBidirectional, any position
StateRequires Database.Stateful (serialization overhead)Built-in serialization
TestingNeeds Test.startTest/stopTest, async executionSynchronous, direct testing
ChainingComplex AsyncApexJob monitoringNative Queueable support
Max recordsLimited by query timeout50 million records
ControlCoarse (batch size only)Fine-grained (fetch any chunk)

How Cursors Work

Think of a cursor as a bookmark in a massive result set. You run a SOQL query once, Salesforce stores the results server-side, and you fetch chunks from any position — forward, backward, or jumping to a specific offset.

         SOQL Result Set (millions of records)
   ┌─────────────────────────────────────────────────┐
   │ Record 1 │ Record 2 │ ... │ Record N │ Record N+1│
   └─────────────────────────────────────────────────┘
         ▲                          ▲
         │                          │
     fetch(0, 200)            fetch(5000, 200)
     "Give me first 200"      "Jump to 5000, give me 200"

The cursor maintains its position across transactions. You can serialize it, pass it to a Queueable job, or even send it to an LWC for pagination.


Basic Usage

Creating a Cursor

// Create a cursor from a SOQL query
Cursor cursor = Database.getCursor(
    'SELECT Id, Name, Industry FROM Account WHERE CreatedDate = THIS_YEAR'
);

// Fetch first 200 records
List<Account> firstPage = (List<Account>) cursor.fetch(0, 200);

// Fetch next 200
List<Account> secondPage = (List<Account>) cursor.fetch(200, 200);

// Jump to record 5000
List<Account> midPage = (List<Account>) cursor.fetch(5000, 200);

Checking Limits

// How many cursor rows have I used?
System.debug('Cursor rows used: ' + Limits.getApexCursorRows());
System.debug('Cursor rows limit: ' + Limits.getLimitApexCursorRows());

// How many cursor instances?
System.debug('Cursors used: ' + Limits.getApexCursors());
System.debug('Cursors limit: ' + Limits.getLimitApexCursors());

Real-World Pattern: Data Migration

Here’s a complete example — migrating Account data in chunks using a Queueable with a cursor:

public class AccountMigrationJob implements Queueable {

    private Cursor accountCursor;
    private Integer currentOffset;
    private Integer batchSize = 200;
    private Integer totalProcessed = 0;

    // First invocation: create the cursor
    public AccountMigrationJob() {
        this.accountCursor = Database.getCursor(
            'SELECT Id, Name, Industry, BillingCountry ' +
            'FROM Account WHERE NeedsMigration__c = true'
        );
        this.currentOffset = 0;
    }

    // Subsequent invocations: resume from offset
    public AccountMigrationJob(Cursor cursor, Integer offset, Integer processed) {
        this.accountCursor = cursor;
        this.currentOffset = offset;
        this.totalProcessed = processed;
    }

    public void execute(QueueableContext ctx) {
        // Fetch next chunk
        List<Account> accounts = (List<Account>)
            accountCursor.fetch(currentOffset, batchSize);

        if (accounts.isEmpty()) {
            // Done! Log completion
            System.debug('Migration complete. Total: ' + totalProcessed);
            return;
        }

        // Process records
        for (Account acc : accounts) {
            acc.Migration_Status__c = 'Completed';
            acc.Migrated_Date__c = Date.today();
        }
        update accounts;

        totalProcessed += accounts.size();
        currentOffset += batchSize;

        // Chain next job with the SAME cursor
        System.enqueueJob(new AccountMigrationJob(
            accountCursor, currentOffset, totalProcessed
        ));
    }
}

// Kick it off
System.enqueueJob(new AccountMigrationJob());

Key differences from Batch Apex:

  • No Database.Stateful needed — the cursor serializes naturally
  • Full control over chunk size and position — not locked to 200
  • Synchronous chaining — no waiting for the platform scheduler
  • Resume from any position — if something fails at record 10,000, restart from there

PaginationCursor: For UI Pagination

The PaginationCursor class is designed for human-facing pagination — like building a paginated data table in LWC:

@AuraEnabled
public static Map<String, Object> getAccounts(String cursorId, Integer page) {
    PaginationCursor pgCursor;

    if (cursorId == null) {
        // First page: create new cursor
        pgCursor = Database.getPaginationCursor(
            'SELECT Id, Name, Industry FROM Account ORDER BY Name',
            50  // page size
        );
    } else {
        // Subsequent pages: resume existing cursor
        pgCursor = Database.getPaginationCursor(cursorId);
    }

    List<Account> records = (List<Account>) pgCursor.fetch(page);

    return new Map<String, Object>{
        'records' => records,
        'cursorId' => pgCursor.getCursorId(),
        'hasNext' => pgCursor.hasNext(),
        'hasPrev' => pgCursor.hasPrevious(),
        'totalRecords' => pgCursor.getTotalSize()
    };
}

The PaginationCursor handles page numbering, has/next/previous state, and total count — all the things you’d normally build yourself.


Governor Limits

LimitValue
Max records per Cursor50,000,000
Max Cursor instances per 24h10,000
Max PaginationCursor instances per 24h200,000
Max records per fetch() call2,000
Cursor lifetimeUntil explicitly closed or transaction end

When to Use What

Need to process > 50M records?
  └─ Yes → Batch Apex (still the only option)
  └─ No
       Need bidirectional navigation?
         └─ Yes → Apex Cursor
         └─ No
              Need UI pagination?
                └─ Yes → PaginationCursor
                └─ No
                     Simple sequential processing?
                       └─ Yes → Apex Cursor (simpler than Batch)
                       └─ No → Batch Apex (scheduled, complex state)

Testing Cursors

Testing is straightforward — no Test.startTest() / Test.stopTest() gymnastics needed:

@isTest
static void testCursorProcessing() {
    // Create test data
    List<Account> testAccounts = new List<Account>();
    for (Integer i = 0; i < 500; i++) {
        testAccounts.add(new Account(Name = 'Test ' + i));
    }
    insert testAccounts;

    // Create cursor and fetch
    Cursor c = Database.getCursor(
        'SELECT Id, Name FROM Account ORDER BY Name'
    );

    List<Account> page1 = (List<Account>) c.fetch(0, 200);
    Assert.areEqual(200, page1.size());

    List<Account> page2 = (List<Account>) c.fetch(200, 200);
    Assert.areEqual(200, page2.size());

    List<Account> page3 = (List<Account>) c.fetch(400, 200);
    Assert.areEqual(100, page3.size()); // Only 100 remaining

    // Verify bidirectional: go back to first page
    List<Account> backToPage1 = (List<Account>) c.fetch(0, 200);
    Assert.areEqual(page1[0].Id, backToPage1[0].Id);
}

Migration Checklist: Batch to Cursor

If you have existing Batch Apex classes that could benefit from cursors, here’s what to evaluate:

  • Does the batch process fewer than 50M records? If yes, cursor is viable
  • Do you need bidirectional access? Cursors shine here
  • Is the batch used for UI pagination? Switch to PaginationCursor
  • Does the batch use Database.Stateful? Cursors handle state naturally
  • Is the batch scheduled? Cursors work in Queueable (which can be scheduled)
  • Does the batch have complex execute() logic? Cursors handle this fine

The Bottom Line

Apex Cursors don’t completely replace Batch Apex — but they cover 80% of the use cases with less code, more control, and easier testing. For data migrations, UI pagination, and sequential processing, they’re the new default.

The next time you reach for implements Database.Batchable, ask yourself: would a cursor be simpler?

What’s your take? Drop a reaction and let me know if you’re switching to cursors or sticking with batch.

How did this article make you feel?

Comments

Loading comments...

Salesforce Tip

Forcenaut is a one-person project. A small coffee helps keep it going.

Buy me a coffee