Hook: Interviewers love this one because it turns a slow 'skip N rows' problem into a fast 'start from here' problem.
Question: What is keyset pagination, and why is it faster than OFFSET/LIMIT for large tables?
Answer: Keyset pagination means you page by the last row you already saw, not by counting from the beginning. Instead of saying 'give me page 200', you say 'give me the next rows after this key'. That lets the database jump into an ordered index near the right spot, which is much faster on big tables. The most important rule is to sort by a stable order, usually a timestamp plus a unique id.
Interview-Ready Answer: I use keyset pagination when I want fast, stable paging on large result sets. I sort by a deterministic key, like created_at and then a unique id, and for the next page I filter on the last seen key instead of using OFFSET. That avoids scanning and discarding thousands of rows, so the query stays fast even deep into the list. The big gotcha is that the order must be unique and stable, or you can get duplicates or skipped rows.
Keyset pagination is also called seek pagination. A seek means the database uses an ordered index to jump to a position, like opening a book at a bookmarked page, instead of flipping through every page from the front. The client sends back the last seen sort key values, often called a cursor, but this is not the same thing as a SQL cursor that holds server state.
ORDER BY, such as ORDER BY created_at, id. The extra unique column is a tie-breaker, which means it breaks ties when two rows share the same timestamp.LIMIT and the sort order. Example idea: 'give me the first 20 rows ordered by created_at, id'.2024-01-01 10:02:00 and 104.WHERE (created_at, id) > (...) for ascending order. That tells the database to start after the last row it already returned.| Aspect | OFFSET/LIMIT | Keyset |
|---|---|---|
| Deep pages | Slow | Fast |
| Work done | Skip rows | Seek then read |
| Stable under inserts | Poor | Better |
| Jump to page 1000 | Easy | Hard |
With OFFSET 100000 LIMIT 50, the database may still have to walk past about 100,000 rows before it can return the 50 you asked for. With keyset pagination, it seeks to the last key and reads the next 50 rows. In practice, that can be the difference between a query that finishes in milliseconds and one that takes seconds on a large table.
created_at, rows with the same timestamp can be skipped or repeated. Always add a unique tie-breaker like id.NULL are tricky because NULL does not compare like a normal value. Avoid nullable sort columns or define a clear NULLS FIRST/NULLS LAST rule.Memory rule: keyset pagination is 'bookmark, not count'. The bookmark is the last key you saw; the database continues from there.
OFFSET is simple and fine for tiny tables, but it gets more expensive as the offset grows. Keyset is the better default for performance-sensitive lists because the database can use the index order directly. The trade-off is that keyset needs a stable sort key and cannot easily jump to page 37 without walking there page by page.
Imagine an e-commerce admin dashboard that lists recent orders. On quiet days, the team used OFFSET/LIMIT to show page 1, page 2, and so on. During a flash sale, thousands of new orders arrived every minute, and page 3 started showing duplicate orders while some orders seemed to vanish between refreshes. The slow query log also lit up because a request like OFFSET 50000 forced the database to scan far too many rows.
The fix was keyset pagination on (created_at, id). The dashboard now asks for 'orders after the last order I saw', so the next page is stable even while new rows are inserted at the top. The incident symptoms were very concrete: users complained that the same order appeared twice, support agents missed a few recent orders, and PostgreSQL showed rising latency on the listing endpoint. After the change, the page response time dropped and the list became predictable again.
What goes wrong when you misunderstand it: if you page only by created_at, two orders with the same timestamp can cause one to be skipped. In a real outage, that looks like 'I cannot find the order anymore' even though the row is still in the database.
-- Keyset pagination demo in PostgreSQL
-- This script is runnable as-is.
-- It shows the correct pattern, the common mistake, and an empty-page edge case.
BEGIN;
DROP TABLE IF EXISTS posts;
CREATE TEMP TABLE posts (
id INTEGER PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
title TEXT NOT NULL
);
-- Two rows share the same timestamp on purpose.
-- That lets us prove why the unique tie-breaker matters.
INSERT INTO posts (id, created_at, title) VALUES
(101, '2024-01-01 10:00:00', 'Alpha'),
(102, '2024-01-01 10:01:00', 'Bravo'),
(103, '2024-01-01 10:02:00', 'Charlie'),
(104, '2024-01-01 10:02:00', 'Delta'),
(105, '2024-01-01 10:03:00', 'Echo'),
(106, '2024-01-01 10:04:00', 'Foxtrot'),
(107, '2024-01-01 10:05:00', 'Golf');
-- The index should match the ORDER BY used by the query.
CREATE INDEX idx_posts_created_at_id ON posts (created_at, id);
-- Page 1: first 3 rows in stable order.
SELECT id, created_at, title
FROM posts
ORDER BY created_at, id
LIMIT 3;
-- Correct keyset page 2:
-- Use the last row from page 1 as the cursor.
-- Because order is ascending, we ask for rows strictly greater than that tuple.
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) > ('2024-01-01 10:02:00'::timestamp, 103)
ORDER BY created_at, id
LIMIT 3;
-- Common bug: filtering only by created_at skips the row with the same timestamp
-- and larger id. Here, id 104 would be missed.
SELECT id, created_at, title
FROM posts
WHERE created_at > '2024-01-01 10:02:00'::timestamp
ORDER BY created_at, id
LIMIT 3;
-- Edge case: a cursor beyond the end returns no rows, which is correct.
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) > ('2024-01-01 10:09:00'::timestamp, 999)
ORDER BY created_at, id
LIMIT 3;
ROLLBACK;Follow-up & Tricky Questions:
> with ascending order, backward uses < and then you reverse the returned rows in the application.(created_at, id) or the exact composite order you query with. Without it, the database may still scan a lot of rows and lose the speed benefit.Tricky gotcha 1: Is a SQL cursor the same as keyset pagination? No. A SQL cursor is a server-side object with state; keyset pagination is usually stateless and stores the last key in the client request.
Tricky gotcha 2: Can I jump to page 500 instantly? Not with pure keyset. Keyset is great for moving forward or backward from a known row, but not for arbitrary page numbers.
Tricky gotcha 3: Why not sort by name? You can, but names may not be unique and can change. A good pagination key should be stable, ordered, and usually unique when combined with a tie-breaker.
Common Mistakes:
id.Memory Hook: Bookmark, don’t count. OFFSET counts from the start; keyset uses the last row as a bookmark and resumes there.
Cheat Sheet:
ORDER BY and a unique tie-breaker.WHERE key > last_key ORDER BY key LIMIT n.Practice Tasks:
OFFSET/LIMIT query from your app into keyset form.ORDER BY and test the plan.