VillageSQL is a drop-in replacement for MySQL with extensions.
All examples in this guide work on VillageSQL. Install Now →
LIMIT 20 OFFSET 200000 reads 200,020 rows and throws away 200,000 of them. That is not a bug; it is what OFFSET means. The server has no way to jump to row 200,001 in an arbitrary ordering, so it walks there. Page 1 is fast, page 10,000 is slow, and the slowdown grows linearly with page depth. Keyset pagination fixes this by remembering where the last page ended instead of counting from the top.
OFFSET Pagination: Fine Until It Isn’t
The familiar pattern:- Cost grows with the page number.
OFFSET 200000scans and discards 200,000 index entries before returning anything. - Rows shift between requests. If a row is inserted while a user pages, page boundaries move, and the user sees a duplicate or misses a row.
Keyset Pagination: Remember Where You Stopped
Instead of counting rows, filter on the last value the client saw:WHERE id > 20 clause lets the server seek directly into the index and read exactly 20 rows, no matter how deep the page. Page 10,000 costs the same as page 1. Inserted rows cannot shift page boundaries, because the boundary is a value, not a position.
The trade-off: keyset pagination gives you next and previous, not “jump to page 47”. For infinite scroll and APIs, that is usually the interface you wanted anyway.
Sorting by Something Other Than the Key
Real listings rarely sort byid. Sort by a non-unique column and you need a tiebreaker, or rows with equal values are skipped or repeated across pages. The client sends back both values from the last row it saw, and the query compares on both:
WHERE (created_at, id) > ('2026-08-01 12:00:00', 143). It returns the same rows, but MySQL’s optimizer does not convert it into an index range seek: EXPLAIN shows a full index scan (type: index) for the row constructor and a seek (type: range) for the expanded form above. Use the expanded form.
Either way, the index must match the ORDER BY:
EXPLAIN: you want type: range on that index and no filesort. See Reading EXPLAIN for what to look for.
Do You Need a Total Count?
SELECT COUNT(*) over a large filtered set can cost more than the page query itself. Interfaces built on keyset pagination usually drop exact counts in favor of a “load more” affordance. If the product genuinely needs a count, cache it or maintain it in a summary table rather than counting per request.
Which One to Use
See also
- MySQL indexes — the index design keyset pagination depends on
- Reading EXPLAIN — confirming the seek instead of a scan
- Primary key strategies — sortable keys (like UUIDv7) make good cursors

