Apex cursors are stateless and generate results from the offset position that is specified in the Cursor.fetch(integer position, integer count) method. You must track the offsets or positions of the results within your particular processing scenario.
A cursor is created when a SOQL query is executed on a Database.getCursor() or Database.getCursorWithBinds() call. When a Cursor.fetch(integer position, integer count) method is invoked with an offset position and the count of records to fetch, the corresponding rows are returned from the cursor. The maximum number of rows per cursor is 50 million, regardless of whether the operation is synchronous or asynchronous. To get the number of cursor rows returned from the SOQL query, use Cursor.getNumRecords().
Calling the Cursor.fetch() method counts against the SOQL query limit, and the rows fetched count against the SOQL query row limit. You can make a maximum of 100 Cursor.fetch() calls per transaction.
Apex cursors may throw these new System exceptions: System.FatalCursorException and System.TransientCursorException. Transactions that fail with System.TransientCursorException can be retried.
Best Practices for Apex Cursors
After you first initialize the cursor, maintain it as an instance variable on your Queueable class. When you enqueue the job, the cursor is serialized along with the job state, so you can reuse the same cursor instance across each transaction in the Queueable chain without incurring additional cursor and cursor row creation costs. An instantiated cursor remains valid throughout its 48-hour lifespan.
In other words, don’t recreate the cursor instance for each transaction in the Queueable chain. If you call Database.getCursor() within the Queueable execute() method, a new cursor is created for the current query results. Using this antipattern rapidly exhausts both the DailyApexCursorLimit and the DailyApexCursorRowsLimit across your rolling 24-hour org allowance.
Apex Cursor Example
This Apex cursor example demonstrates how to process a high volume of records by combining a standard cursor with a chained Queueable class.
The cursor (locator) and the offset tracking variable (position) are initialized in the class constructor and stored as instance variables. Because Queueable job state is serialized, the same cursor instance and position offset are preserved across every transaction hop. During each execution, the job fetches a manageable chunk of records, processes them, updates the position, and re-enqueues itself until the entire result set is exhausted.
This is a simple example that you can use as a starting-point and adapt to your implementation needs.
public with sharing class QueryChunkingQueueable implements Queueable { private Database.Cursor locator; private Integer position; public QueryChunkingQueueable() { locator = Database.getCursor( 'SELECT Id FROM Contact WHERE LastActivityDate = LAST_N_DAYS:400', AccessLevel.USER_MODE); position = 0; } public void execute(QueueableContext ctx) { Integer remainingRows = locator.getNumRecords() - position; if (remainingRows == 0) { return; // Nothing to do } // Take the minimum of batch size and remaining rows to avoid over-fetching Integer fetchSize = Math.min(200, remainingRows); List<Contact> scope = locator.fetch(position, 200); // do something, like archive or delete the scope list records // Increment the position position += fetchSize; if (position < locator.getNumRecords()) { // process the next chunk System.enqueueJob(this); } } }
Use Apex Cursors with Data 360 Data Model Objects (Beta)
You can use Database.getCursor() to create an Apex cursor for a SOQL query against a Data 360 data model object (DMO). Use Cursor.fetch() to retrieve records in chunks and Cursor.getNumRecords() to get the number of records in the result set.
For example, this code creates an Apex cursor for the Individual__dlm DMO. This is a simple example that you can use as a starting-point and adapt to your implementation needs.
public with sharing class DataChunkProcessor implements Queueable { private Database.Cursor cursor; private Integer position; public DataChunkProcessor() { // Initialize cursor with DC SOQL this.cursor = Database.getCursor('SELECT Name__c, Email__c FROM Individual__dlm', AccessLevel.USER_MODE); this.position = 0; } public void execute(QueueableContext context) { Integer remainingRows = cursor.getNumRecords() - position; if (remainingRows == 0) { return; // Nothing to do } // Take the minimum of batch size and remaining rows to avoid over-fetching Integer fetchSize = Math.min(200, remainingRows); // Fetch a chunk of 200 records without hitting SOQL query row limits or heap limits List<Individual__dlm> records = cursor.fetch(position, 200); // Process records for (Individual__dlm rec : records) { // Business logic } // Increment position this.position += fetchSize; // Chain the next job if more records are left if (this.position < cursor.getNumRecords()) { System.enqueueJob(this); } } }
Apex cursors for Data 360 DMOs expire 24 hours after creation. If the cursor expires before processing is complete, a System.InvalidQueryLocatorException is thrown. Otherwise, Apex cursors for Data 360 DMOs have the same transaction and daily limits as standard Apex cursors.
To get transaction limits on Apex cursors for Data 360 DMOs, use these methods in the Limits class.
- Limits.getDataCloudCursorRows and its upper bound method Limits.getLimitDataCloudCursorRows.
- Limits.getDataCloudCursors and its upper bound method Limits.getLimitDataCloudCursors.
Pagination Cursors
Like a standard Apex cursor, an Apex pagination cursor provides a pointer to a large SOQL query result set. However, an Apex pagination cursor is designed for UI-based pagination, such as multipage record lists.
To create a pagination cursor, call Database.getPaginationCursor() or Database.getPaginationCursorWithBinds() with a SOQL query as an argument. You org can have a maximum of 100,000 rows across all active pagination cursors per transaction, regardless of whether the operation is synchronous or asynchronous. This size limit is lower than that of a regular Apex cursor, as pagination cursors are designed for human-readable data.
However, pagination cursors have a higher instance daily limit than that of regular Apex cursors. Whereas standard cursors are limited to 10,000 instances per org per 24-hour period, pagination cursors are limited to 200,000 instances per org per 24-hour period. This higher instance limit supports many users accessing records lists that rely on smaller pagination cursors.
To retrieve a page of rows from a pagination cursor, call PaginationCursor.fetchPage(integer start, integer pageSize). The start parameter is the zero-based index from which to begin fetching rows, and the pageSize is the maximum number of rows to retrieve for this page. The maximum pageSize value is 2000 rows.
Unlike a standard Apex cursor, a pagination cursor retrieves a complete page of records, where record rows deleted after the creation of the cursor are skipped over by default. This way, the number of rows displayed per page is consistent.
For example, let’s say that you create a standard cursor and a pagination cursor on the same SOQL query, where the result set is 100 rows. After the cursors are created, you delete the first five rows in the set, indexed 0-4. If you then call Cursor.fetch(0, 20), only 15 rows are retrieved—rows indexed 5-19. However, if you call PaginationCursor.fetchPage(0, 20), 20 rows are retrieved—rows indexed 5-24. The fetchPage() method automatically skips over the five deleted records so that a complete page is retrieved.
To manage this handling of deleted records, the fetchPage() method returns a Database.CursorFetchResult object instead of only the list of results. The Database.CursorFetchResult object encapsulates the rows retrieved and information for the next pagination call.
- To retrieve the rows as a list of sObjects, call CursorFetchResult.getRecords().
- To retrieve the number of deleted rows that the cursor skipped in the fetchPage() operation, call CursorFetchResult.getDeletedRows().
- To retrieve the next page of results, first call CursorFetchResult.getNextIndex(), and then use the return value as the start parameter in the next fetchPage() call.
- To determine whether to make subsequent calls to fetchPage(), use the CursorFetchResult.isDone() method. The method returns true if the specified pageSize is reached, which indicates that a full page of results is retrieved. It also returns true if the pagination cursor reaches the end of a result set before the specified pageSize is reached, which indicates that a partial, final page of results is retrieved.
Calling the PaginationCursor.fetchPage() and PaginationCursor.fetchDeleted() methods count against the SOQL query limit, and the rows fetched count against the SOQL query row limit.
Apex pagination cursors throw these System exceptions: System.FatalCursorException and System.TransientCursorException. Transactions that fail with System.TransientCursorException can be retried.
Cursors and Pagination Cursor Limits
Cursors and pagination cursors are subject to both per-transaction limits and rolling 24-hour org limits. For Apex cursor limits for Data 360 objects, see the Use Apex Cursors with Data 360 Data Model Objects (Beta) section.
Transaction Limits
Calling the Cursor.fetch() method counts against the SOQL query limit, and the rows fetched count against the SOQL query row limit. You can make a maximum number of Cursor.fetch() calls per transaction as dictated by system limits.
Calling the PaginationCursor.fetchPage() and PaginationCursor.fetchDeleted() methods count against the SOQL query limit, and the rows fetched count against the SOQL query row limit.
To get Apex transaction limits for standard cursors and pagination cursors, use these methods in the Limits class.
- Limits.getApexCursorRows() and its upper bound Limits.getLimitApexCursorRows() method
- Limits.getApexCursors() and its upper bound Limits.getLimitApexCursors() method
- Limits.getApexPaginationCursors() and its upper bound Limits.getLimitApexPaginationCursors() method
- Limits.getApexPaginationCursorRows() and its upper bound Limits.getLimitApexPaginationCursorRows() method
- Limits.getFetchCallsOnApexCursor() and its upper bound Limits.getLimitFetchCallsOnApexCursor() method. This limit is cumulative across standard cursors and pagination cursors.
To view the current transaction limit allowances for Apex cursors and Apex pagination cursors, see the Per-Transaction Apex Limits section of Execution Governors and Limits.
Rolling 24-hour Org Limits
Rolling 24-hour limits apply across all cursor transactions in the org. Rather than resetting at midnight, rolling limits are recalculated hourly based on total usage over the preceding 24-hour window.
Salesforce consumes these limits when a cursor is created, not when cursor rows are fetched.
-
DailyApexCursorLimit–Maximum number of active Apex cursor instances per 24-hour period
-
DailyApexPCursorLimit–Maximum number of active Apex pagination cursor instances per 24-hour
-
DailyApexCursorRowsLimit– Maximum cumulative number of cursor rows and pagination cursor rows per 24-hour period. If you create a cursor over an N-row result set, N is subtracted from this limit allowance immediately, regardless of the number of rows you fetch later.
To view the current org limit allowances for Apex cursors and Apex pagination cursors, see the Salesforce Platform Apex Limits section of Execution Governors and Limits.
Expiration Limits
Apex cursors and pagination cursors expire 48 hours after creation.
Apex Cursor and Pagination Cursor Limits Example
This example demonstrates how to programmatically check both per-transaction governor limits and 24-hour org limits for Apex cursors.
- Track transaction-level usage for active cursors, fetched rows, and cumulative fetch calls by using the System.Limits class.
- Query rolling 24-hour org allowances for daily cursor instances and total row counts using OrgLimits.getMap() and OrgLimit.getLimit().
// Create a standard cursor Database.Cursor cursor = Database.getCursor('SELECT Id, Name FROM Account LIMIT 20', AccessLevel.USER_MODE); System.debug('Standard Cursors: ' + Limits.getApexCursors() + '/' + Limits.getLimitApexCursors()); System.debug('Standard Cursor Rows: ' + Limits.getApexCursorRows() + '/' + Limits.getLimitApexCursorRows()); // Fetch records List<Account> batch1 = cursor.fetch(0, 10); List<Account> batch2 = cursor.fetch(10, 10); // Create a pagination cursor Database.PaginationCursor pagCursor = Database.getPaginationCursor('SELECT Id, Name FROM Account LIMIT 15', AccessLevel.USER_MODE); System.debug('Pagination Cursors: ' + Limits.getApexPaginationCursors() + '/' + Limits.getLimitApexPaginationCursors()); System.debug('Pagination Cursor Rows: ' + Limits.getApexPaginationCursorRows() + '/' + Limits.getLimitApexPaginationCursorRows()); // Fetch a page Database.CursorFetchResult page = pagCursor.fetchPage(0, 5); // Check shared fetch call limit System.debug('Fetch Calls: ' + Limits.getFetchCallsOnApexCursor() + '/' + Limits.getLimitFetchCallsOnApexCursor()); // Get daily limits map Map<String, System.OrgLimit> limitMap = OrgLimits.getMap(); // Standard cursor daily limit System.OrgLimit dailyCursorLimit = limitMap.get('DailyApexCursorLimit'); System.debug('Daily Cursors: ' + dailyCursorLimit.getValue() + '/' + dailyCursorLimit.getLimit()); // Pagination cursor daily limit System.OrgLimit dailyPCursorLimit = limitMap.get('DailyApexPCursorLimit'); System.debug('Daily Pagination Cursors: ' + dailyPCursorLimit.getValue() + '/' + dailyPCursorLimit.getLimit()); // Shared daily rows limit System.OrgLimit dailyRowsLimit = limitMap.get('DailyApexCursorRowsLimit'); System.debug('Daily Cursor Rows: ' + dailyRowsLimit.getValue() + '/' + dailyRowsLimit.getLimit());