python 100 lines · 3 tabs

Cursor-Based Pagination in FastAPI with a Dependency and RFC 5988 Link Headers

Shared by codesnips Aug 2026
3 tabs
import base64
import json
from datetime import datetime
from typing import NamedTuple

from fastapi import HTTPException


class Cursor(NamedTuple):
    created_at: datetime
    id: int


def encode_cursor(cursor: Cursor) -> str:
    payload = json.dumps(
        {"t": cursor.created_at.isoformat(), "i": cursor.id}
    ).encode("utf-8")
    return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")


def decode_cursor(raw: str) -> Cursor:
    try:
        padding = "=" * (-len(raw) % 4)
        payload = base64.urlsafe_b64decode(raw + padding)
        data = json.loads(payload)
        return Cursor(
            created_at=datetime.fromisoformat(data["t"]),
            id=int(data["i"]),
        )
    except (ValueError, KeyError, TypeError):
        raise HTTPException(status_code=400, detail="Invalid cursor")
3 files · python Explain with highlit

Cursor-based (keyset) pagination avoids the correctness and performance pitfalls of OFFSET/LIMIT paging: instead of skipping N rows, it anchors the next page on the last row's sort key, so inserts and deletes elsewhere in the table can't cause rows to be skipped or duplicated, and the query stays fast because the database can seek straight into the index. This snippet shows the whole flow in idiomatic FastAPI: a reusable cursor codec, a request-scoped dependency, and an endpoint that emits Link headers.

In cursor.py, a cursor is just an opaque, URL-safe base64 blob wrapping the tie-broken sort key — here the (created_at, id) pair. encode_cursor serializes that tuple to JSON and base64-encodes it so clients treat it as opaque and don't build their own offsets. decode_cursor reverses the process and raises HTTPException(400) on any malformed input, which keeps a tampered or truncated cursor from leaking a stack trace. Encoding an id alongside created_at is what makes the ordering total, so rows sharing a timestamp still page deterministically.

In pagination.py, CursorParams is a small dependency object built via Depends. It validates limit with Query bounds (1–100) and lazily decodes the incoming cursor only when accessed through the after property, so a request without a cursor pays no decoding cost. Modeling pagination as a dependency means every paginated route shares the same validation and default behavior for free.

In articles.py, list_articles fetches limit + 1 rows — the extra row is a cheap lookahead that reveals whether another page exists without a second COUNT query. The keyset predicate (created_at, id) < (:ts, :id) expresses the tie-broken comparison as a row-value comparison, which most databases optimize against a composite index. The handler trims the sentinel row, computes the next cursor from the last surviving row, and calls _set_link_header to write an RFC 5988 Link header with rel="next", mirroring how GitHub's API paginates. Clients follow the header rather than constructing URLs, so the server keeps full control over ordering and cursor format. The trade-off is that keyset paging can't jump to an arbitrary page number and needs a stable, indexed sort key — for infinite-scroll and feed-style APIs that's exactly the right shape.


Related snips

Share this code

Here's the card — post it anywhere.

Cursor-Based Pagination in FastAPI with a Dependency and RFC 5988 Link Headers — share card
Link copied