On 100M rows, OFFSET is like asking a librarian to count 99 million books before handing you one page.
Question: How do I paginate 100M rows in SQL without page 100,000 becoming painfully slow?
Answer: Use keyset pagination, which means “start after the last row I already saw” instead of “skip N rows.” I sort by a stable key, usually something like created_at DESC, id DESC, and fetch the next page with a WHERE clause on that last key. This stays fast on deep pages because the database can seek into an index instead of scanning and discarding millions of rows.
Interview-Ready Answer: I would not use deep OFFSET pagination on 100M rows. I’d use keyset pagination with a deterministic sort order and a composite index, for example created_at DESC, id DESC. The first page uses LIMIT, and every next page uses the last row as a cursor: WHERE (created_at, id) < (:last_created_at, :last_id). That keeps latency stable, avoids skipping millions of rows, and prevents duplicates or missed rows when data changes between requests.
Think in terms of a bookmark, not a page number.
created_at DESC, id DESC. The tie-breaker matters because many rows share the same timestamp.(tenant_id, created_at DESC, id DESC). This lets the optimizer use the leftmost part of the index efficiently.ORDER BY ... LIMIT 50. Fifty or one hundred rows is a common page size because it keeps payloads small and the query cheap.WHERE (created_at, id) < (:last_created_at, :last_id) for descending order. The database can seek directly into the index, then read the next 50 rows.OFFSET 10,000,000 LIMIT 50 means the engine still has to find and discard ten million rows before it can hand you the next 50. With a good index this is still better than a full table scan, but the cost grows with the page number, so page 1 is fast and page 200,000 is not.
| Method | How it works | Best for | Problem |
|---|---|---|---|
| OFFSET + LIMIT | Skip N rows | Small lists | Deep pages slow |
| Keyset | Bookmark last row | Feeds, APIs | No random page jump |
| DB cursor | Keep session state | Batch jobs | Ties up connection |
O(offset + page_size).O(log N + page_size) with the right index.id.READ COMMITTED, so new rows can appear between requests.A common production case is an admin dashboard for a checkout service that stores 100M payment events. The first version used ORDER BY created_at DESC LIMIT 50 OFFSET 5000000 for the audit log. Page 1 was fine, but deeper pages took 20 to 40 seconds, support agents saw endless spinners, and logs showed huge buffer reads plus many rows being skipped. When new payments arrived, page numbers shifted, so the same event could appear twice or disappear between refreshes.
The fix was keyset pagination with a composite index on (created_at DESC, id DESC) and a cursor token built from the last row on each page. After that, latency stayed predictable, the UI felt instant, and the audit trail stopped drifting under live traffic.
What goes wrong when you misunderstand it: the app looks fast in testing with page 1, then falls apart in production on deep pages. Symptoms include timeouts on large offsets, hot CPU from repeated scans, log lines with huge row counts, and user reports like “I saw this order already” or “page 200 never loads.”
DROP TABLE IF EXISTS audit_log;
CREATE TABLE audit_log (
id BIGINT PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
action TEXT NOT NULL
);
-- Duplicate timestamps are intentional: real feeds often have ties.
-- The id tie-breaker makes the order deterministic when created_at matches.
INSERT INTO audit_log (id, created_at, action) VALUES
(10, TIMESTAMP '2026-01-01 10:00:00', 'signup'),
(9, TIMESTAMP '2026-01-01 10:00:00', 'login'),
(8, TIMESTAMP '2026-01-01 09:59:30', 'view'),
(7, TIMESTAMP '2026-01-01 09:58:00', 'add_to_cart'),
(6, TIMESTAMP '2026-01-01 09:57:00', 'checkout'),
(5, TIMESTAMP '2026-01-01 09:56:30', 'payment'),
(4, TIMESTAMP '2026-01-01 09:55:00', 'refund'),
(3, TIMESTAMP '2026-01-01 09:54:00', 'logout'),
(2, TIMESTAMP '2026-01-01 09:53:00', 'support'),
(1, TIMESTAMP '2026-01-01 09:52:00', 'archive');
-- Match the index to the ORDER BY used by the page query.
-- On a large table, this lets the engine seek directly into the sorted data.
CREATE INDEX idx_audit_log_created_id_desc
ON audit_log (created_at DESC, id DESC);
-- Page 1: fast, simple, and what every pagination scheme starts with.
SELECT id, created_at, action
FROM audit_log
ORDER BY created_at DESC, id DESC
LIMIT 4;
-- Page 2 with keyset pagination:
-- The last row of page 1 becomes the bookmark for the next request.
WITH first_page AS (
SELECT id, created_at, action
FROM audit_log
ORDER BY created_at DESC, id DESC
LIMIT 4
),
last_seen AS (
SELECT created_at, id
FROM first_page
ORDER BY created_at ASC, id ASC
LIMIT 1
)
SELECT a.id, a.created_at, a.action
FROM audit_log a
CROSS JOIN last_seen s
WHERE (a.created_at, a.id) < (s.created_at, s.id)
ORDER BY a.created_at DESC, a.id DESC
LIMIT 4;
-- OFFSET still works, but it gets more expensive as the page number grows.
-- On 100M rows, this is the pattern that becomes painful.
SELECT id, created_at, action
FROM audit_log
ORDER BY created_at DESC, id DESC
LIMIT 4 OFFSET 4;
-- Edge case: cursor past the end returns no rows, which is correct.
SELECT id, created_at, action
FROM audit_log
WHERE (created_at, id) < (TIMESTAMP '2025-12-31 00:00:00', 0)
ORDER BY created_at DESC, id DESC
LIMIT 4;Follow-up & Tricky Questions:
> on the first visible row, then you reverse the result order if the UI wants the same direction.(tenant_id, created_at DESC, id DESC) so the engine can use the filter and the sort together.OFFSET okay if I have an index? It is fine for small offsets, but the database still has to walk past the skipped rows. The index helps, but it does not change the fact that deep offsets get slower as they grow.ORDER BY name? Yes, if the order is deterministic and indexed, but be careful with collation and duplicate names. Add a tie-breaker so the order never depends on luck.Common gotchas: The big trap is assuming page 1 performance means page 10,000 will also be fine. It will not.
Common Mistakes:
id so the order is stable.WHERE clause and ORDER BY together, not just the table columns.OFFSET in production. Fix: reserve it for shallow pages or admin-only small tables.Memory Hook: “Don’t count to the page; bookmark the shelf.” If you already know the last row, ask for the rows after it.
Cheat Sheet:
created_at plus id.OFFSET is easy but gets slower as the offset grows.Practice Tasks:
tenant_id = 42 and update the index to match it.