Implementing Cursor-Based Pagination with PostgreSQL

This page is part of the Offset vs Cursor Pagination reference under Query Patterns & Data Shaping Strategies. It walks through the full migration — from diagnosing a degrading OFFSET query to a production-ready keyset implementation with an OpenAPI contract and CI/CD guards.

When to Apply This Pattern

LIMIT … OFFSET forces PostgreSQL to materialize and discard every row before the offset on each request. The cost is linear with the offset value, so performance collapses as users reach deeper pages or as the table grows. The specific triggers that indicate you need this migration are:

Signal Threshold that demands action
Slow query log shows Seq Scan with actual rows far below rows estimate Table > 50 k rows
statement_timeout fires only on paginated routes p99 latency > 500 ms
Duplicate or missing records across adjacent pages Any occurrence under concurrent writes
pg_stat_statements mean execution time climbs with dataset size > 200 ms mean on paginated endpoints

If two or more of these apply, the resolution is a keyset query backed by a composite index — not query tuning or caching.


Offset vs keyset pagination row traversal in PostgreSQL Left side shows OFFSET pagination scanning all rows up to the offset before returning results. Right side shows keyset pagination jumping directly to the cursor position via an index seek. OFFSET pagination row 1 — discarded row 2 — discarded … N rows discarded … row N+1 → returned row N+2 → returned row N+3 → returned Full scan: O(N+page) cost grows with offset Keyset pagination Composite index (created_at DESC, id DESC) B-tree leaf seek → O(log N) cursor row → first result next row → returned next row → returned Index seek: O(log N + page) cost is constant regardless of depth

OpenAPI Contract Snippet

Declare the PageInfo schema before touching the database. This anchors the response shape for both server and client:

# openapi/components/schemas/PageInfo.yaml (OpenAPI 3.1.0)
PageInfo:
  type: object
  required:
    - has_next_page
  properties:
    next_cursor:
      type: string
      nullable: true
      description: >
        Opaque Base64-encoded JSON tuple representing the last row in the
        current page. Omitted (or null) when has_next_page is false.
      example: "eyJ2IjoxLCJpZCI6IjQ5OSIsImNyZWF0ZWRfYXQiOiIyMDI1LTA4LTAxVDEwOjAwOjAwWiJ9"
    has_next_page:
      type: boolean
      description: True when at least one more page follows this one.
    sort_field:
      type: string
      enum: [created_at, updated_at]
      description: The field the cursor is anchored to.

Enforce this contract in CI with a JSON Schema assertion — if has_next_page is false, next_cursor must be absent or null. This rule catches backend bugs before they surface in generated SDKs.

Step-by-Step Resolution

Step 1 — Baseline the existing query

Run EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) on the current paginated endpoint. A plan containing Seq Scan or Index Scan Backward with a large rows removed by filter count confirms the problem:

EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM events
ORDER BY created_at DESC
LIMIT 50 OFFSET 10000;

Save this baseline plan to .ci/pagination-baseline.json so later steps can diff against it.

Step 2 — Add a composite index

A keyset WHERE (created_at, id) < (:a, :b) predicate is only efficient when the index covers both columns in the same direction as ORDER BY. Missing or misaligned indexes cause the planner to fall back to a sequential scan, which erases all performance gains.

-- Forward pagination (newest first)
CREATE INDEX CONCURRENTLY idx_events_created_at_id_desc
  ON events (created_at DESC, id DESC);

-- Backward pagination (oldest first) — only needed if your API exposes prev_cursor
CREATE INDEX CONCURRENTLY idx_events_created_at_id_asc
  ON events (created_at ASC, id ASC);

Verify index utilization after creation:

SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'events'
  AND indexrelname LIKE '%created_at%';

idx_scan = 0 after the next test run means the planner is still ignoring the index — check for type mismatches between the index column and the query parameter.

Step 3 — Rewrite the query to keyset style

Replace the OFFSET clause with a row-value comparison. PostgreSQL supports tuple comparison natively, which maps directly to the composite index:

-- Forward: next page after cursor
SELECT * FROM events
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;

-- Backward: previous page before cursor
SELECT * FROM events
WHERE (created_at, id) > (:cursor_created_at, :cursor_id)
ORDER BY created_at ASC, id ASC
LIMIT 50;

Why the primary key is mandatory: sorting only on created_at produces an unstable result set whenever two rows share the same timestamp. Appending id makes the sort total-ordered and guarantees that the cursor position is unique.

Step 4 — Encode the cursor

Cursors must be opaque, versioned, and URL-safe. Clients must not parse them; the server owns the format. Encode as base64url(JSON):

TypeScript (Zod):

import { z } from 'zod';

const CursorPayload = z.object({
  v: z.literal(1),
  id: z.string(),
  created_at: z.string(), // ISO 8601
});

type Cursor = z.infer<typeof CursorPayload>;

export function encodeCursor(row: { id: string; created_at: string }): string {
  const payload: Cursor = { v: 1, id: row.id, created_at: row.created_at };
  return Buffer.from(JSON.stringify(payload)).toString('base64url');
}

export function decodeCursor(raw: string): Cursor {
  const json = Buffer.from(raw, 'base64url').toString('utf-8');
  return CursorPayload.parse(JSON.parse(json));
}

Python:

import base64, json
from dataclasses import dataclass

@dataclass
class CursorPayload:
    v: int
    id: str
    created_at: str  # ISO 8601

def encode_cursor(row: dict) -> str:
    payload = {"v": 1, "id": row["id"], "created_at": row["created_at"]}
    return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")

