Skip to main content

Cursor pagination on list queries

Summary

Thirteen Public API list queries now accept optional cursorPagination alongside existing offset pagination. Together with accountStatements — which already used cursor-only paging — 14 list endpoints support cursor-based traversal.

Use cursorPagination for large result sets (full-practice sync, export, reconciliation). Existing pagination: { page, pageSize } behaviour is unchanged on every query below. This release is additive.

Why cursor pagination

Offset pagination (page / pageSize) uses MongoDB skip, which gets slower as page grows. Cursor pagination walks forward (or backward) from a known record id instead of skipping preceding rows, so each request stays roughly constant time.

Prefer cursorPagination when you iterate through most or all rows matching your filters. Keep pagination for shallow pages (for example UI lists where users jump to page 1–3).

Queries with new cursorPagination

Each query below accepts cursorPagination: CursorPagination in addition to its existing arguments. Pass the last row's id from the previous page as cursor. Results are ordered by record id (_id): ascending for NEXT, descending then reversed for PREVIOUS.

QueryNotes
contactsSupports search, filters, options
formsSupports filters (for example clinicalPathwayId)
invoicesRequires dateRange; supports filters, options
labsRequires dateRange; supports options
lettersRequires dateRange; supports filters, options
patientDocumentsSupports search, filters, options
patientsSupports search, options
practiceTemplateDocumentsSupports search, options
prescriptionsRequires dateRange; supports filters, options
productsSupports search, filters, options
questionnairesSupports options
recordsRequires dateRange; supports options
tasksSupports options

All other arguments on each query behave as before. Only the paging mechanism changes when you opt into cursorPagination.

accountStatements already paginates with the CursorPagination input on its pagination argument (cursor-only — no offset page). That shape is unchanged. The thirteen queries above use a separate optional cursorPagination argument so existing offset callers are not affected.

CursorPagination input

Shared input type. See CursorPagination and CursorPaginationDirection.

FieldRequiredDescription
pageSizeNoMax rows per page. Default 30. Maximum 200.
cursorNoid of the last row from the previous page. Omit on the first page.
directionNoNEXT (default) or PREVIOUS. PREVIOUS requires cursor.

Mutual exclusivity: pagination and cursorPagination cannot be used on the same query. Providing both returns a GraphQL validation error with extensions.code: BAD_USER_INPUT and message Provide either pagination or cursorPagination, not both.

Response shape

List payloads still expose data and pageInfo. With cursorPagination, pageInfo follows the PageInfoForCursorPagination convention on each query's data type:

  • pageSize — size used for this page
  • hasMore — whether another page exists in the requested direction
  • page — unset (no page number in cursor mode)

Examples

Patients — first and next page

query PatientsFirstPage {
patients(cursorPagination: { pageSize: 100 }) {
data {
id
firstName
lastName
}
pageInfo {
pageSize
hasMore
}
}
}

query PatientsNextPage($cursor: String!) {
patients(
cursorPagination: { cursor: $cursor, direction: NEXT, pageSize: 100 }
) {
data {
id
firstName
lastName
}
pageInfo {
pageSize
hasMore
}
}
}

Invoices — incremental sync with options

query InvoicesUpdatedSince($since: Date!) {
invoices(
dateRange: { start: $since, end: "2099-12-31" }
cursorPagination: { pageSize: 200 }
options: { updatedAt: { start: $since } }
) {
data {
id
updatedAt
}
pageInfo {
hasMore
}
}
}

Loop until pageInfo.hasMore is false. Pass the last row's id as cursorPagination.cursor on each subsequent request.

Integration guidance

  • Same pattern everywhere: The cursor loop is identical across all thirteen queries — only the query name and required filters (such as dateRange) differ.
  • Page size: Up to 200 per request for bulk jobs.
  • Filter stability: search, dateRange, filters, and options apply before paging. A cursor is only valid for the same filter set used when it was obtained.
  • Stable ordering: Ordering by id is stable for each record's lifetime. Do not assume offset pages and cursor pages return the same slices if data changes between requests.
  • Invalid cursor: A malformed or non-ObjectId cursor fails at query execution. Restart from the first page without a cursor.
  • PREVIOUS without cursor: Rejected with BAD_USER_INPUT. Use NEXT without a cursor to start from the first page.
  • Offset unchanged: No deprecation date for pagination on these queries.

Migration

Caller todayAction
Uses pagination onlyNo change required
Deep offset paging over large listsAdopt cursorPagination on the relevant query
Sends both pagination argumentsSend only one; handle BAD_USER_INPUT if both are present

See also