Neo4j Pagination: SKIP/LIMIT vs Keyset (Seek) Paging for Large Result Sets
SKIP/LIMIT forces Neo4j to scan and discard every preceding row on deep pages. Keyset pagination with an indexed anchor and a compound cursor keeps page cost constant — here's how to choose, implement, and validate it.
06 Jul 2026, 15:44 UTC

The decision you're making
You have a Cypher query that can return hundreds of thousands of nodes, and a client that consumes them page by page. The decision is how to slice those results: classic offset paging with SKIP/LIMIT, or keyset (seek) pagination anchored on an indexed property. The short version: SKIP/LIMIT is fine for shallow, human-driven browsing; keyset pagination is the right default for deep iteration, APIs, and anything an automated client will page through to the end.
The reason is mechanical. With SKIP 100000 LIMIT 100, Neo4j must produce and then discard 100,000 rows on every request. Page 1,000 costs roughly a thousand times what page 1 costs. Keyset pagination instead starts each query where the last one ended, so every page costs about the same.
Comparing the supported options
| Criterion | SKIP / LIMIT (offset) | Keyset (seek) pagination |
|---|---|---|
| Cost of deep pages | Grows linearly with offset; late pages scan and discard all preceding rows | Roughly constant per page; index seek jumps to the anchor |
| Random access (jump to page N) | Supported natively | Not supported without extra bookkeeping |
| Schema requirements | None | Needs a unique, indexed, orderable anchor property (plus tiebreaker) |
| Client complexity | Trivial: page number × page size | Client must carry a cursor (last row's anchor values) |
| Behavior under concurrent writes | Rows can shift between pages; duplicates/gaps likely | More stable, but boundary rows can still reappear; design for idempotent consumption |
| Good fit | Admin UIs, first few pages, small datasets | APIs, feeds, deep iteration over large sets |
Why SKIP gets expensive
Cypher executes SKIP by materializing the ordered result and dropping rows until the offset is reached. There is no shortcut: the planner cannot know which rows to skip without producing them. Combined with ORDER BY, the engine must sort the full matching set before it can even begin skipping. On a large label scan this turns an innocent-looking "next page" button into a full-table operation.
Keyset pagination replaces the offset with a predicate: WHERE n.created > $cursor. If created is indexed, the planner can satisfy that with a NodeIndexSeek (or a range seek over a range index in Neo4j 5.x), reading only the rows the page actually returns.
Constraints that decide for you
- You need "jump to page 37." Only offset paging supports this. If your UI has a numbered page list, keyset alone won't work; you'd need to precompute anchors per page, which is rarely worth it.
- You have no suitable anchor property. Keyset paging needs a value that is indexed, orderable, and unique per row. A creation timestamp alone is not safe: two nodes created in the same millisecond tie, and ties at a page boundary silently drop or duplicate rows. Add a unique tiebreaker such as an application-assigned id.
- You're exporting the whole dataset. Neither approach is ideal. Page-by-page round trips are the wrong tool; use the driver's streaming result consumption, or APOC's
apoc.periodic.iteratefor batched server-side processing. - Consistency across pages matters. Each HTTP request is a separate transaction, so the graph can change between pages. If you need a stable snapshot, run the whole iteration inside one explicit transaction via the driver, or accept that boundary rows may repeat and make consumers idempotent.
Concrete implementation: compound cursor keyset paging
Assume Neo4j 5.x, a Person label, a created datetime property, and a unique personId string. Create the indexes (run in Neo4j Browser or cypher-shell, requires a user with index-admin privileges):
CREATE RANGE INDEX person_created IF NOT EXISTS
FOR (p:Person) ON (p.created);
CREATE CONSTRAINT person_id_unique IF NOT EXISTS
FOR (p:Person) REQUIRE p.personId IS UNIQUE;First page — run from your application through the official driver, with parameters rather than string interpolation:
MATCH (p:Person)
RETURN p
ORDER BY p.created, p.personId
LIMIT $sizeSubsequent pages use the last row's (created, personId) pair as the cursor. Because the anchor is a pair, the predicate is a compound inequality, not two simple comparisons:
MATCH (p:Person)
WHERE p.created > $lastCreated
OR (p.created = $lastCreated AND p.personId > $lastId)
RETURN p
ORDER BY p.created, p.personId
LIMIT $sizeThe compound form is what makes ties safe: rows sharing a created value are ordered by personId, and the cursor resumes strictly after the last emitted row. A plain WHERE p.created >= $lastCreated would re-emit boundary rows; > alone would skip tied rows. Keep $size bounded (hundreds, not hundreds of thousands) — a large LIMIT still materializes that many rows in memory.
Validating the choice on your version
Planner behavior is version-sensitive, so verify rather than assume. In Browser or cypher-shell, prefix the query with PROFILE and compare the two forms against a representative dataset:
PROFILE
MATCH (p:Person)
RETURN p
ORDER BY p.created, p.personId
SKIP 100000 LIMIT 100;versus the keyset equivalent with a cursor near the same position. Compare db hits and elapsed time in the plan output — the offset version should show dramatically more db hits. Then run EXPLAIN on the keyset query and confirm the anchor predicate yields an index seek operator rather than a NodeByLabelScan feeding a sort over everything. Exact operator names vary between Neo4j 4.x and 5.x, so check against your version's plan output rather than a fixed string.
Second, test the boundary behavior: write a test that inserts new Person rows (including rows with created equal to an existing cursor value) while another thread pages through with the keyset query, and assert that no pre-existing row is silently skipped across a page boundary. Duplicates at the boundary are acceptable if your consumer deduplicates by personId; silent skips are not.
Limitations to keep in mind
- Keyset paging is forward-only. "Previous page" requires either reversing the sort and swapping inequality directions, or caching recent cursors client-side.
- If the sort key is mutable (e.g.,
lastModified), rows can move between pages mid-iteration. Prefer immutable anchors like creation time or a sequence. - Filtering that isn't covered by the anchor index can still force a scan before the seek predicate applies; composite needs may call for careful index design, which is itself version-dependent — validate with
EXPLAIN.
The practical rule: default to keyset pagination with a unique tiebreaker for anything machines consume, keep SKIP/LIMIT for shallow human browsing, and confirm the plan on your own data before committing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.