← Back to Spatial Pagination & Cursor Strategies
Replace OFFSET/LIMIT with a deterministic keyset cursor so PostGIS bounding-box queries page in constant time regardless of dataset depth.
Context & When to Use
OFFSET-based pagination asks PostgreSQL to materialize the entire filtered result set, sort it, discard the first N rows, then return the next batch. For spatial workloads this is doubly expensive: the GiST index that accelerates && or ST_DWithin cannot skip scanned tuples, so query latency grows linearly as page depth increases. At page 500 of a 20-row page, the database discards 9 980 rows on every request.
Keyset pagination eliminates that waste. Instead of skipping rows, the API decodes a cursor representing the last returned row’s primary key, applies a WHERE id > :cursor_id clause, and lets the B-tree index jump straight to that boundary. Query cost becomes proportional to the page size — not the page number. This is the technique described in Spatial Pagination & Cursor Strategies and the right choice whenever your API must serve deep, stable pages of spatial features.
Use this approach when:
- The result set has more than ~1 000 features and clients page forward sequentially (maps, exports, feeds).
- Concurrent writes are possible between page requests — offset drift produces duplicate or missing rows, keyset traversal does not.
- You want your geospatial API architecture to stay stateless: the cursor encodes all resume state so the server stores nothing between requests.
It is not the right tool when clients need random page access (jump to page 47), because keyset traversal is inherently forward-only. For random access, materialize result sets into Redis or accept the offset trade-off on bounded datasets.
Data Flow Overview
The diagram below shows how a single paginated request moves through the stack, from the decoded cursor through the dual-index query to the encoded next cursor in the response.
Schema & Index Prerequisites
Your PostGIS table needs two indexes: a GiST index for the spatial pre-filter, and a B-tree index for keyset traversal. Both must exist before the query planner can execute the pattern efficiently.
CREATE TABLE locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
geom GEOMETRY(Point, 4326) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Spatial pre-filter: GiST accelerates the && bounding-box operator
CREATE INDEX idx_locations_geom ON locations USING GIST (geom);
-- Keyset traversal: B-tree enables WHERE id > :cursor_id as a range scan
CREATE INDEX idx_locations_id ON locations (id);If your workload sorts by distance (ORDER BY geom <-> :origin), also create a compound index on (geom, id) so the planner can resolve the KNN scan and the tiebreaker without a separate sort step.
Runnable Implementation
The endpoint below is production-ready with SQLAlchemy 2.0 async, Pydantic v2, and URL-safe Base64 cursor encoding. The spatial pre-filter uses && (bounding-box overlap, GiST-accelerated), and pagination relies on id > :cursor_id.
import base64
import json
from typing import Optional, List
from fastapi import FastAPI, Query, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
DATABASE_URL = "postgresql+asyncpg://user:password@localhost/gisdb"
engine = create_async_engine(DATABASE_URL, pool_size=10, max_overflow=5)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
app = FastAPI()
# ── Models ────────────────────────────────────────────────────────────────────
class LocationOut(BaseModel):
id: str
name: str
geom_wkt: str # WKT for API transport; swap ST_AsGeoJSON for GeoJSON output
created_at: str
class PagedLocations(BaseModel):
items: List[LocationOut]
next_cursor: Optional[str] = None # absent → caller is on the last page
# ── Cursor helpers ────────────────────────────────────────────────────────────
def encode_cursor(row_id: str) -> str:
"""Encode a UUID string to a URL-safe Base64 cursor token."""
payload = json.dumps({"id": row_id}).encode()
return base64.urlsafe_b64encode(payload).decode()
def decode_cursor(token: str) -> dict:
"""Decode and validate a cursor token; raises 400 on any tampering."""
try:
payload = base64.urlsafe_b64decode(token.encode()).decode()
data = json.loads(payload)
if "id" not in data:
raise ValueError("missing id key")
return data
except Exception:
raise HTTPException(status_code=400, detail="Invalid cursor token")
# ── Dependency ────────────────────────────────────────────────────────────────
async def get_db():
async with AsyncSessionLocal() as session:
yield session
# ── Route ─────────────────────────────────────────────────────────────────────
@app.get("/locations", response_model=PagedLocations)
async def list_locations(
bbox: str = Query(
...,
description="Spatial filter: minx,miny,maxx,maxy (EPSG:4326)",
example="-0.5,51.3,0.2,51.6",
),
cursor: Optional[str] = Query(None, description="Opaque continuation token from previous page"),
limit: int = Query(20, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
# 1. Parse and validate the bounding box
try:
minx, miny, maxx, maxy = map(float, bbox.split(","))
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="bbox must be minx,miny,maxx,maxy")
# 2. Build the keyset WHERE clause depending on cursor presence
if cursor:
cursor_data = decode_cursor(cursor)
sql = text("""
SELECT
id::text AS id,
name,
ST_AsText(geom) AS geom_wkt,
created_at::text AS created_at
FROM locations
WHERE geom && ST_MakeEnvelope(:minx, :miny, :maxx, :maxy, 4326)
AND id > :cursor_id::uuid -- keyset resume; B-tree range scan
ORDER BY id ASC
LIMIT :limit
""")
params = {
"minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy,
"cursor_id": cursor_data["id"],
"limit": limit,
}
else:
sql = text("""
SELECT
id::text AS id,
name,
ST_AsText(geom) AS geom_wkt,
created_at::text AS created_at
FROM locations
WHERE geom && ST_MakeEnvelope(:minx, :miny, :maxx, :maxy, 4326)
ORDER BY id ASC
LIMIT :limit
""")
params = {"minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy, "limit": limit}
# 3. Execute and map rows
result = await db.execute(sql, params)
rows = result.mappings().all()
items = [LocationOut(**row) for row in rows]
# 4. Emit next_cursor only when the page is full (signals more pages exist)
next_cursor = encode_cursor(items[-1].id) if len(items) == limit else None
return PagedLocations(items=items, next_cursor=next_cursor)Key Parameters & Options
| Parameter | Type | Default | Notes |
|---|---|---|---|
bbox | str | required | minx,miny,maxx,maxy in EPSG:4326. Drives ST_MakeEnvelope; the wider the box, the more rows the GiST scan touches. |
cursor | str | None | URL-safe Base64 token. Absent on first page; clients pass back whatever next_cursor the API returns. |
limit | int | 20 | Capped at 200. Larger values increase individual query cost but reduce round-trip count for bulk consumers. |
ORDER BY | SQL | id ASC | Must be deterministic. Replace id with a composite key if you sort by distance — always append id as a tiebreaker. |
ST_MakeEnvelope | SQL | — | Fourth argument must match the geometry column’s SRID (4326 here). Mismatches produce silent wrong results, not errors. |
pool_size | engine | 10 | Size the connection pool relative to your concurrent request volume; spatial queries hold connections longer than simple CRUD. |
Gotchas & Failure Modes
Floating-point sort instability. Sorting purely by
ST_Distanceor the<->operator produces ties for co-located points. The planner may resolve ties differently on each call, causing rows to appear on two consecutive pages or be skipped entirely. Always append, id ASCas the final sort key and encode both the distance andidin the cursor.SRID mismatch in
ST_MakeEnvelope. If the geometry column stores data in EPSG:3857 (Web Mercator) but the envelope is built in 4326, the bounding box silently misses or over-selects features. Check withSELECT Find_SRID('public', 'locations', 'geom')before going live.Cursor decoded but index not used. If
EXPLAIN ANALYZEshows a sequential scan after adding the cursor, the planner may have decided the filtered row count makes an index scan more expensive. RunANALYZE locationsto refresh table statistics and setrandom_page_cost = 1.1on SSD-backed instances to steer the planner back to the index.Limit exactly equals result count on the last page. If the last page happens to contain exactly
limitrows, the API emits anext_cursorthat resolves to an empty page. Clients must handle an emptyitemslist as the true end-of-stream signal, or you can reduce the internal query limit tolimit + 1and use the extra row only as a lookahead sentinel without including it in the response.URL encoding of the Base64 token. Standard Base64 uses
+and/, which must be percent-encoded in query strings. The implementation above usesurlsafe_b64encode, which substitutes-and_instead — safe to pass in a URL without additional encoding. Mixing the two variants corrupts the cursor silently.
Verification
Run this against a local PostGIS instance to confirm both indexes are used:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id::text, name, ST_AsText(geom) AS geom_wkt, created_at::text
FROM locations
WHERE geom && ST_MakeEnvelope(-0.5, 51.3, 0.2, 51.6, 4326)
AND id > 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'::uuid
ORDER BY id ASC
LIMIT 20;Look for these indicators in the output:
Index Scan using idx_locations_geom— GiST spatial pre-filter activeIndex Cond: (geom && '...'::geometry)— bounding box pushed into the index scanFilter: (id > '...'::uuid)— keyset predicate applied after GiST, before row projectionRows Removed by Filtershould be small relative torows=in the index scan node
End-to-end smoke test with curl:
# First page (no cursor)
curl -s "http://localhost:8000/locations?bbox=-0.5,51.3,0.2,51.6&limit=5" | python3 -m json.tool
# Second page — paste next_cursor from the response above
curl -s "http://localhost:8000/locations?bbox=-0.5,51.3,0.2,51.6&limit=5&cursor=<next_cursor>" | python3 -m json.toolA valid response on the second call must not repeat any id from the first page, and next_cursor must be absent when the final page contains fewer than limit items.
Related
- Spatial Pagination & Cursor Strategies — decision matrix comparing keyset, offset, and seek-method patterns for spatial APIs
- GeoJSON vs GeoParquet Serialization — choosing the right response format once your pagination is stable
- API Versioning for GIS Endpoints — evolving cursor schemas across API versions without breaking clients
← Back to Spatial Pagination & Cursor Strategies