Offset versus keyset pagination, under writes
Measure your machineNeeds Python
Problem
OFFSET pagination asks for "rows 1,000 to 1,100 of this order", so the database must produce and discard every earlier row, and the cost grows with the page number. It also identifies a page by position, so a row inserted ahead of the reader shifts every later row by one, and the next page repeats a row. Keyset pagination asks for "the next 100 rows after the last one I saw", which the index answers directly and which is unaffected by inserts ahead.
Use SQLite with 1,000,000 rows. Page through the table with OFFSET, and after page 1, insert one new row at the top. Count duplicates and skipped rows, then repeat with keyset pagination. Also time page 10,000.
The question. How much slower is OFFSET at page 10,000, and how many duplicates does one insert cause in each method?
Predict
The OFFSET page far into the table is much slower than the keyset page, and the duplicate test prints one repeated id. Keyset gives no repeats.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
offset-versus-keyset-pagination-under-writes.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """OFFSET against keyset pagination, under writes. 1,000,000 rows in SQLite. The time to fetch page 10,000 with OFFSET and with a keyset condition; then page 1, a new row inserted at the top, page 2: the duplicates each method returns. """ import argparse, json, sqlite3, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rows", type=int, default=1_000_000, help="Rows.") p.add_argument("--page", type=int, default=100, help="Rows per page.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() db = sqlite3.connect(":memory:") db.execute("CREATE TABLE c(id INTEGER PRIMARY KEY, name TEXT)") db.executemany("INSERT INTO c(name) VALUES (?)", ((f"n{i}",) for i in range(args.rows))) last = args.rows - args.page def timed(sql): t = time.perf_counter() for _ in range(20): db.execute(sql).fetchall() return (time.perf_counter() - t) / 20 * 1000 t_off = timed(f"SELECT id FROM c ORDER BY id DESC LIMIT {args.page} OFFSET {last}") t_key = timed(f"SELECT id FROM c WHERE id < {args.page + 1} ORDER BY id DESC LIMIT {args.page}") p1 = [r[0] for r in db.execute(f"SELECT id FROM c ORDER BY id DESC LIMIT {args.page} OFFSET 0")] db.execute("INSERT INTO c(name) VALUES ('new')") p2 = [r[0] for r in db.execute(f"SELECT id FROM c ORDER BY id DESC LIMIT {args.page} OFFSET {args.page}")] k2 = [r[0] for r in db.execute(f"SELECT id FROM c WHERE id < {p1[-1]} ORDER BY id DESC LIMIT {args.page}")] rows = [{"method": "OFFSET", "page_10000_ms": t_off, "duplicates": len(set(p1) & set(p2))}, {"method": "keyset (WHERE id < last seen)", "page_10000_ms": t_key, "duplicates": len(set(p1) & set(k2))}] for r in rows: print(f"{r['method']:<30} page 10,000 in {r['page_10000_ms']:7.3f} ms duplicates after one insert: {r['duplicates']}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"methods": rows, "summary": [{"speedup": t_off / t_key}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run offset-versus-keyset-pagination-under-writes.py uv run offset-versus-keyset-pagination-under-writes.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rows1,000,000 | Rows. |
| --page100 | Rows per page. |
Our run
Two ways to page
| Method | Page 10,000 (ms) | Duplicates after one insert |
|---|---|---|
| OFFSET | 4.48 | 1 |
| keyset (WHERE id < last seen) | 0.024 | 0 |
OFFSET page 10,000 in 4.479 ms duplicates after one insert: 1
keyset (WHERE id < last seen) page 10,000 in 0.023 ms duplicates after one insert: 0What we found
Page 10,000 took 4.48 ms with OFFSET and 0.024 ms with the keyset predicate (191×). One insert after page 1 made OFFSET repeat 1 row; keyset repeated 0.
Read it
A page identified by position is only stable if nothing moves. For an API over changing data, the cursor should name the last item seen (an opaque token holding its sort key), which makes every page cost the same and makes concurrent writes harmless to the reader.