How to Implement Cursor-Based Pagination

Offset pagination breaks at scale: latency grows linearly with depth, and the result set shifts when rows are inserted mid-scroll. Cursor-based (keyset) pagination fixes both by encoding the last-seen row as an opaque cursor instead of counting from the top.

Backend Engineerpythonfastapipostgresql

What's wrong with OFFSET pagination

OFFSET N LIMIT 20 tells Postgres to scan and discard the first N rows, then return the next 20. At page 500 that's 10,000 rows thrown away - latency is O(offset), not O(1). There's also a correctness problem: if a new row is inserted while someone scrolls, every subsequent page shifts by one and the user sees duplicates or skips.

How keyset cursors work

Instead of tracking a page number, track the last row the client saw. For a created_at DESC, id DESC feed the cursor encodes the pair (created_at, id) of the final row on the previous page. The next query asks Postgres for rows that come after that boundary:

SELECT id, event_type, payload, created_at
FROM events
WHERE (created_at, id) < (%s, %s)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Postgres's tuple comparison evaluates left-to-right: created_at is compared first; id breaks ties when two events share a timestamp. Both columns need the same sort direction so the composite index covers both the WHERE and the ORDER BY.

The index that makes it O(log n)

CREATE INDEX ix_events_created_id
    ON events (created_at DESC, id DESC);

Without this, the cursor predicate is a sequential scan. With it, Postgres seeks directly to the boundary row - constant seek time regardless of how many pages deep the client has scrolled.

FastAPI implementation

import json, base64
from datetime import datetime
import psycopg2
from fastapi import FastAPI, HTTPException

app = FastAPI()
DB = "postgres://postgres@localhost:5432/app"

def _encode_cursor(created_at: datetime, id_: int) -> str:
    raw = json.dumps({"ts": created_at.isoformat(), "id": id_}).encode()
    return base64.urlsafe_b64encode(raw).decode().rstrip("=")

def _decode_cursor(cursor: str) -> tuple:
    pad = "=" * (-len(cursor) % 4)
    try:
        data = json.loads(base64.urlsafe_b64decode(cursor + pad))
        return datetime.fromisoformat(data["ts"]), int(data["id"])
    except Exception:
        raise HTTPException(status_code=400, detail="invalid cursor")

@app.get("/api/events")
def list_events(cursor: str | None = None, limit: int = 20):
    limit = max(1, min(limit, 100))
    conn = psycopg2.connect(DB)
    cur = conn.cursor()
    if cursor:
        ts, id_ = _decode_cursor(cursor)
        cur.execute(
            "SELECT id, event_type, created_at FROM events"
            " WHERE (created_at, id) < (%s, %s)"
            " ORDER BY created_at DESC, id DESC LIMIT %s",
            (ts, id_, limit),
        )
    else:
        cur.execute(
            "SELECT id, event_type, created_at FROM events"
            " ORDER BY created_at DESC, id DESC LIMIT %s",
            (limit,),
        )
    rows = cur.fetchall()
    conn.close()
    next_cursor = _encode_cursor(rows[-1][2], rows[-1][0]) if len(rows) == limit else None
    return {"events": rows, "next_cursor": next_cursor, "limit": limit}

The cursor is base64-encoded JSON - opaque to clients so you can change the internal format later without breaking callers. Invalid cursors return 400.

Tradeoffs to know

Keyset pagination has one real limitation: there is no random access. You cannot jump to "page 47 of 200" - you can only move forward from a cursor. For most infinite-scroll feeds that's fine. For UIs that need "page N of M" navigation, a common hybrid keeps offset for the first page count and switches to a cursor for subsequent pages.

Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.

Try it in a workspace →

What you'll practice

FAQ

What is cursor-based pagination and how does it differ from offset pagination?

Cursor-based (keyset) pagination uses a WHERE clause against the last-seen row's sort key instead of OFFSET. It has constant latency regardless of depth, and the result set is stable when rows are inserted mid-scroll - OFFSET pagination has neither property.

What should a pagination cursor encode?

It should encode all columns in the ORDER BY clause - enough to uniquely locate the boundary row. For a created_at DESC, id DESC feed, encode both (created_at, id) so two events with the same timestamp don't cause duplicates or skips.

Why does cursor-based pagination need a composite index?

Without an index, the WHERE (created_at, id) < (%s, %s) predicate causes a sequential scan. A composite index on (created_at DESC, id DESC) lets Postgres seek directly to the boundary row, making each page fetch O(log n) instead of O(rows).

Keep learning

Return the Correct HTTP Status CodeBackend projectRepair Broken API PaginationBackend projectAdd CORS Middleware to an Express APIBackend projectBackend roadmapStep by step to hiredBackend interview questionsSTAR answersAll Backend projectsProjects hub

Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.

Open the workspace →