1. Home
  2. Blog
  3. Databases & SQL

Databases & SQL

SQL Pagination Explained: OFFSET vs Keyset, With a Million-Row Test

Showing results one page at a time looks simple with LIMIT and OFFSET. On large tables, deep pages get slow and rows can repeat or vanish. We measured both approaches on a million rows to show why keyset pagination exists.

10 min read
SQL pagination explained: OFFSET vs keyset, with a million-row test

Key takeaways

  • OFFSET pagination skips rows, so deeper pages do more work. In our test, the last page took about 38 ms against 0.03 ms for the first.
  • Keyset pagination continues after the last row seen, and stayed at about 0.03 ms even on the deepest page.
  • Always sort by a unique key, such as (created_at, id), or pages can overlap.
  • OFFSET suits small tables and numbered pages; keyset suits large tables, feeds and APIs.

An admin screen lists orders twenty at a time. It is fast on page 1. Someone clicks “last page” on a table with a million rows, and the database grinds. Meanwhile, a mobile feed shows the same post twice because new posts arrived between scrolls. Both problems come from the most common way to paginate: LIMIT and OFFSET.

We built a SQLite 3.50 table with one million posts and an index on (created_at, id), then timed both techniques, averaging repeated runs. All numbers below are from that test on one machine; absolute times on your database will differ, but the pattern is the same.

What is pagination?

Pagination splits a large result into smaller pages, so an app loads twenty rows instead of a million. It keeps responses fast, reduces memory use and network traffic, and gives users something they can actually read. Every list screen, search result and API collection needs it.

LIMIT and OFFSET

-- page 3, 20 rows per page
SELECT id, created_at, title
FROM posts
ORDER BY created_at, id
LIMIT 20 OFFSET 40;

The offset is (page - 1) * page_size. It is easy to understand, supports jumping straight to any page number, and every database supports it. SQL Server and Oracle use the standard form OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY, which means the same thing.

Always order by something unique

Without ORDER BY, SQL does not promise any order, so page 2 might repeat rows from page 1. Ordering by a column that is not unique, such as created_at alone, has a quieter version of the same problem: rows that share a timestamp can swap places between queries. Add a unique tie-breaker, usually the primary key, so the order is fully determined. Our sample data happened to have no shared timestamps, but real tables almost always do. This is the same principle behind the ordering rules in window functions.

Why deep pages get slow

OFFSET does not jump to a position. The database must produce and throw away every skipped row. Page 50,000 means reading 999,980 rows to return 20. The execution plan showed this clearly:

OFFSET: SCAN posts USING COVERING INDEX ix_created
KEYSET: SEARCH posts USING COVERING INDEX ix_created (created_at>?)

“SCAN” means walking through the index from the start; “SEARCH” means jumping directly to a position. Reading the plan, as described in our query optimization guide, tells you which one you have.

Keyset pagination

Keyset pagination, also called seek or cursor pagination, remembers where the previous page ended and asks for rows after that point:

-- first page
SELECT id, created_at, title FROM posts
ORDER BY created_at, id
LIMIT 20;

-- next page: pass the last row's (created_at, id)
SELECT id, created_at, title FROM posts
WHERE (created_at, id) > (?, ?)
ORDER BY created_at, id
LIMIT 20;

The row-value comparison (created_at, id) > (?, ?) means “later timestamp, or same timestamp with a higher ID”. PostgreSQL, MySQL 8, and SQLite support it. Where it is not available, write it out: created_at > ? OR (created_at = ? AND id > ?). With an index on the same columns in the same order, the database jumps straight to the right spot, no matter how deep the page.

Our million-row results

First, selecting only indexed columns, so the index alone could answer the query:

Page Offset OFFSET time
1 0 0.02 ms
100 1,980 0.02 ms
10,000 199,980 1.90 ms
49,999 999,960 9.69 ms
49,999 (keyset) n/a 0.02 ms

Then with SELECT *, including a 200-character body column that is not in the index, which is closer to a real list screen:

Query Time
OFFSET 0 0.03 ms
OFFSET 199,980 8.14 ms
OFFSET 999,960 38.00 ms
Keyset, deepest page 0.03 ms

