Why does MYSQL higher LIMIT offset slow the query down?
Master System Design with Codemia
Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.
Introduction
MySQL's LIMIT offset, count gets slower as the offset increases because MySQL must read and discard all rows before the offset. A query with LIMIT 1000000, 10 reads 1,000,010 rows, throws away the first million, and returns only 10. This full-scan behavior happens even with indexes. The fix is to avoid large offsets entirely by using keyset pagination (WHERE id > last_seen_id), deferred joins, or covering indexes.
How LIMIT OFFSET Works Internally
MySQL executes this as:
- Find all matching rows using the index on
created_at(or full table scan if no index) - Sort them if needed
- Scan through 100,020 rows
- Discard the first 100,000 rows
- Return rows 100,001 through 100,020
The database cannot "jump" to row 100,000 — it must count through each row sequentially. At offset 0 this is instant. At offset 1,000,000 it becomes painfully slow.
Demonstrating the Slowdown
The time increases linearly with the offset because MySQL reads offset + count rows for every query.
Why Indexes Do Not Help
Even with a perfect index, MySQL still traverses index entries one by one:
With SELECT *, MySQL also performs a lookup from the index back to the table data for each of those 500,000 rows — even the ones it discards.
Fix 1: Keyset Pagination (Recommended)
Instead of using an offset, remember where you left off and filter with WHERE:
This is O(1) regardless of how deep you paginate because MySQL seeks directly to id = 12345 in the index and reads 20 rows forward. No rows are discarded.
Fix 2: Deferred Join
Fetch only the IDs using the index, then join to get the full rows:
The subquery uses a covering index scan (index-only, no table lookups) for the 500,000 skipped rows. Only the 20 matching IDs trigger a table lookup.
Fix 3: Covering Index
If your query only needs indexed columns, MySQL never touches the table:
Fix 4: Pre-Computed Page Boundaries
Store the boundary values for each page:
This works well for static or slowly-changing datasets.
Comparison
| Approach | Time at Offset 1M | Supports "Jump to Page"? | Complexity |
LIMIT offset, count | ~2s | Yes | Simple |
| Keyset pagination | ~0.001s | No (sequential only) | Low |
| Deferred join | ~0.3s | Yes | Medium |
| Covering index | ~0.5s | Yes | Low |
| Pre-computed pages | ~0.001s | Yes | High |
Common Pitfalls
- Using OFFSET for infinite scroll: Infinite scroll is sequential — use keyset pagination. Each "load more" passes the last seen ID, making every page equally fast.
- Keyset pagination with non-unique columns:
WHERE created_at < ?skips rows with the same timestamp. Add a tiebreaker:WHERE (created_at, id) < (?, ?). - COUNT(*) for total pages:
SELECT COUNT(*) FROM postson large tables is slow in InnoDB. Cache the count or use an estimate:SELECT TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_NAME = 'posts'. - ORDER BY without index: Without an index matching the ORDER BY, MySQL must filesort the entire result set before applying LIMIT — slow at any offset.
- ORM default pagination: Most ORMs (Django, Rails, Laravel) use LIMIT/OFFSET by default. Switch to keyset pagination for large datasets.
Summary
LIMIT offset, countreads offset + count rows and discards the first offset — linear time growth- Keyset pagination (
WHERE id > last_id LIMIT count) is O(1) and the best solution for sequential access - Deferred join fetches IDs via index-only scan, then joins for full rows — good for random page access
- Covering indexes help by avoiding table lookups for discarded rows
- Avoid large OFFSET values in production — they are a performance trap that gets worse as data grows

