Skip to main content

Pagination

Every root field and every to-many relation field returns a connection following the Relay Cursor Connections specification. There is no plain list and no limit argument: you always select through edges { node { … } } and size the page with first or last.

The examples use the equipment and maintenance_request objects from the overview.

Connection shape​

type equipmentConnection {
edges: [equipmentEdge] # the requested page
pageInfo: PageInfo!
totalCount: Long! # all matching records, ignoring the page window
}

type equipmentEdge {
node: equipment # the record
cursor: String! # this record's position, opaque
}

type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String # null when the page is empty
endCursor: String # null when the page is empty
}

totalCount counts everything matching your where, not what is on the page — including on a nested connection, where it reports the size of the whole relation rather than the preview you asked for.

Arguments​

ArgumentTypeMeaning
firstIntReturn the first N records (forward paging).
afterStringStart after this cursor. Use pageInfo.endCursor from the previous page.
lastIntReturn the last N records (backward paging).
beforeStringEnd before this cursor. Use pageInfo.startCursor.
offsetIntSkip N records from the start. Combine with first for direct page access.

Page sizes​

Default, root connection20
Default, nested (to-many) connection10
Maximum, anywhere100

The nested default is deliberately smaller: a nested connection resolves once per parent row, so its page size multiplies where a root page size applies once. A first: 20 root query with an unsized nested connection is already 20 × 10 rows.

A first or last above 100 is silently reduced to 100 rather than rejected. Read edges.length — or pageInfo.hasNextPage — rather than assuming you received what you asked for.

Combinations that are rejected​

CombinationWhy
first and lastPage forward or backward, not both.
offset with afteroffset is an absolute position, after a relative one.
offset with beforeSame reason.
offset with lastUse offset + first, or last + before.
a negative first, last or offset

Each returns graphql/invalid-pagination and echoes the conflicting arguments:

invalid-pagination.graphql
{ equipment(first: 20, offset: 100, after: "Y3Vyc29yOjA=") { edges { node { name } } } }
{
"data": null,
"errors": [
{
"message": "Pass either 'offset' or 'after', not both: 'offset' addresses an absolute position and 'after' a relative one.",
"path": ["equipment"],
"extensions": {
"code": "https://problems.tourfold.com/graphql/invalid-pagination",
"title": "Invalid pagination arguments",
"data": { "offset": 100, "after": "Y3Vyc29yOjA=" }
}
}
]
}

where and order_by combine freely with every paging mode.

Paging forward​

Request the first page, then pass its endCursor as after:

pagination-first-page.graphql
query FirstPage {
equipment(first: 2, order_by: [{ name: asc }]) {
totalCount
pageInfo { hasNextPage endCursor }
edges { cursor node { id name } }
}
}
{
"data": {
"equipment": {
"totalCount": 3,
"pageInfo": { "hasNextPage": true, "endCursor": "Y3Vyc29yOjE=" },
"edges": [
{ "cursor": "Y3Vyc29yOjA=", "node": { "id": "6f3d2a10-0000-4000-8000-000000000101", "name": "Basement Chiller CH-02" } },
{ "cursor": "Y3Vyc29yOjE=", "node": { "id": "6f3d2a10-0000-4000-8000-000000000102", "name": "Passenger Lift LIFT-01" } }
]
}
}
}
pagination-second-page.graphql
query SecondPage {
equipment(first: 2, after: "Y3Vyc29yOjE=", order_by: [{ name: asc }]) {
pageInfo { hasNextPage endCursor }
edges { cursor node { id name } }
}
}

Keep going while pageInfo.hasNextPage is true. Because every query is given a total order, consecutive pages fit together without you supplying a tiebreaker.

Paging backward​

last with before walks towards the start. last alone returns the final page:

pagination-last-page.graphql
query LastPage {
equipment(last: 2, order_by: [{ name: asc }]) {
pageInfo { hasPreviousPage startCursor }
edges { cursor node { name } }
}
}

Then pass startCursor as before to step back again.

Jumping to a page​

