Hook: Pagination looks harmless until the table gets big; then the database has to “skip a mountain to read a few rows.” Interviewers love this because it tests indexing, ordering, and real-world performance all at once.
Question: How do you optimize pagination in SQL so it stays fast and correct on large tables?
Answer: The main trick is to avoid large OFFSET values on big tables. OFFSET/LIMIT is easy to write, but the database still reads and discards the skipped rows, so deep pages get slower and slower. A faster pattern is keyset pagination (also called seek pagination), where you use the last row from the previous page as a cursor and ask for “rows after this point” using the same sort columns plus a unique tie-breaker.
Interview-Ready Answer: I optimize pagination by using a stable ORDER BY and a composite index on the sort columns, then prefer keyset pagination over large OFFSETs. With keyset pagination, I pass the last row’s sort values as a cursor, so the database can seek directly to the next page instead of scanning and discarding thousands of rows. I still use OFFSET for small tables or rare admin screens, but for high-traffic feeds and catalogs, keyset pagination is usually the better choice because it keeps page latency roughly constant.
Pagination is not just about showing fewer rows. The database still has to find the correct slice of the sorted result set. If the sort order is not indexed well, it may sort a large set first; if you use a large OFFSET, it may walk past many rows before returning anything useful.
| Approach | How it works | Deep-page speed | Best for | Main gotcha |
|---|---|---|---|---|
| OFFSET/LIMIT | Skip N rows, return next M | Slower as N grows | Small tables, page numbers | Reads and discards skipped rows |
| Keyset | Start after last seen key | Usually steady | Feeds, timelines, catalogs | Needs a stable sort key |
ORDER BY created_at DESC, id DESC.LIMIT rows in that order.(created_at, id).< that cursor in the same order, again with LIMIT.That is why keyset pagination usually feels fast even when the table grows. The amount of work stays close to the page size, not the page number.
For fast pagination, the ORDER BY and the index should agree. A good default pattern is an index that starts with the sort columns and ends with a unique tie-breaker such as id. The tie-breaker matters because many rows can share the same timestamp or score. Without it, the order is not fully deterministic, and rows can jump between pages.
For example, if you sort by created_at DESC only, two rows with the same timestamp may appear on different pages in one request and then swap order on the next request. Adding id DESC makes the order stable.
COUNT(*) on a huge table can also be expensive. Many systems cache counts or show an approximate number instead.OFFSET 100000 LIMIT 20 can force the database to walk past 100,000 rows just to return 20. That is why deep pages get slower.O(limit) after the index seek, while OFFSET behaves like O(offset + limit).O(n log n) work.id.updated_at, rows can move between pages as they change.Memory model: Offset pagination is “start from the front and count.” Keyset pagination is “open the book to the bookmark.” That is the mental picture to remember in an interview.
Real-World Example: Imagine an e-commerce product catalog with millions of items. The home page shows the newest products first, and users keep scrolling. At first, LIMIT 20 OFFSET 0 works fine. But once users reach deeper pages, the API starts taking seconds because each request makes the database skip more and more rows.
A production fix is to sort by created_at DESC, id DESC, add the matching index, and return a cursor with the last item on each page. The app then asks for “the next 20 products after this cursor,” which keeps response times stable even as the catalog grows.
What goes wrong: A common outage is a feed endpoint using OFFSET 50000 during a traffic spike. The database CPU climbs, slow query logs fill with the same pagination query, and the app begins timing out with 504 errors. Users see spinning loaders, missing items, or repeated products because the order is not stable or the offset shifts when new rows are inserted.
-- PostgreSQL demo: compare naive offset-style paging with correct keyset pagination.
-- The key lesson is that the sort order must be stable, and the cursor must include a unique tie-breaker.
DROP TABLE IF EXISTS feed_items;
CREATE TABLE feed_items (
id BIGINT PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
title TEXT NOT NULL
);
INSERT INTO feed_items (id, created_at, title) VALUES
(1, TIMESTAMP '2024-01-05 10:00:00', 'Alpha'),
(2, TIMESTAMP '2024-01-04 10:00:00', 'Beta'),
(3, TIMESTAMP '2024-01-03 10:00:00', 'Gamma'),
(4, TIMESTAMP '2024-01-02 10:00:00', 'Delta'),
(5, TIMESTAMP '2024-01-02 10:00:00', 'Epsilon'),
(6, TIMESTAMP '2024-01-02 10:00:00', 'Zeta'),
(7, TIMESTAMP '2024-01-01 10:00:00', 'Eta');
-- This index matches the paging order.
-- In a real app, this is what lets the database seek instead of scanning.
CREATE INDEX feed_items_created_at_id_desc_idx
ON feed_items (created_at DESC, id DESC);
-- Page 1: newest items first.
SELECT id, created_at, title
FROM feed_items
ORDER BY created_at DESC, id DESC
LIMIT 4;
-- Naive keyset using only created_at is WRONG when several rows share the same timestamp.
-- Here, rows with created_at = '2024-01-02 10:00:00' can be skipped.
SELECT id, created_at, title
FROM feed_items
WHERE created_at < TIMESTAMP '2024-01-02 10:00:00'
ORDER BY created_at DESC, id DESC
LIMIT 4;
-- Correct keyset pagination uses the full cursor: (created_at, id).
-- This returns the remaining rows in a stable, gap-free order.
SELECT id, created_at, title
FROM feed_items
WHERE (created_at, id) < (TIMESTAMP '2024-01-02 10:00:00', 6)
ORDER BY created_at DESC, id DESC
LIMIT 4;
-- If you need to page backward, reverse the comparison and order.
SELECT id, created_at, title
FROM feed_items
WHERE (created_at, id) > (TIMESTAMP '2024-01-02 10:00:00', 6)
ORDER BY created_at ASC, id ASC
LIMIT 4;Follow-up & Tricky Questions:
(created_at DESC, id DESC). That gives the optimizer a path that matches the order you ask for.COUNT(*), cache the count, or show an approximate total. On very large tables, counting everything on every request can be as expensive as the page query itself.Common Mistakes:
id so the order is deterministic.ORDER BY and cursor comparison.Memory Hook: OFFSET is a librarian counting from the front of the shelf; keyset is a bookmark that opens right where you left off.
Cheat Sheet:
OFFSET/LIMIT is simple but gets slower as the offset grows.COUNT(*) carefully; it can be expensive on huge tables.Practice Tasks:
OFFSET 1000 into keyset pagination using a cursor.