Stockio

Cursor Pagination in PostgreSQL: A REST API Guide

Build stable cursor pagination with PostgreSQL keyset queries, composite ordering, opaque tokens, and explicit behavior for filters, deleted rows, and new records.

By Chidi Nwosu

Backend Engineer ·

On this page

Pagination becomes a correctness problem when a collection changes between requests. A customer browsing invoices should not see the same invoice twice simply because another invoice was inserted. Large offsets also force databases to walk past increasing amounts of data before returning a small page. Cursor pagination can address both problems when its ordering is designed carefully.

Define the collection before defining the cursor

Start with a concrete endpoint such as an account's invoices, ordered newest first. Decide which filters are supported, whether records can change position, and whether consumers need random page access. Cursor pagination is excellent for sequential traversal, but it does not naturally provide a reliable jump to page 400.

Sketch a response and inspect it in the JSON Formatter. Include an item array and a next cursor, and use null when there is no following page. Avoid exposing database-specific implementation details as fields that consumers will inevitably depend on.

Write down the consistency promise. An ordinary cursor does not create a frozen snapshot across requests. New records may appear before the current position; changed sort values can move records across the boundary. If the feature requires a legally exact export, use a separate snapshot or export workflow rather than implying that browsing semantics provide one.

Choose a stable, unique ordering

Ordering only by creation time is insufficient because multiple rows can share a timestamp. Add a unique tiebreaker, such as an immutable invoice identifier. For descending traversal, both comparisons must follow the same descending order. This produces a total order: every eligible row has a deterministic position.

SELECT id, created_at, total_cents
FROM invoices
WHERE account_id = $1
AND (created_at, id) < ($2::timestamptz, $3::bigint)
ORDER BY created_at DESC, id DESC
LIMIT $4;

The first request omits the boundary predicate. Subsequent requests use the last returned row's timestamp and identifier. PostgreSQL supports row comparisons, but check null handling before applying this pattern to nullable sort columns. Prefer non-null pagination keys or define explicit null ordering and matching predicates.

Use an index beginning with the equality filter and followed by the ordering columns, such as account identifier, creation time, and identifier. Verify the actual plan rather than assuming any index containing those columns is equally useful. Additional filters may need different indexes or intentionally restricted combinations.

Design opaque cursors and page boundaries

A cursor can contain a version, the last ordering tuple, and a fingerprint of relevant filters. Encoding JSON as Base64url makes transport convenient; it does not encrypt the contents or prevent tampering. Do not embed secrets, and never treat a decoded account identifier as authorization.

If tamper resistance matters, authenticate the token using a maintained signing implementation. Validate token length before decoding, then validate the decoded schema and version. Bind the cursor to filters and tenant context so accidentally reusing it on another collection produces a clear input error rather than confusing results.

Fetch one extra row beyond the requested page size. If the caller requests 25 records, query for 26, return the first 25, and derive the next cursor from record 25 only when an extra record exists. Building the cursor from record 26 would silently skip that row.

Clamp page size to a documented maximum and reject invalid values consistently. A missing next cursor means no further results at that moment; it is not a permanent promise that the collection will never grow.

Handle mutations and reverse navigation explicitly

An immutable creation timestamp usually behaves better than an editable title or activity score. If a row's ordering key changes during traversal, it can move behind or ahead of the cursor. Explain this limitation for activity feeds and ranking systems rather than promising perfect deduplication from an ordinary keyset query.

For backward navigation, reverse both the comparison and query ordering, fetch the bounded result, then reverse the returned rows into the public presentation order. Test equal timestamps and page boundaries carefully. Supporting before and after together increases contract complexity; reject combinations you do not intend to support.

Deletion of the cursor's source row should not break traversal when the token stores the ordering values themselves. An implementation that looks up the last row by identifier can fail unnecessarily after deletion. Store enough boundary information to continue without that lookup.

Do not attach a costly exact count to every page by habit. Counts may be stale immediately and can dominate query time. Offer approximate counts, separately fetched counts, or no count depending on the product's actual needs. PostgreSQL's index documentation is a useful starting point for understanding the trade-offs.

Verify performance and authorization together

Create fixtures with repeated timestamps, empty results, a final partial page, and records belonging to different accounts. Walk every page and assert that the expected identifiers appear exactly once under a stable dataset. Then insert and delete records between requests to confirm the documented behavior.

Every page still needs tenant authorization. A signed cursor is not a substitute for checking the current caller's permissions. If access changes halfway through traversal, subsequent requests must use current policy, not the permissions implied by the first page.

Inspect plans with EXPLAIN using realistic account sizes. Compare early and late pages, since keyset pagination's advantage is avoiding growing offsets. Measure endpoint timing with the API Latency Tester, while remembering that a browser timing includes network conditions and cannot identify the database bottleneck alone.

Document cursor expiration, invalid-token responses, and page-size limits in your Backend & APIs reference. Treat cursor format as private, but maintain supported old versions long enough for normal browsing sessions and integrations to finish safely.

Key takeaways

  • Use an immutable composite ordering with a unique tiebreaker and a matching index.
  • Fetch one extra row, but create the next cursor from the last row actually returned.
  • Treat opaque cursors as untrusted input and recheck authorization on every request.
  • Distinguish convenient browsing from snapshot-consistent exports and exact counting.
Featured sponsor

Reach designers & developers at work

Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.

Become a sponsor

Keep reading

Related articles

More in Backend & APIs
Backend & APIs

10 Best API Testing Tools

Choose an API testing tool for quick requests, shared collections, or automated checks, with clear notes on each option’s workflow, strengths, limitations, and pricing model.

Includes: JSON formatterChidi NwosuChidi Nwosu
Backend & APIs

How to Prevent SQL Injection With Parameterized Queries

Keep SQL structure separate from untrusted values, handle dynamic sorting and search safely, and test database access paths without relying on escaping or ORM assumptions.

Includes: JSON formatterChidi NwosuChidi Nwosu
Backend & APIs

Headless Architecture Explained: Decoupling Content

Plan a headless publishing architecture with explicit content contracts, safe rendering, resilient caching, and previews that editors can actually trust.

Includes: JSON formatterChidi NwosuChidi Nwosu
Backend & APIs

Authorization Code Flow With PKCE: A Backend Guide

Implement OAuth authorization code flow with PKCE, state validation, secure code exchange, and server-side token handling without confusing OAuth with login.

Includes: JSON formatterChidi NwosuChidi Nwosu
Backend & APIs

Idempotency Keys: Safe Retries for POST Requests

Prevent duplicate orders and payments with durable idempotency keys, request fingerprints, concurrency control, and clearly documented retry and retention rules.

Includes: JSON formatterChidi NwosuChidi Nwosu
Backend & APIs

Verify Webhook Signatures and Handle Duplicate Events

Build reliable webhook receivers with raw-body signature verification, replay checks, durable event storage, and idempotent processing that survives retries.

Includes: JSON formatterChidi NwosuChidi Nwosu