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​
| Argument | Type | Meaning |
|---|---|---|
first | Int | Return the first N records (forward paging). |
after | String | Start after this cursor. Use pageInfo.endCursor from the previous page. |
last | Int | Return the last N records (backward paging). |
before | String | End before this cursor. Use pageInfo.startCursor. |
offset | Int | Skip N records from the start. Combine with first for direct page access. |
Page sizes​
| Default, root connection | 20 |
| Default, nested (to-many) connection | 10 |
| Maximum, anywhere | 100 |
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​
| Combination | Why |
|---|---|
first and last | Page forward or backward, not both. |
offset with after | offset is an absolute position, after a relative one. |
offset with before | Same reason. |
offset with last | Use offset + first, or last + before. |
a negative first, last or offset |
Each returns graphql/invalid-pagination and echoes the conflicting arguments:
{ 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:
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" } }
]
}
}
}
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:
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:
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:
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:
_gton the timestamp skips every record still tied at the page boundary. If more records share oneupdated_atthan fit in a page, the ones that did not fit are never returned — and the traversal ends looking successful._gteinstead 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":
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:
- Fetch the first page with
order_by: [{ updated_at: asc }, { id: asc }]and no cursor filter. - Take
updated_atandidfrom the last node returned. - Pass both into the query above.
- 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 | |
|---|---|
hasNextPage | Whether 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. |
hasPreviousPage | Whether records precede this page. true whenever after was supplied. |
startCursor / endCursor | Cursors of the first and last edge, or null when the page is empty. |