OFFSET time grew roughly in proportion to the offset, while keyset stayed flat. Both methods returned the same first row, ID 999,961, for the deepest page, so the results were identical; only the cost differed. Thirty-eight milliseconds sounds small, but it is over a thousand times slower than the first page, it repeats for every request, and on bigger tables and busier servers it grows into seconds.

Drift: duplicates and gaps

OFFSET has a correctness problem too. We read page 1 with three rows, inserted a new row that sorts first, then read page 2:

page 1:                  [1, 2, 3]
-- a new row is inserted at the top
page 2 (OFFSET 3):       [3, 4, 5]

Row 3 appeared twice, because everything shifted down by one. Deleting a row does the opposite and silently skips one. In feeds and exports, this means duplicate items or missed records. Keyset pagination does not have this problem: page 2 asks for rows after row 3, so new rows earlier in the order cannot shift it.

Tip: Sort keyset pages by columns that never change, such as creation time and ID. If you sort by something that changes, like updated_at or a score, rows can jump between pages while a user is reading.

Trade-offs

OFFSET Keyset
Deep page speed Slows down with depth Constant
Jump to page 37 Yes No
Stable while data changes No Yes
Implementation Trivial Needs a cursor and matching index
Sorting by any column Easy Needs a unique, indexed sort key
Previous page Easy Reverse the comparison and order

A middle ground for OFFSET is the deferred join: first page through IDs using a covering index, then fetch full rows only for those IDs. It reduces the cost of skipped rows, as our first table showed, but does not remove it.

Designing a paginated API

Most large public APIs use cursor-based pagination. A typical response looks like this:

{
  "data": [ ... 20 items ... ],
  "next_cursor": "eyJjIjoxNzAwMDYwMDAwLCJpIjo5OTk5ODB9",
  "has_more": true
}

The cursor is the last row’s sort values, encoded, often as Base64 JSON, so clients treat it as an opaque token. Clients pass it back as ?cursor=... to get the next page. Keep a maximum page size, validate cursors, and consider signing them so clients cannot craft their own. Our REST API best practices cover more of the response design.

Total counts

“Page 3 of 50,000” needs a total count, and SELECT COUNT(*) on a large, filtered table can cost more than the page itself. Options include showing “more results” instead of an exact total, caching counts, or using an estimate from database statistics. Infinite-scroll interfaces usually skip totals entirely.

Testing pagination

  • Request every page and check that the union equals the full result, with no duplicates.
  • Test the last page, an empty table, and a page size larger than the table.
  • Insert and delete rows between page requests, and check what clients see.
  • Use rows that share the same sort value to prove the tie-breaker works.
  • Time a deep page on production-sized data, not just page 1.

Boundaries such as the last page and empty results are classic places for bugs, as our guide to boundary value analysis explains.

Which to use

  • Small tables or admin screens with page numbers: OFFSET is fine.
  • Large tables, feeds, infinite scroll, exports and public APIs: keyset.
  • Both needs: keyset for “next” and “previous”, with limited page-number jumps or search to narrow results.

Final thoughts

LIMIT and OFFSET are perfect until tables grow and data changes underneath the user. Keyset pagination costs a little more design, a unique sort key, a matching index and a cursor, but it stays fast at any depth and never shows the same row twice. Measure a deep page on realistic data before deciding which one your feature needs.

Frequently asked questions

What is keyset pagination?

It fetches the next page by asking for rows after the last row seen, using WHERE on the sort columns, instead of skipping rows with OFFSET. It is also called seek or cursor pagination.

Why is OFFSET slow on large tables?

The database must read and discard every skipped row. In our million-row test, the deepest page took about 38 ms with OFFSET and 0.03 ms with keyset.

Why do I see duplicate rows across pages?

Either the ORDER BY is not unique, or rows were inserted or deleted between requests while using OFFSET. Add a unique tie-breaker and consider keyset pagination.

Can keyset pagination jump to a page number?

Not directly. It moves forward or backward from a known row. Use OFFSET where numbered page jumps are essential.

What index does keyset pagination need?

An index on the sort columns in the same order, such as (created_at, id), so the database can jump directly to the starting row.

Keep learning

Work with Stack Quora

Need help with testing, WordPress or SEO?

We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.

Keep reading

More guides on testing, AI & modern development

Enjoyed this article? Pick a topic and jump straight into our newest tutorials.

Artificial Intelligence

