Apex Cursors

Use Apex cursors to break up the processing of a SOQL query result into pieces that can be processed within the bounds of a single transaction. Cursors provide you with the ability to work with large query result sets, while not actually returning the entire result set. You can traverse a query result in parts, with the flexibility to navigate forward and back in the result set. Package developers and advanced developers can use cursors to work with high-volume and high-resource processing jobs. Cursors combined with chained queueable Apex jobs are a powerful alternative to batch Apex and address some of batch Apex’s limitations.

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().

Apex Cursors establish a fixed set of resulting record IDs when they’re created. Subsequent record updates after the cursor is established don’t affect the existing set of record IDs. For example, if an object field is updated such that the record no longer meets the initial WHERE clause of the SOQL query, the record ID remains in the result set. Similarly, subsequent changes to record sharing rules don’t alter the result set.

Note

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 throw these new System exceptions: System.FatalCursorException and System.TransientCursorException. Transactions that fail with System.TransientCursorException can be retried.

Apex Cursor Example

1public with sharing class QueryChunkingQueueable implements Queueable {
2    private Database.Cursor locator;
3    private Integer position;
4
5    public QueryChunkingQueueable() {
6        locator = Database.getCursor(
7            'SELECT Id FROM Contact WHERE LastActivityDate = LAST_N_DAYS:400',
8            AccessLevel.USER_MODE);
9        position = 0;
10    }
11
12    public void execute(QueueableContext ctx) {
13        Integer remainingRows = locator.getNumRecords() - position;
14        if (remainingRows == 0) {
15            return; // Nothing to do
16        }
17
18        // Take the minimum of batch size and remaining rows to avoid over-fetching
19        Integer fetchSize = Math.min(200, remainingRows);
20
21        List<Contact> scope = locator.fetch(position, 200);
22        position += scope.size();
23        // do something, like archive or delete the scope list records
24        if (position < locator.getNumRecords()) {
25            // process the next chunk
26            System.enqueueJob(this);
27        }
28    }
29}

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. A single Database.PaginationCursor instance can have a maximum of 100,000 rows, 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

To get limits on Apex cursors and Apex pagination cursors, use these methods in the Limits class.

  • Limits.getApexCursorRows() and its upper bound Limits.getLimitApexCursorRows() method
  • Limits.getFetchCallsOnApexCursor() and its upper bound Limits.getLimitFetchCallsOnApexCursor() 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

To view transaction and daily limits for Apex cursors and Apex pagination cursors, see Execution Governors and Limits.

Apex cursors and pagination cursors have the same expiration limits as API Query cursors. See API Query Cursor Limits.

Apex Cursor and Pagination Cursor Limits Example

1// Create a standard cursor
2Database.Cursor cursor = Database.getCursor('SELECT Id, Name FROM Account LIMIT 20');
3System.debug('Standard Cursors: ' + Limits.getApexCursors() + '/' + Limits.getLimitApexCursors());
4System.debug('Standard Cursor Rows: ' + Limits.getApexCursorRows() + '/' + Limits.getLimitApexCursorRows());
5
6// Fetch records
7List<Account> batch1 = cursor.fetch(0, 10);
8List<Account> batch2 = cursor.fetch(10, 10);
9
10// Create a pagination cursor
11Database.PaginationCursor pagCursor = Database.getPaginationCursor('SELECT Id, Name FROM Account LIMIT 15');
12System.debug('Pagination Cursors: ' + Limits.getApexPaginationCursors() + '/' + Limits.getLimitApexPaginationCursors());
13System.debug('Pagination Cursor Rows: ' + Limits.getApexPaginationCursorRows() + '/' + Limits.getLimitApexPaginationCursorRows());
14
15// Fetch a page
16Database.CursorFetchResult page = pagCursor.fetchPage(0, 5);
17
18// Check shared fetch call limit
19System.debug('Fetch Calls: ' + Limits.getFetchCallsOnApexCursor() + '/' + Limits.getLimitFetchCallsOnApexCursor());
20
21// Get daily limits map
22Map<String, System.OrgLimit> limitMap = OrgLimits.getMap();
23
24// Standard cursor daily limit
25System.OrgLimit dailyCursorLimit = limitMap.get('DailyApexCursorLimit');
26System.debug('Daily Cursors: ' + dailyCursorLimit.getValue() + '/' + dailyCursorLimit.getLimit());
27
28// Pagination cursor daily limit
29System.OrgLimit dailyPCursorLimit = limitMap.get('DailyApexPCursorLimit');
30System.debug('Daily Pagination Cursors: ' + dailyPCursorLimit.getValue() + '/' + dailyPCursorLimit.getLimit());
31
32// Shared daily rows limit
33System.OrgLimit dailyRowsLimit = limitMap.get('DailyApexCursorRowsLimit');
34System.debug('Daily Cursor Rows: ' + dailyRowsLimit.getValue() + '/' + dailyRowsLimit.getLimit());