← Back to Blog
Apex Cursors: A Simpler Alternative to Batch Apex
Salesforce Summer '24 introduced Apex Cursors (Beta). Cursors don't do anything you couldn't already do with Batch Apex — but they make large data processing simpler and cleaner.
What Cursors Actually Are
A way to fetch SOQL results in chunks using fetch(position, count) instead of building a full Batch Apex class with start/execute/finish methods.
Database.Cursor cursor = Database.getCursor('SELECT Id FROM Contact');
List<Contact> chunk = cursor.fetch(0, 200); // Get first 200
List<Contact> next = cursor.fetch(200, 200); // Get next 200
Honest Comparison
| Feature | Apex Cursors | Batch Apex |
|---|---|---|
| Max records | 50 million | 50 million |
| Code complexity | Simpler | 3 required methods |
| Bidirectional navigation | Yes | No |
| Dynamic chunk size | Yes | Fixed per job |
| Production ready | Beta | GA |
| State management | Manual | Built-in (Stateful) |
Job Execution Limits
This is where the real differences show:
| Limit | Cursors + Queueable | Batch Apex |
|---|---|---|
| Concurrent jobs | 50 per transaction | 5 org-wide |
| Chaining depth | Unlimited (prod) | Chain from finish() only |
| Jobs in queue | Queueable queue | 100 in flex queue |
| Start method | N/A | 1 at a time org-wide |
Why this matters:
- Batch Apex: Only 5 jobs run concurrently across your entire org. Multiple processes compete for slots.
- Cursors + Queueable: No org-wide limit. Chain unlimited jobs in production.
⚠️ Dev/Trial orgs: Queueable chain depth limited to 5.
Example: Mass Update Stale Contacts
With Batch Apex
public class StaleContactBatch implements Database.Batchable<SObject> {
public Database.QueryLocator start(Database.BatchableContext ctx) {
return Database.getQueryLocator(
'SELECT Id, Status__c FROM Contact WHERE LastActivityDate < LAST_N_DAYS:400'
);
}
public void execute(Database.BatchableContext ctx, List<Contact> scope) {
for (Contact c : scope) {
c.Status__c = 'Inactive';
}
update scope;
}
public void finish(Database.BatchableContext ctx) { }
}
// Competes for 5 org-wide batch slots
Database.executeBatch(new StaleContactBatch(), 200);
With Apex Cursors
public class StaleContactJob implements Queueable {
private Database.Cursor cursor;
private Integer position = 0;
public StaleContactJob() {
cursor = Database.getCursor(
'SELECT Id, Status__c FROM Contact WHERE LastActivityDate < LAST_N_DAYS:400'
);
}
public void execute(QueueableContext ctx) {
List<Contact> contacts = cursor.fetch(position, 200);
if (contacts.isEmpty()) return;
for (Contact c : contacts) {
c.Status__c = 'Inactive';
}
update contacts;
position += contacts.size();
if (position < cursor.getNumRecords()) {
System.enqueueJob(this);
}
}
}
// Doesn't compete for batch slots
System.enqueueJob(new StaleContactJob());
Governor Limits
| Limit | Value |
|---|---|
| Max rows per cursor | 50 million |
| Max fetch calls per transaction | 10 |
| Max cursors per day | 10,000 |
| Max rows per day (aggregate) | 100 million |
Source: Salesforce Developer Limits
Exception Handling
try {
List<Contact> records = cursor.fetch(position, 200);
} catch (System.TransientCursorException e) {
System.enqueueJob(this); // Manual retry
} catch (System.FatalCursorException e) {
System.debug(LoggingLevel.ERROR, e.getMessage());
}
⚠️ No auto-retry. You implement retry logic yourself.
When to Use What
| Use Cursors When | Use Batch When |
|---|---|
| Simpler code preferred | Need GA stability |
| Dynamic chunk sizes needed | Built-in state management needed |
| Avoiding batch job queue | Team knows Batch well |
| Chaining with Queueable | Need start/execute/finish hooks |
Key Takeaways
- Not a replacement — Both solve the same problems
- Simpler syntax — Less boilerplate code
- Better concurrency — Avoids 5 batch job limit
- Still Beta — Test before production use