Skip to main content
← 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

FeatureApex CursorsBatch Apex
Max records50 million50 million
Code complexitySimpler3 required methods
Bidirectional navigationYesNo
Dynamic chunk sizeYesFixed per job
Production readyBetaGA
State managementManualBuilt-in (Stateful)

Job Execution Limits

This is where the real differences show:

LimitCursors + QueueableBatch Apex
Concurrent jobs50 per transaction5 org-wide
Chaining depthUnlimited (prod)Chain from finish() only
Jobs in queueQueueable queue100 in flex queue
Start methodN/A1 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

LimitValue
Max rows per cursor50 million
Max fetch calls per transaction10
Max cursors per day10,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 WhenUse Batch When
Simpler code preferredNeed GA stability
Dynamic chunk sizes neededBuilt-in state management needed
Avoiding batch job queueTeam knows Batch well
Chaining with QueueableNeed start/execute/finish hooks

Key Takeaways

  1. Not a replacement — Both solve the same problems
  2. Simpler syntax — Less boilerplate code
  3. Better concurrency — Avoids 5 batch job limit
  4. Still Beta — Test before production use

References