LLM Structured Output Explained: Getting Reliable JSON From AI Models

LLM structured output explained with validation we ran in Pydantic: JSON mode and schemas, constrained decoding, the ways model output…

Read article →
DevOps

SSH Keys Explained: How They Work, ssh-keygen, Ed25519 and Safe Habits

SSH keys explained with keys we generated: public vs private keys, ssh-keygen, Ed25519 vs RSA, fingerprints, passphrases, ssh-agent, authorized_keys, config…

Read article →
DevOps

HTTP Caching Explained: Cache-Control, ETag and 304 Responses, With a Live Test

HTTP caching explained with a small server we ran: Cache-Control directives, max-age, no-cache vs no-store, ETag revalidation and 304 responses,…

Read article →
Test Automation

API Contract Testing Explained: Catch Breaking Changes Before They Ship

API contract testing explained with a schema check we ran: what a contract is, consumer-driven contracts, Pact, OpenAPI, which changes…

Read article →
Software Testing

Risk-Based Testing Explained: Score Risks, Build a Matrix and Test What Matters

Risk-based testing explained step by step: identifying risks, scoring likelihood and impact, building a risk matrix, mapping risk to test…

Read article →
Databases & SQL

SQL Constraints Explained: NOT NULL, UNIQUE, CHECK and FOREIGN KEY, Tested

SQL constraints explained with tests we ran: NOT NULL, UNIQUE, CHECK, DEFAULT, PRIMARY and FOREIGN KEY, the NULL loopholes, SQLite's…

Read article →
Databases & SQL

SQL Date Functions Explained: Adding, Comparing and Grouping Dates Without Bugs

SQL date functions explained with queries we ran: adding intervals, month-end surprises, date differences, the BETWEEN trap, grouping by day,…

Read article →
Software Development

Python asyncio Explained: async, await, gather and TaskGroup With Real Timings

Python asyncio explained with code we timed: coroutines, await, gather, TaskGroup, error handling, timeouts, limiting concurrency and the blocking-call mistake…

Read article →
Software Development

Debounce vs Throttle in JavaScript: How They Work, When to Use Each, Tested

Debounce vs throttle in JavaScript explained with timings we measured in Node.js: how each works, simple implementations, leading and trailing…

Read article →
Software Development

Python Context Managers Explained: The with Statement, contextlib and Real Uses

Python context managers explained with code we ran: how with works, __enter__ and __exit__, contextlib.contextmanager, suppress, ExitStack, database transactions and…

Read article →
Artificial Intelligence

LLM Temperature and Top-p Explained: How AI Models Choose Their Next Word

LLM temperature and top-p explained with a simulation we ran: how probabilities are reshaped, what top-p keeps, recommended settings by…

Read article →
DevOps

YAML Explained: Syntax, the Norway Problem and Other Traps We Tested

YAML explained with a parser test: basic syntax, lists and maps, the Norway problem, versions turning into floats, octal zip…

Read article →
DevOps

Cron Jobs Explained: Syntax, Real Schedules and the Mistakes That Break Them

Cron jobs explained with schedules we verified: the five fields, operators, real examples, the day-of-month OR day-of-week trap, environment problems,…

Read article →
Software Testing

Bug Life Cycle in Software Testing: Every Status, Who Owns It and Common Mistakes

The bug life cycle explained: every defect status from New to Closed, who owns each step, rejected, deferred and reopened…

Read article →
Test Automation

Property-Based Testing in Python: Hypothesis Finds the Bugs Your Examples Miss

Property-based testing explained with Hypothesis tests we ran: properties vs examples, strategies, shrinking, the real bugs it found in our…

Read article →
Databases & SQL

SQL Pagination Explained: OFFSET vs Keyset, With a Million-Row Test

SQL pagination explained with a million-row test we ran: LIMIT and OFFSET, why deep pages get slow, keyset (cursor) pagination,…

Read article →
Databases & SQL

SQL EXISTS vs IN: Differences, the NOT IN NULL Trap and Performance

SQL EXISTS vs IN explained with queries we ran: how each works, why NOT IN returns no rows when a…

Read article →
Software Development

Python Logging Explained: Levels, Handlers, Formatters and Mistakes to Avoid

Python logging explained with code we ran: levels, basicConfig quirks, named loggers, handlers and formatters, logger.exception, lazy formatting, duplicate logs…

