Paginates large SOQL result sets up to 50M records using Salesforce Apex Cursor API with cursor navigation, Queueable chaining, and LWC pagination. Replaces OFFSET for large datasets.
How this skill is triggered — by the user, by Claude, or both
Slash command
/salesforce-claude-code:sf-apex-cursorThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
The `Cursor` class (GA Spring '26) enables efficient pagination through up to 50 million SOQL rows without the 2,000-row OFFSET limit. Use it for large dataset processing that previously required chunked OFFSET patterns or raw Batch Apex.
The Cursor class (GA Spring '26) enables efficient pagination through up to 50 million SOQL rows without the 2,000-row OFFSET limit. Use it for large dataset processing that previously required chunked OFFSET patterns or raw Batch Apex.
Reference: @../_reference/APEX_CURSOR.md
@AuraEnabled methods with server-side cursor pagination for LWC components| Approach | Max Records | Heap Impact | Best For |
|---|---|---|---|
SOQL OFFSET | 2,000 | Full result set in heap | Small UI pagination |
| Batch Apex | Unlimited | Per-execute governor reset | Background mass processing |
Cursor class | 50,000,000 | Per-page only | Large paginated reports, async chaining, LWC infinite scroll |
| Record Count | Best Approach | Why |
|---|---|---|
| < 200 | Standard SOQL with LIMIT | Simple, no overhead |
| 200 - 2,000 | OFFSET pagination | Adequate performance, simpler code |
| 2,000 - 50,000 | Cursor | OFFSET degrades above 2K; Cursor maintains constant performance |
| 50,000+ | Cursor + Queueable chaining | Single cursor handles up to 50M records |
| Batch processing | Database.QueryLocator | Full governor reset per execute chunk |
Key insight: OFFSET forces the database to skip N rows on every request. At 10,000 OFFSET, the DB scans and discards 10K rows. Cursor maintains a server-side pointer with no scanning overhead regardless of position.
// Open a cursor — returns a server-side pointer, not the data
Database.Cursor cursor = Database.getCursor('SELECT Id, Name FROM Account ORDER BY Id');
// Fetch a page of records starting at offset
List<SObject> page = cursor.fetch(offset, pageSize);
// Total number of records the cursor can return
Integer total = cursor.getNumRecords();
// Serialize the cursor for use across transactions (Queueable chaining)
String cursorId = cursor.getId();
// Re-open a serialized cursor in a new transaction
Database.Cursor resumed = Database.getCursor(cursorId);
// Always close when done to release server-side resources
cursor.close();
Process data page-by-page without loading everything into heap. Do not accumulate all rows in memory.
public class LargeAccountAuditor {
public static AuditSummary auditAccounts() {
Database.Cursor cursor = Database.getCursor(
'SELECT Id, Name, AnnualRevenue, Industry FROM Account ORDER BY Name'
);
Integer pageSize = 2000;
Integer offset = 0;
Decimal totalRevenue = 0;
Integer totalCount = 0;
List<Audit_Log__c> logsToInsert = new List<Audit_Log__c>();
try {
while (offset < cursor.getNumRecords()) {
List<Account> page = cursor.fetch(offset, pageSize);
for (Account acc : page) {
totalRevenue += acc.AnnualRevenue != null ? acc.AnnualRevenue : 0;
totalCount++;
if (acc.AnnualRevenue == null || acc.AnnualRevenue == 0) {
logsToInsert.add(new Audit_Log__c(
Record_Id__c = acc.Id,
Finding__c = 'Missing AnnualRevenue'
));
}
}
// page goes out of scope — GC-eligible, heap stays flat
if (logsToInsert.size() >= 5000) {
insert logsToInsert;
logsToInsert.clear();
}
offset += pageSize;
}
if (!logsToInsert.isEmpty()) insert logsToInsert;
} finally {
cursor.close();
}
return new AuditSummary(totalCount, totalRevenue);
}
}
Serialize a Cursor by ID and pass it across Queueable jobs to process 50M records across chained async transactions.
public class LargeLeadProcessorQueueable implements Queueable {
private String cursorId;
private Integer offset;
private static final Integer PAGE_SIZE = 2000;
// First call — no cursor yet
public LargeLeadProcessorQueueable() {
Database.Cursor cursor = Database.getCursor(
'SELECT Id, Status, LeadSource FROM Lead WHERE IsConverted = false ORDER BY Id'
);
this.cursorId = cursor.getId();
this.offset = 0;
cursor.close(); // Close handle; cursor remains alive on server
}
// Subsequent calls — resume from cursor
public LargeLeadProcessorQueueable(String cursorId, Integer offset) {
this.cursorId = cursorId;
this.offset = offset;
}
public void execute(QueueableContext ctx) {
Database.Cursor cursor = Database.getCursor(this.cursorId);
if (this.offset >= cursor.getNumRecords()) {
cursor.close();
return;
}
List<Lead> page = cursor.fetch(this.offset, PAGE_SIZE);
processLeads(page);
Integer nextOffset = this.offset + page.size();
if (nextOffset < cursor.getNumRecords()) {
cursor.close();
System.enqueueJob(new LargeLeadProcessorQueueable(this.cursorId, nextOffset));
} else {
cursor.close();
}
}
}
For user-facing pagination (LWC infinite scroll, Screen Flows), use Database.PaginationCursor. It is @AuraEnabled compatible.
public with sharing class AccountPaginationController {
private static final Integer DEFAULT_PAGE_SIZE = 20;
@AuraEnabled(cacheable=false)
public static PageResult getAccounts(String cursorId, Integer pageSize) {
if (pageSize == null || pageSize <= 0) pageSize = DEFAULT_PAGE_SIZE;
Database.PaginationCursor cursor;
if (String.isBlank(cursorId)) {
cursor = Database.getPaginationCursor(
'SELECT Id, Name, Industry, AnnualRevenue FROM Account ORDER BY Name'
);
} else {
cursor = Database.getPaginationCursor(cursorId);
}
List<Account> page = cursor.fetch(pageSize);
PageResult result = new PageResult();
result.records = page;
result.cursorId = cursor.getId();
result.hasMore = cursor.hasMore();
result.totalCount = cursor.getNumRecords();
return result;
}
public class PageResult {
@AuraEnabled public List<Account> records;
@AuraEnabled public String cursorId;
@AuraEnabled public Boolean hasMore;
@AuraEnabled public Integer totalCount;
}
}
import { LightningElement } from 'lwc';
import getAccounts from '@salesforce/apex/AccountPaginationController.getAccounts';
export default class AccountInfiniteList extends LightningElement {
accounts = [];
cursorId = null;
hasMore = true;
isLoading = false;
connectedCallback() { this.loadPage(); }
async loadPage() {
if (this.isLoading || !this.hasMore) return;
this.isLoading = true;
try {
const result = await getAccounts({ cursorId: this.cursorId, pageSize: 20 });
this.accounts = [...this.accounts, ...result.records];
this.cursorId = result.cursorId;
this.hasMore = result.hasMore;
} catch (error) {
console.error('Pagination error:', error);
} finally {
this.isLoading = false;
}
}
handleLoadMore() { this.loadPage(); }
}
| Constraint | Detail |
|---|---|
| Max records per cursor | 50,000,000 |
| Cursor lifetime (sync) | 10 minutes |
| Cursor lifetime (async) | 60 minutes |
fetch() max page size | 2,000 rows per call |
| Max open cursors | 10 per transaction |
PaginationCursor @AuraEnabled | Fully supported |
Always close cursors in a try/finally block.
Database.Cursor cursor = Database.getCursor('SELECT Id FROM Lead');
try {
List<Lead> leads = cursor.fetch(0, 100);
processLeads(leads);
} catch (System.CursorException e) {
// Cursor expired or already closed
System.debug(LoggingLevel.WARN, 'Cursor error: ' + e.getMessage());
throw;
} finally {
try { cursor.close(); } catch (Exception ignored) {}
}
| Error | Cause | Fix |
|---|---|---|
Cursor has been closed | fetch() after close() | Check cursor state before fetching |
Cursor has expired | > 10 min sync / 60 min async | Process faster or use Queueable chaining |
Maximum cursors exceeded | > 10 open cursors | Close cursors in finally blocks |
Non-selective query | WHERE clause not indexed | Add custom index or narrow query |
@IsTest
private class CursorPaginationTest {
@TestSetup
static void makeData() {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 500; i++) {
accounts.add(new Account(Name = 'Cursor Test ' + String.valueOf(i).leftPad(3, '0')));
}
insert accounts;
}
@IsTest
static void shouldPaginateThroughAllRecords() {
Test.startTest();
Database.Cursor cursor = Database.getCursor(
'SELECT Id, Name FROM Account WHERE Name LIKE \'Cursor Test%\' ORDER BY Name'
);
Integer totalFetched = 0;
try {
while (totalFetched < cursor.getNumRecords()) {
List<Account> page = (List<Account>) cursor.fetch(totalFetched, 200);
totalFetched += page.size();
}
} finally {
cursor.close();
}
Test.stopTest();
System.assertEquals(500, totalFetched, 'Should fetch all 500 records');
}
}
sf-apex-async-patterns — For Queueable chaining patternssf-apex-constraints — Governs SOQL and DML usage in cursor-processing codesf-soql-constraints — Governs query structure and selectivity requirementsnpx claudepluginhub jiten-singh-shahi/salesforce-claude-code --plugin salesforce-claude-codeDesigns cursor-based pagination for live datasets, eliminating page drift. Implements opaque cursors, forward/backward traversal, and stable position encodings.
Generates, refactors, and reviews Apex classes including service, selector, domain, triggers, batch, queueable, and REST resources. Includes test generation.
Generates production-grade Apex classes with Service-Selector-Domain layering, sharing models, and async patterns (Queueable, Batchable, Schedulable). Static code generation without org connection.