def decode_cursor(raw: str) -> CursorPayload:
    # Restore padding
    padding = 4 - len(raw) % 4
    padded = raw + "=" * (padding % 4)
    data = json.loads(base64.urlsafe_b64decode(padded))
    assert data.get("v") == 1, f"Unknown cursor version: {data.get('v')}"
    return CursorPayload(**data)

Reject cursors with an unknown v value with 400 Bad Request and an RFC 7807 problem+json body.

Step 5 — Wire into the API response

Return the encoded cursor alongside has_next_page. Fetch limit + 1 rows to determine whether a next page exists without a separate COUNT(*) query:

// Express handler (TypeScript)
app.get('/v1/events', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 50, 200);
  const rawCursor = req.query.cursor as string | undefined;

  let whereSql = '';
  const params: unknown[] = [limit + 1];

  if (rawCursor) {
    const c = decodeCursor(rawCursor);
    whereSql = `WHERE (created_at, id) < ($2::timestamptz, $3::uuid)`;
    params.push(c.created_at, c.id);
  }

  const rows = await db.query(
    `SELECT id, created_at, payload
     FROM events
     ${whereSql}
     ORDER BY created_at DESC, id DESC
     LIMIT $1`,
    params,
  );

  const hasNext = rows.length > limit;
  const page = rows.slice(0, limit);
  const lastRow = page[page.length - 1];

  res.json({
    data: page,
    page_info: {
      has_next_page: hasNext,
      next_cursor: hasNext ? encodeCursor(lastRow) : null,
    },
  });
});

Step 6 — Client-side pagination loop

Python async iterator with retry on conflict:

import asyncio
from httpx import AsyncClient, HTTPStatusError

async def paginate_events(client: AsyncClient, limit: int = 50):
    cursor = None
    attempt = 0
    while True:
        params = {"limit": limit}
        if cursor:
            params["cursor"] = cursor
        try:
            resp = await client.get("/v1/events", params=params)
            resp.raise_for_status()
            page = resp.json()
            yield page["data"]
            info = page["page_info"]
            if not info["has_next_page"]:
                break
            cursor = info["next_cursor"]
            attempt = 0
        except HTTPStatusError as exc:
            if exc.response.status_code == 409 and attempt < 5:
                await asyncio.sleep(0.5 * (2 ** attempt))
                attempt += 1
            else:
                raise

RFC and Standard Compliance

RFC 9110 (HTTP Semantics) does not mandate a pagination mechanism, but it constrains the contract:

Idempotency and Caching

Cursor-based GET requests are safe and idempotent. A cursor encodes an exact position in the sort order, not a page number, so the same cursor always returns the same rows as long as the underlying data and index are unchanged. This makes cursor responses highly cacheable:

Cache-Control: max-age=60, stale-while-revalidate=30
ETag: "v1-cursor-eyJ2IjoxLCJpZCI6Ijk5OSJ9"

Do not cache responses for the first page (no cursor) beyond a short TTL — new rows appear at the top of a descending sort and stale first pages mislead clients. For deeper pages, longer TTLs are safe because older rows rarely change. This aligns with the broader statelessness and caching strategies applied across the API surface.

SDK and Codegen Downstream Effect

The OpenAPI PageInfo schema propagates into generated clients. Before vs after the keyset migration:

- // offset-based generated type
- interface PaginationParams { page: number; limit: number; }
- interface PaginationResponse { total: number; page: number; }

+ // cursor-based generated type
+ interface PaginationParams { cursor?: string; limit: number; }
+ interface PaginationResponse {
+   page_info: { has_next_page: boolean; next_cursor: string | null };
+ }

Generators that mark next_cursor as non-nullable when nullable: true is missing from the schema will emit code that crashes on the final page. Run a generator smoke-test in CI using the PageInfo schema fixture before merging spec changes.

Common Mistakes

Mistake Correct approach
Sorting only on created_at (non-unique column) Always append the primary key: ORDER BY created_at DESC, id DESC
Missing composite index or mismatched sort direction Create (created_at DESC, id DESC) — direction in the index must match ORDER BY
Transparent cursors that encode column names directly Use opaque Base64-encoded JSON; version the payload (v: 1)
Using COUNT(*) to determine has_next_page Fetch limit + 1 rows; if count > limit then has_next_page = true
Allowing mutable columns (e.g. updated_at) as the sole sort key Use append-only columns (created_at, id) or implement soft deletes to preserve sort stability

FAQ

How do I handle NULL values in cursor columns without breaking pagination continuity?

PostgreSQL sorts NULL first by default (NULLS FIRST in descending order). If NULL appears mid-stream it breaks cursor comparison. Enforce NOT NULL at the schema level where possible. If the column is nullable, normalize NULL to a sentinel value (COALESCE(created_at, '9999-12-31'::timestamptz)) in both the index expression and the query predicate, and keep that sentinel consistent for both forward and backward navigation.

What composite index structure should I use for multi-field cursor pagination?

Use (sort_field_1, sort_field_2, …, primary_key) with ASC/DESC directions matching the ORDER BY clause exactly. The primary key must be the last column to guarantee row uniqueness. Example: CREATE INDEX ON orders (status, created_at DESC, id DESC) paired with WHERE (status, created_at, id) < (:status, :created_at, :id).

Should cursor values be opaque or transparent for API consumers?

Always opaque in production. Transparent cursors that expose column names break forward compatibility when internal sort fields change — clients start parsing them and building hard dependencies. Provide a debug-only decode endpoint (GET /debug/cursor?raw=…) for operators. Never document the cursor’s internal format in the public OpenAPI spec.