GraphQL Pagination — Relay Connections
GraphQL doesn’t mandate a pagination style. The de facto standard is Relay Connections — cursor-based pagination with a specific schema shape. Many tools (Relay, Apollo) work best with this convention; using it from day one saves rewriting later.
The shapes
Three common approaches:
| Style | When |
|---|---|
| Offset/limit | small datasets, admin UIs (“page 3 of 5”) |
| Simple cursor | most common in modern APIs |
| Relay Connections | when using Relay or for ecosystem compatibility |
Offset/limit (the simplest)
type Query {
posts(limit: Int = 20, offset: Int = 0): [Post!]!
}
query {
posts(limit: 10, offset: 30) {
id
title
}
}
Same problems as REST offset pagination — slow for deep pages (OFFSET 1000000 scans + discards). Drifts if rows are inserted between page loads. Fine for small bounded datasets.
Simple cursor pagination
type Query {
posts(first: Int = 20, after: String): PostPage!
}
type PostPage {
items: [Post!]!
nextCursor: String
hasMore: Boolean!
}
query {
posts(first: 10) {
items { id title }
nextCursor
hasMore
}
}
# Next page:
query {
posts(first: 10, after: "eyJpZCI6MTAwfQ==") {
items { id title }
nextCursor
}
}
Cursor is opaque (typically base64-encoded JSON of the last-seen sort key). Constant-time at any depth. The simpler alternative to Relay’s full spec.
Relay Connections — the standard
A specific connection-edge-node schema shape that Relay and most GraphQL tooling expects.
type Query {
posts(first: Int, after: String, last: Int, before: String): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int # optional, expensive
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Post {
id: ID!
title: String!
# ...
}
Query:
query {
posts(first: 10) {
edges {
cursor
node { id title }
}
pageInfo {
hasNextPage
endCursor
}
}
}
Response:
{
"data": {
"posts": {
"edges": [
{"cursor": "abc1", "node": {"id": "1", "title": "..."}},
{"cursor": "abc2", "node": {"id": "2", "title": "..."}}
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "abc2"
}
}
}
}
Client uses endCursor for the next page:
query {
posts(first: 10, after: "abc2") { ... }
}
Why the edges/node split?
Looks verbose for “just a list of posts.” The split allows:
- Edge-level metadata — properties of the relationship, not the node. E.g. for
user.posts,Edgecould carryaddedAt,pinned,role. - Pagination per relationship —
user.posts(first: 10)is naturally connection-shaped. - Consistency across types — every paginated field looks the same; clients and tools (Apollo, Relay) handle them uniformly.
type PostEdge {
node: Post!
cursor: String!
addedToCollectionAt: DateTime # relationship metadata
pinned: Boolean!
}
If you don’t need edge metadata, the edges wrapper is overhead. Some teams use a flat items + pageInfo shape (the “simple cursor” style above).
Forward and backward pagination
Relay defines both directions:
| Args | Direction |
|---|---|
first: Int, after: String |
forward |
last: Int, before: String |
backward |
Backward pagination is rare in practice (“show me the page before this one”). Most clients only use forward. Implement backward only if you need it.
You can also mix: first: 10, after: "abc" plus last: 10, before: "xyz" etc. — see the Relay spec for edge cases.
Cursor encoding
import base64, json
def encode_cursor(last_seen_id: int, last_seen_created: datetime) -> str:
return base64.urlsafe_b64encode(
json.dumps({"id": last_seen_id, "created": last_seen_created.isoformat()}).encode()
).decode()
def decode_cursor(cursor: str) -> dict:
return json.loads(base64.urlsafe_b64decode(cursor))
Why base64 JSON: opaque to clients (they don’t depend on the format), easy for the server to evolve (add fields, change encoding).
The SQL behind the cursor:
SELECT * FROM posts
WHERE (created_at, id) < (:cursor_created, :cursor_id) -- tuple comparison
ORDER BY created_at DESC, id DESC
LIMIT :first + 1 -- +1 to know hasNextPage
Tuple comparison handles ties — when multiple rows share created_at, id breaks the tie.
The “+1” technique:
- Fetch N+1 rows.
- If you got N+1, there’s a next page (set
hasNextPage = true); return the first N. - If you got ≤ N, no next page; return what you have.
Avoids a separate COUNT query.
totalCount — usually skip
type PostConnection {
totalCount: Int! # expensive
edges: [PostEdge!]!
pageInfo: PageInfo!
}
totalCount requires SELECT COUNT(*) FROM posts WHERE ... — same problem as REST. On a table with 50M rows, it’s seconds.
If clients don’t strictly need it, omit. If they do, make it optional (nullable) and let resolver short-circuit for expensive filters.
For approximate counts on Postgres:
SELECT reltuples::bigint FROM pg_class WHERE relname = 'posts'
Returns an approximate row count from statistics — fast but stale.
Nested pagination
query {
user(id: 1) {
posts(first: 10) {
edges {
node {
id
comments(first: 5) {
edges { node { id body } }
}
}
}
}
}
}
For each post (10 of them), fetch 5 comments. Naively this is N+1 (one query per post for comments). DataLoader fixes it — see 04_resolvers_n_plus_1_dataloader.md — but pagination complicates DataLoader (different first per call).
Common solution: window-based batch query:
SELECT * FROM (
SELECT *, row_number() OVER (PARTITION BY post_id ORDER BY created_at DESC) AS rn
FROM comments
WHERE post_id = ANY($1)
) WHERE rn <= 5
One query, returns top-5 comments per post. Resolver groups by post_id.
Implementation in Strawberry
import strawberry
from typing import Optional
from base64 import b64encode, b64decode
@strawberry.type
class PageInfo:
has_next_page: bool
end_cursor: Optional[str]
@strawberry.type
class PostEdge:
node: "Post"
cursor: str
@strawberry.type
class PostConnection:
edges: list[PostEdge]
page_info: PageInfo
@strawberry.type
class Query:
@strawberry.field
async def posts(
self,
first: int = 20,
after: Optional[str] = None,
) -> PostConnection:
cursor_id = decode_cursor(after) if after else None
rows = await db.fetch_all(
"SELECT * FROM posts WHERE ($1::int IS NULL OR id < $1) ORDER BY id DESC LIMIT $2",
cursor_id, first + 1,
)
has_next = len(rows) > first
items = rows[:first]
edges = [PostEdge(node=Post(**r), cursor=encode_cursor(r["id"])) for r in items]
return PostConnection(
edges=edges,
page_info=PageInfo(
has_next_page=has_next,
end_cursor=edges[-1].cursor if edges else None,
),
)
Most Python GraphQL libraries (Strawberry, Ariadne, Graphene) have helpers for Connection-style pagination.
Common pitfalls
- Offset pagination at depth —
OFFSET 100000 LIMIT 50is slow. Use cursor. - Cursor on non-unique field — ties skip or duplicate rows. Tiebreak with
id(or unique column). totalCounton every paginated field — expensive; make optional.- Different pagination per field — one uses offset, another cursor. Inconsistent for clients. Standardize.
- No
+1peek — separate COUNT query forhasNextPage. Fetch N+1 instead. - Cursor format is JSON without encoding — clients see DB internals; can’t change format.
Common interview confusions
- “Connection edges/nodes is for compatibility with relational DBs.” — no, it’s for relationship metadata. The split lets edges carry per-edge data (e.g.
pinnedon a user-post relationship). - “Relay pagination requires totalCount.” — optional. Many implementations omit it for performance.
- “You need GraphQL Relay library to use Connections.” — Connection is a schema convention. You can implement it in any library.
Interview angle
- “What’s the Relay Connection pattern?” — schema convention for cursor pagination: a
Connectiontype withedges(each{node, cursor}),pageInfo(hasNextPage,endCursor, etc.), optionaltotalCount. Args:first,after,last,before. Standard across GraphQL tooling. - “Why use cursors over offset?” — constant-time at any depth (offset’s
LIMIT 50 OFFSET 1000000scans + discards 1000000 rows). Doesn’t skip/duplicate when rows are inserted between pages. - “Why the edges/node split?” — edges can carry relationship-level metadata (when the user joined the group, whether a post is pinned in a collection). Without edges, that has nowhere natural to live.
- “How do you detect
hasNextPagewithout a separate COUNT?” — fetchfirst + 1rows. If you gotfirst + 1, there’s a next page; return the first N as edges. - “How do you handle nested pagination without N+1?” — DataLoader plus a batched window query (e.g.
row_number() OVER (PARTITION BY ...)in Postgres) to fetch top-N children per parent in one query. - “What’s in a cursor?” — typically base64-encoded JSON of the last-seen sort key (
{id, created_at}). Opaque to clients; lets the server evolve the format.