Skip to content
About
Main content

Offset versus keyset pagination, under writes

Measure your machineNeeds Python

Problem

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. 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 at page 10,000, and how many duplicates does one insert cause in each method?


Predict

At page 10,000, how many times faster is the keyset page than the OFFSET page?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    offset-versus-keyset-pagination-under-writes.py40 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--rows1,000,000 Rows.
--page100 Rows per page.

Our run

Two ways to page

Two ways to page
MethodPage 10,000 (ms)Duplicates after one insert
OFFSET4.481
keyset (WHERE id < last seen)0.0240
Reference runApple M2 · 8 cores · 8 GB
Output · uv run offset-versus-keyset-pagination-under-writes.py
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: 0
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What 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 should name the last item seen (an holding its sort key), which makes every page cost the same and makes concurrent writes harmless to the reader.