For a "go to page N" control, use offset with first:

pagination-page-six.graphql
query PageSix {
equipment(first: 20, offset: 100, order_by: [{ name: asc }]) {
totalCount
pageInfo { hasNextPage hasPreviousPage }
edges { node { id name } }
}
}
offset = (pageNumber - 1) × pageSize

Counting without fetching​

Selecting totalCount without edges fetches no records at all — the server issues only the count:

open-request-count.graphql
query OpenRequestCount {
maintenance_request(where: { resolved: { _eq: false } }) {
totalCount
}
}

Use this for badge counts and summaries. Adding edges to the same query brings the default page size back into play, so keep count-only queries count-only.

:::warning Cursors are positions, not bookmarks A cursor encodes a record's offset in the ordered result set, not a pointer to the record itself. It is opaque — never construct, parse, or store one long-term — and it has a consequence you must design around: it is only valid against the result set that produced it.

If records are inserted or deleted while you are paging, the offsets shift underneath you. A record can appear on two consecutive pages, or be skipped entirely. Reusing yesterday's endCursor against today's data does not resume where you left off; it lands at whatever now sits at that position.

So cursors are not suitable for exhaustive iteration — a sync job, an export, "fetch every record". For those, use keyset pagination instead: it addresses data rather than positions.

A cursor is still the right tool for a person paging through a table, where a shifted row is not a correctness problem. :::

Exhaustive iteration​

To read every record — a sync, an export, a reconciliation — page by a stable ordering plus a filter, carrying the last row you saw forward as the start of the next request.

The ordering must be one you can express as a filter, and it must be unique. That second requirement is the one that catches people:

:::danger A single timestamp cursor loses records Ordering by updated_at alone and carrying only that value forward does not work, and fails silently:

  • _gt on the timestamp skips every record still tied at the page boundary. If more records share one updated_at than fit in a page, the ones that did not fit are never returned — and the traversal ends looking successful.
  • _gte instead re-reads the same page forever, so it never terminates.

This is not an edge case. Bulk-imported or migrated records routinely share a timestamp to the microsecond, and the page maximum is 100. :::

The fix is a compound cursor: updated_at for progress, plus id to break ties. id works because every query's total order ends in id ascending, so it is the one column guaranteed to disambiguate two otherwise identical rows.

Order by both, and ask for "later timestamp, or same timestamp and later id":

exhaustive-page.graphql
query ExhaustivePage($lastUpdatedAt: DateTime!, $lastId: ID!) {
equipment(
where: {
_or: [
{ updated_at: { _gt: $lastUpdatedAt } }
{ _and: [{ updated_at: { _eq: $lastUpdatedAt } }, { id: { _gt: $lastId } }] }
]
}
order_by: [{ updated_at: asc }, { id: asc }]
first: 100
) {
edges {
node {
id
name
updated_at
}
}
}
}

The loop:

  1. Fetch the first page with order_by: [{ updated_at: asc }, { id: asc }] and no cursor filter.
  2. Take updated_at and id from the last node returned.
  3. Pass both into the query above.
  4. Repeat until a page comes back empty.

Carry both values. Carrying only the timestamp reintroduces exactly the problem above.

To resume from the beginning, start with $lastId = 00000000-0000-0000-0000-000000000000, which sorts before any generated id.

:::note Not a snapshot This is resilient — it never skips or repeats a record because of a concurrent insert or delete, which is what offset cursors cannot promise. It is not a point-in-time snapshot: a record modified while you are part-way through moves to a later updated_at and so may be visited again with its new contents, and a record deleted mid-traversal simply will not appear. If you need transactional consistency across a whole export, the API does not currently offer a change-sequence or high-water mark to build it on. :::

pageInfo semantics​

Field
hasNextPageWhether records follow this page. Meaningful when paging forward with first, or when before is set. Reported as false for a last-only query, which is already the final page.
hasPreviousPageWhether records precede this page. true whenever after was supplied.
startCursor / endCursorCursors of the first and last edge, or null when the page is empty.