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 Point | Batch Apex | Apex Cursors |
|---|---|---|
| Boilerplate | 3 methods required (start, execute, finish) | Single cursor object |
| Navigation | Forward-only, 200 records at a time | Bidirectional, any position |
| State | Requires Database.Stateful (serialization overhead) | Built-in serialization |
| Testing | Needs Test.startTest/stopTest, async execution | Synchronous, direct testing |
| Chaining | Complex AsyncApexJob monitoring | Native Queueable support |
| Max records | Limited by query timeout | 50 million records |
| Control | Coarse (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
| Limit | Value |
|---|---|
| Max records per Cursor | 50,000,000 |
| Max Cursor instances per 24h | 10,000 |
| Max PaginationCursor instances per 24h | 200,000 |
| Max records per fetch() call | 2,000 |
| Cursor lifetime | Until 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?
Salesforce Tip
Enjoyed this article?
Forcenaut is a one-person project. A small coffee helps keep it going.
Buy me a coffee
Comments