python 105 lines · 3 tabs

Opaque Cursor Pagination for FastAPI Endpoints with SQLAlchemy

Shared by codesnips Jul 2026
3 tabs
import base64
import json
from datetime import datetime
from typing import Optional, Tuple


class InvalidCursor(Exception):
    pass


def encode_cursor(created_at: datetime, item_id: int) -> str:
    payload = json.dumps({"c": created_at.isoformat(), "i": item_id})
    return base64.urlsafe_b64encode(payload.encode()).decode()


def decode_cursor(token: Optional[str]) -> Optional[Tuple[datetime, int]]:
    if not token:
        return None
    try:
        raw = base64.urlsafe_b64decode(token.encode()).decode()
        data = json.loads(raw)
        return datetime.fromisoformat(data["c"]), int(data["i"])
    except (ValueError, KeyError, TypeError, json.JSONDecodeError) as exc:
        raise InvalidCursor("malformed pagination cursor") from exc
3 files · python Explain with highlit

Cursor pagination (also called keyset pagination) avoids the classic OFFSET problem where deep pages get slower and rows shift under concurrent inserts. Instead of skipping N rows, it remembers the sort position of the last row seen and asks the database for rows strictly after it. This snippet wires up an opaque, tamper-resistant cursor for a FastAPI endpoint backed by SQLAlchemy.

The cursor.py tab defines the cursor contract. A cursor is just the ordering key of the last item in a page — here the tuple (created_at, id) — serialized to JSON and base64-encoded so the client treats it as an opaque token rather than a poke-able offset. encode_cursor and decode_cursor handle round-tripping, and decode_cursor raises InvalidCursor on any malformed input so the endpoint can turn it into a clean 400 rather than a 500. Encoding the full sort key (not just the id) is essential: sorting on a non-unique column like created_at requires the id tie-breaker to guarantee a total order, otherwise rows at a timestamp boundary can be skipped or duplicated.

The paginate.py tab holds the reusable query logic. apply_cursor builds the keyset predicate using a row-value comparison — (created_at, id) > (last_created_at, last_id) — expressed with SQLAlchemy's tuple_(...) so a single composite index on (created_at, id) satisfies it. The keyset_page helper always fetches limit + 1 rows: the extra row is a cheap probe that tells it whether a next page exists without a second COUNT query. If the extra row comes back, it is trimmed off and the last kept row becomes the next cursor.

The orders_api.py tab exposes GET /orders. It validates limit with Query bounds, decodes any incoming cursor, delegates to keyset_page, and returns a Page model containing the serialized items and a next_cursor that is None on the final page. A key trade-off worth noting: keyset pagination supports only forward (or reverse) traversal along a stable sort, not random jumps to page 42, and the sort columns must be immutable enough that a row's key does not move mid-scan. For infinite-scroll feeds and large tables, that constraint is well worth the consistent performance.


Related snips

Share this code

Here's the card — post it anywhere.

Opaque Cursor Pagination for FastAPI Endpoints with SQLAlchemy — share card
Link copied