Read article →
Software Development

JavaScript Map vs Object: Key Differences, Pitfalls and a Benchmark

JavaScript Map vs Object explained with code we ran in Node.js: key types, ordering, the prototype trap, JSON, size, WeakMap…

Read article →
Software Development

Python Type Hints Explained: Annotations, mypy and the Errors It Catches

Python type hints explained with a real mypy run: basic annotations, Optional and unions, TypedDict, Literal, Protocol, generics, Callable, strict…

Read article →
Artificial Intelligence

LLM Tokens Explained: How AI Models Read Text, Count Costs and Fill Context Windows

LLM tokens explained with a tokenizer we ran: how text is split, why counts differ by language and tokenizer, context…

Read article →
DevOps

Semantic Versioning Explained: MAJOR.MINOR.PATCH, Ranges and Release Rules

Semantic versioning explained with code we ran: MAJOR.MINOR.PATCH rules, pre-releases, correct version sorting, caret and tilde ranges, 0.x versions, lockfiles…

Read article →
DevOps

Git Reset vs Revert: How to Undo Commits Safely, With a Tested Example

Git reset vs revert explained with a repository we built: soft, mixed and hard reset, revert commits, reverting merges, recovering…

Read article →
Software Testing

The Test Pyramid Explained: Layers, Ratios and When to Break the Shape

The test pyramid explained: unit, integration and end-to-end layers, sensible ratios, the ice cream cone anti-pattern, the testing trophy and…

Read article →
Test Automation

pytest Fixtures Explained: Scope, yield Teardown, conftest.py and Factories

pytest fixtures explained with a suite we ran: how fixtures work, scopes, yield teardown, conftest.py, factory fixtures, parametrized fixtures, built-ins…

Read article →
Databases & SQL

SQL Upsert Explained: INSERT ON CONFLICT, MERGE and the REPLACE Trap

SQL upsert explained with queries we ran: INSERT ON CONFLICT DO UPDATE and DO NOTHING, the excluded row, conditional updates,…

Read article →
Databases & SQL

SQL LIKE Operator Explained: Wildcards, Case Sensitivity and Performance

The SQL LIKE operator explained with queries we ran: % and _ wildcards, escaping, NOT LIKE and NULL, case sensitivity…

Read article →
Artificial Intelligence

AI Hallucinations Explained: Why Language Models Make Things Up and How to Reduce It

AI hallucinations explained: what they are, why language models produce them, common types, how to reduce them with grounding and…

Read article →
Test Automation

Mocking in Python: unittest.mock, patch and the Mistakes That Make Tests Lie

Mocking in Python with unittest.mock, tested: Mock and return_value, side_effect, patching where a name is used, spec and autospec, mocking…

Read article →
Software Testing

Code Coverage Explained: Line vs Branch Coverage and What 100% Really Means

Code coverage explained with a real coverage.py run: line, branch and condition coverage, reading a report, why 100% coverage can…

Read article →
Artificial Intelligence

What Are AI Agents? How They Work, With a Simple Agent Loop You Can Run

What are AI agents? A plain guide to the agent loop, tools, memory and guardrails, with a small Python loop…

Read article →
Test Automation

TDD vs BDD: Key Differences, One Feature Built Both Ways and When to Use Each

TDD vs BDD explained with one discount feature built both ways: red-green-refactor with code we ran, Given-When-Then scenarios, tools, trade-offs…

Read article →
Software Testing

User Acceptance Testing (UAT): Process, Checklist and Real Examples

User acceptance testing explained: who runs it, how it differs from QA, a seven-step process, a UAT test case template,…

Read article →
Artificial Intelligence

What Are Embeddings? Vectors and Cosine Similarity Explained Simply

What are embeddings? A plain-language guide to vectors, cosine similarity and semantic search, with a small example we ran, common…

Read article →
Test Automation

Flaky Tests: What Causes Them and How to Fix Them for Good

Flaky tests explained with demos we ran: the main causes, how to confirm and diagnose one, proper fixes for timing,…

Read article →
Software Testing

Load Testing vs Stress Testing: Differences, Metrics and k6 Examples

Load testing vs stress testing explained: what each proves, spike and soak tests, the metrics that matter, why averages mislead,…

Read article →