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.
| Query | Notes |
|---|---|
contacts | Supports search, filters, options |
forms | Supports filters (for example clinicalPathwayId) |
invoices | Requires dateRange; supports filters, options |
labs | Requires dateRange; supports options |
letters | Requires dateRange; supports filters, options |
patientDocuments | Supports search, filters, options |
patients | Supports search, options |
practiceTemplateDocuments | Supports search, options |
prescriptions | Requires dateRange; supports filters, options |
products | Supports search, filters, options |
questionnaires | Supports options |
records | Requires dateRange; supports options |
tasks | Supports options |
All other arguments on each query behave as before. Only the paging mechanism
changes when you opt into cursorPagination.
Related: accountStatements (unchanged)
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.
| Field | Required | Description |
|---|---|---|
pageSize | No | Max rows per page. Default 30. Maximum 200. |
cursor | No | id of the last row from the previous page. Omit on the first page. |
direction | No | NEXT (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 pagehasMore— whether another page exists in the requested directionpage— 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, andoptionsapply before paging. Acursoris only valid for the same filter set used when it was obtained. - Stable ordering: Ordering by
idis 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
cursorfails at query execution. Restart from the first page without a cursor. PREVIOUSwithoutcursor: Rejected withBAD_USER_INPUT. UseNEXTwithout a cursor to start from the first page.- Offset unchanged: No deprecation date for
paginationon these queries.
Migration
| Caller today | Action |
|---|---|
Uses pagination only | No change required |
| Deep offset paging over large lists | Adopt cursorPagination on the relevant query |
| Sends both pagination arguments | Send only one; handle BAD_USER_INPUT if both are present |
See also
- Input:
CursorPagination - Object:
PageInfoForCursorPagination - Existing cursor-only query:
accountStatements