Skip to main content

Command Palette

Search for a command to run...

How do you write efficient pagination for millions of rows?

Updated
•3 min read•View as Markdown

Efficient pagination is absolutely critical when dealing with millions of rows in PostgreSQL — especially if you care about performance, index usage, and UX for APIs or UI pages.

1. OFFSET/LIMIT Pagination (a.k.a. “Skip-Limit”)

SELECT * FROM orders
ORDER BY created_at
OFFSET 10000
LIMIT 50;

Pros:

  • Simple to implement

  • Page numbers are intuitive for UIs

Cons:

  • Slower as OFFSET increases — PostgreSQL must scan and discard the skipped rows

  • Non-deterministic under concurrent updates (rows can shift across pages)

  • Doesn’t scale beyond 100k rows

When to use:

  • Small datasets (or <100 pages)

  • Admin dashboards or static data

  • Temporary/internal tooling

2. Keyset Pagination (a.k.a. Seek Method)

Description:

Use a cursor (like created_at, id) to fetch the next page.

-- First page
SELECT * FROM orders
ORDER BY created_at, id
LIMIT 50;

-- Next page (cursor = last row)
SELECT * FROM orders
WHERE (created_at, id) > ('2024-07-01 10:00:00', 'uuid-123')
ORDER BY created_at, id
LIMIT 50;

Pros:

  • O(1) pagination — always uses index seek

  • Safe under high concurrency

  • Deterministic (no skipping/overlaps)

  • Ideal for real-time apps

Cons:

  • Can't jump to arbitrary page (e.g., page 10)

  • Cursor management required

Index recommendation:

CREATE INDEX ON orders (created_at, id);

3. Hybrid Keyset + Offset (for Jumping to Specific Pages)

Description:

Use OFFSET for initial jump + keyset after that.

E.g., cache the (created_at, id) of the first row on page 10 → use that as keyset.

Pros:

  • Allows page jumping

  • Still uses index efficiently from that point onward

Cons:

  • Adds complexity (requires cursor cache)

  • Still slow on very high page numbers if not cached

Use case:

  • UIs where both page numbers and infinite scroll are required

4. Cursor-based Pagination (Encoded Cursors)

This is commonly used in APIs (GraphQL, REST with cursor tokens).

Description:

Return a base64-encoded cursor like:

{
  "next_cursor": "created_at=2024-07-01T10:00:00&id=abc-123"
}

In the next query:

SELECT * FROM orders
WHERE (created_at, id) > (:created_at, :id)
ORDER BY created_at, id
LIMIT 50;

Pros:

  • API-friendly, stateless pagination

  • Easy to cache cursors

  • Works with dynamic filters

How to Handle UPDATEs / Concurrent Changes

Challenge Problem
Rows inserted/deleted during pagination Causes "row shifting" on OFFSET pagination
Rows updated (e.g., created_at changed) Might move between pages if paginating on that column

For Large Scale Dataset pagination

Tip Why it Matters
Use composite indexes (created_at, id) is better than created_at alone
Avoid OFFSET over 10k degrades performance fast
Consider materialized views or temp tables for heavy analytical pagination
Expose cursor in your API layer makes UX smooth & stateless
Cache row cursors by page hybrid offset-keyset model
Use REPEATABLE READ isolation if pagination happens within a transaction to avoid data shifts

When to Use Each Method

Use Case Recommended Pagination
Admin dashboard < 10k rows OFFSET/LIMIT
Real-time web/mobile UI Keyset pagination
Reporting with stable pages Window function with ROW_NUMBER()
APIs with infinite scroll Cursor-based (encoded cursor tokens)
UI with both pages & infinite scroll Hybrid keyset + cursor caching

More from this blog

Kiran Sabne — PostgreSQL, AI & Backend Engineering at Scale

15 posts

Engineering Notes by Kiran Sabne — deep dives into PostgreSQL performance tuning, database internals, AI & vector search workloads, CDC pipelines, and backend systems at scale. I'm a backend and database engineer with 8+ years working on production PostgreSQL, Aurora/RDS, Go, AI & Machine learning. This blog covers real production incidents, query optimization, indexing strategies, replication, and data pipeline design — the stuff you learn debugging systems at 3am. New posts weekly.