Paginating Nearest-Neighbour Results Deterministically
A “nearest hubs” endpoint that returns ten results is straightforward. The same endpoint with a next page is where it stops being straightforward, because the obvious implementation — ORDER BY distance SKIP 10 LIMIT 10 — is wrong in two independent ways. It re-runs the whole search and discards the first page, so page five costs five times page one; and it produces a different result set than the user was looking at if anything was inserted, deleted or moved in between, so rows can appear twice or vanish entirely. This page replaces it with a keyset cursor: a stable sort, a compound cursor that survives ties, and a query whose cost is the same on page fifty as on page one.
Prerequisites & Versions
An ordinary point index; the cursor is encoded client-side.
| Requirement | Minimum version | Install |
|---|---|---|
| Python | 3.11 | — |
| neo4j (async driver) | 5.20 | pip install "neo4j>=5.20" |
| Neo4j Server | 5.15 | native point, POINT INDEX |
Implementation
import base64
import json
from dataclasses import dataclass
from neo4j import AsyncGraphDatabase
PAGE = """
MATCH (h:Hub)
WHERE h.location.latitude >= $min_lat AND h.location.latitude <= $max_lat
AND h.location.longitude >= $min_lon AND h.location.longitude <= $max_lon
WITH h, point.distance(h.location, $centre) AS metres
WHERE metres <= $radius_m
// The keyset predicate: strictly past the cursor in the SAME order the
// results are sorted by. The id tiebreak is what makes it total — without it,
// two hubs at an identical distance make the boundary ambiguous and one of
// them is either repeated on the next page or skipped entirely.
AND ($after_metres IS NULL
OR metres > $after_metres
OR (metres = $after_metres AND h.id > $after_id))
RETURN h.id AS id, metres
ORDER BY metres, h.id
LIMIT $limit
"""
@dataclass(frozen=True)
class Cursor:
metres: float
id: str
def encode(self) -> str:
return base64.urlsafe_b64encode(
json.dumps({"m": self.metres, "i": self.id}).encode()
).decode()
@staticmethod
def decode(token: str | None) -> "Cursor | None":
if not token:
return None
raw = json.loads(base64.urlsafe_b64decode(token.encode()))
return Cursor(metres=float(raw["m"]), id=str(raw["i"]))
@dataclass(frozen=True)
class Page:
rows: list[dict]
next_cursor: str | None
@property
def has_more(self) -> bool:
return self.next_cursor is not None
class NearestHubs:
def __init__(self, uri: str, auth: tuple[str, str]) -> None:
self._driver = AsyncGraphDatabase.driver(uri, auth=auth)
async def close(self) -> None:
await self._driver.close()
async def page(self, box: dict, centre, radius_m: float,
limit: int = 10, after: str | None = None) -> Page:
cursor = Cursor.decode(after)
async with self._driver.session() as session:
result = await session.run(
PAGE,
**box,
centre=centre,
radius_m=radius_m,
# Fetch one extra row: its existence is what tells us there is a
# next page, without a second COUNT query over the same region.
limit=limit + 1,
after_metres=cursor.metres if cursor else None,
after_id=cursor.id if cursor else None,
)
rows = [dict(r) async for r in result]
has_more = len(rows) > limit
rows = rows[:limit]
next_cursor = (
Cursor(metres=rows[-1]["metres"], id=rows[-1]["id"]).encode()
if has_more and rows else None
)
return Page(rows=rows, next_cursor=next_cursor)
How It Works
The sort must be total, or the cursor has nothing to anchor to. ORDER BY metres alone is a partial order: two hubs equidistant from the origin can come back in either order, and the database is under no obligation to be consistent between calls. Appending the id makes the order total, which means “everything strictly after this point” identifies exactly one boundary. That is the property the whole approach rests on, and it is why the tiebreak is not optional even though ties feel unlikely — on a grid-planned city, equidistant pairs are common rather than rare.
The keyset predicate replaces SKIP, and its cost does not grow. SKIP 500 makes the server produce five hundred rows and throw them away; the keyset version asks the index for rows past a value, which is a seek to a position rather than a walk from the beginning. Page fifty costs what page one costs.
Fetching limit + 1 answers “is there more” for free. The alternative — a separate count() over the same region — doubles the work and answers a question nobody asked, since a total count of a radius search is rarely displayed and is expensive precisely when the result set is large.
Common Failure Patterns
1. Sorting by distance alone. The most common version of this bug, and the hardest to reproduce: it only misbehaves when two rows tie, so it works in development and produces occasional duplicates in production. Any keyset cursor needs a total order, and the id is the cheapest way to get one.
2. Putting the cursor’s distance in the box. It is tempting to shrink the bounding box on each page since the results are getting further away — but the box is what makes the query seekable, and narrowing its inner edge is not something a bounding box can express. The keyset predicate belongs on the computed distance, above the seek; the box stays the same on every page.
3. Treating the cursor as a durable handle. It encodes a position in an ordering, not a snapshot. If the origin or radius changes, the cursor is meaningless and must be discarded — so the endpoint should either include those parameters in the token and reject a mismatch, or make it impossible to pass a cursor with different search parameters.
# Bind the cursor to the search it came from, so a changed radius cannot
# silently resume into a different ordering.
def encode(self, centre, radius_m: float) -> str:
payload = {"m": self.metres, "i": self.id,
"c": [centre.latitude, centre.longitude], "r": radius_m}
return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
Performance Notes
The cost difference is the whole argument:
$$C_{\text{skip}}(p) \approx p \cdot L \cdot c_{\text{row}}, \qquad C_{\text{keyset}}(p) \approx L \cdot c_{\text{row}}$$
For page $p$ at page size $L$, SKIP is linear in the page number and the cursor is constant. On a ten-row page that is invisible until someone builds an export that walks every page, at which point the SKIP version is quadratic in the result size and the cursor version is linear.
There is a subtlety worth knowing: the distance is computed, not stored, so the ORDER BY metres cannot be served by the index and the sort happens over the candidate set the box returned. That is fine — the box has already reduced it to a manageable size — but it means the cursor’s benefit is in avoiding re-sorting and discarding, not in avoiding the sort altogether. Where pages are walked exhaustively and the region is large, storing a precomputed grid cell and paging on that instead moves the sort into the index, at the cost of ordering by cell rather than by true distance.
Both approaches read the same rows from the store on page one. The divergence starts at page two and grows, which is why this rarely shows up in a benchmark that measures a single request and always shows up in a support ticket about a slow “load more” button.
Two design points are worth settling before this reaches an API contract.
The first is what a cursor promises. A keyset cursor guarantees that no row the caller has already seen will be shown again, and that no row which was present and unchanged throughout the walk will be skipped. It does not guarantee a consistent snapshot: a hub inserted nearer than the current cursor position will never appear in that caller’s sequence, and one moved from beyond the cursor to before it will be missed. That is usually the right trade for a proximity search, where the alternative — holding a transaction open across a user’s paging — is far worse. But it is a property worth documenting rather than discovering, because “we never showed them the new depot” is a reasonable complaint from someone who does not know how the pagination works.
The second is what to do about deep paging at all. A radius search that needs fifty pages is usually a radius that is too large rather than a paging problem, and the better answer is often to say so: cap the total result set, return the count that was capped, and let the caller narrow the search. Deep pagination is cheap with a cursor, which makes it tempting to offer without limit — but a caller walking ten thousand hubs one page at a time is almost always building something that would be better served by a single bulk export with no ordering guarantee at all.
Related
- K-Nearest Neighbor Routing in Production Spatial Graphs — the search this pages through.
- Implementing KNN Search for Nearby Logistics Hubs — the box-then-clip query the cursor sits on top of.
- Latitude-Corrected Bounding Boxes in Python — the box that stays constant across every page.
- Keeping Spatial Queries in the Plan Cache — why the cursor is a parameter and not part of the query text.
This guide is part of K-Nearest Neighbor Routing, within Cypher Spatial Queries & Pathfinding Patterns.