Every row, exactly once.
Pagination looks like two numbers in a URL. Then rows start to repeat, vanish and arrive mid-page. Learn to page through data that will not sit still, one real bug at a time, in one small record shop.
records when the collector started: 60 (#1 to #60) while they read: 3 new arrivals after pages 1 and 2, 3 sales after page 4 rows read from the feed: 63 shown twice: 6 (#44 to #46, #51 to #53) never shown: 3 (#24 to #26)
A newest-first feed, read by offset while the shop changes. Six records came round twice. Three were never shown. Chapter 04 fixes it with a cursor.
From the first page to the last row
Pagination has one promise: walk the pages to the end and you see every row exactly once, in order, without the server doing more work than the page needs. Each chapter keeps one part of that promise.
- 01Turn a page number into rowsOffsets, limits and the edges: page 0, the last page and the page after it.
- 02Make an order totalWhy ties come back in any order, and the one column that fixes it.
- 03Page with a cursorRemember the last row, not the count, so new and deleted rows cannot shift the pages.
- 04Design the tokenOpaque, versioned cursors that carry the whole question and refuse the wrong one.
- 05Know when to stopShort pages, empty pages, limit plus one, and what a total count really costs.
- 06Sync what changedWhy a timestamp loses updates, and the value that only goes up.
The moving feed
Twelve records, newest first, four to a page. Records arrive at the top and sell while you read. Watch what each way of paging shows you.
- 1. With Offset chosen, play the busy pattern. Two records come round twice, in blue, and two are missed, in red.
- 2. Switch to Cursor and play it again. Every record is seen once.
- 3. That one difference is case 04. Fix it in Python, in chapter 04.
- Green means seen once.
- Blue means seen twice.
- Red means missed.
- Yellow marks where you are.
Offset skips as many rows as you have read.
012 records, newest first. Read them 4 at a time.
- #12not read yet
- #11not read yet
- #10not read yet
- #9not read yet
- #8not read yet
- #7not read yet
- #6not read yet
- #5not read yet
- #4not read yet
- #3not read yet
- #2not read yet
- #1not read yet
- Pages read
- 0
- Rows shown
- 0
- Shown twice
- 0
- Missed
- 0
Pages you got
None yet. Press Next page.
4 records a page. Missed counts records that were there from the start, are still there, and that you have already gone past.
Eight chapters, eight bugs
Each chapter explains one idea in depth, then hands you a program that gets it wrong in exactly that way. You fix it, and the checker walks every page to prove the fix.
- 01Pages and offsetsCase: The first page nobody sawoffsets, page numbers, edges15 min
- 02LimitsCase: The page with every recordlimits, defaults, page size15 min
- 03A stable orderCase: Musical chairstotal order, tie-breakers20 min
- 04CursorsCase: The feed that repeated itselfoffset drift, keyset cursors25 min
- 05Cursors on a sort with tiesCase: The price that hid recordscomposite keys, deep pages20 min
- 06Opaque cursorsCase: The cursor that forgot the questionopaque tokens, versioning20 min
- 07Knowing when to stopCase: The client that gave up earlyshort pages, has more, counts15 min
- 08Syncing changesCase: The update the sync never sawchange feeds, versions, clocks25 min
Run it, fix it, prove it
Every case is a short Python program with one bug in it, and nothing to install beyond uv. Here is case 07 from start to finish.
- 1Run ituv run python -m crate run 7
records for sale: 189 records downloaded: 18 requests made: 1 last reply: 18 records, next = '20'
The server said there was more. The client stopped anyway.
- 2Fix itlab/cases/case07_stop.py
if page.next is None: break # only the server knows where the end is
Stop on the one signal the server documents.
- 3Prove ituv run python -m crate check 7
PASS every record for sale is downloaded, once (189 of 189) PASS it stops when next is None: 12 requests, no more (12) PASS an empty page in the middle does not stop it PASS a single short last page takes one request
Every record, once, and not one request too many.
What an offset costs
The same page, fetched by OFFSET and by cursor, at five depths. And the same feed, read by each while the shop changes between pages.
- 0 rows before the pageOFFSET is 1xOFFSET0.014 mscursor0.014 ms
- 1,000 rows before the pageOFFSET is 2xOFFSET0.030 mscursor0.014 ms
- 10,000 rows before the pageOFFSET is 6xOFFSET0.093 mscursor0.014 ms
- 100,000 rows before the pageOFFSET is 49xOFFSET0.917 mscursor0.019 ms
- 199,980 rows before the pageOFFSET is 174xOFFSET2.151 mscursor0.012 ms
| Between pages | Offset: twice, missed | Cursor: twice, missed | ||
|---|---|---|---|---|
| nothing changes | 0 | 0 | 0 | 0 |
| 1 new record | 52 | 0 | 0 | 0 |
| 5 new records | 330 | 0 | 0 | 0 |
| 1 seen record sold | 0 | 47 | 0 | 0 |
| 3 seen records sold | 0 | 129 | 0 | 0 |
From crate try deep and crate try drift. Chapters 04 and 05 explain every number, and the timings on your machine will differ a little.
Four commands to the first bug
You need git and uv. uv installs Python 3.13 for you, and the lab uses the standard library only. No accounts, no API keys, no Docker.
git clone https://github.com/jars-demo/pagination-demo.git cd pagination-demo/lab uv sync uv run python -m crate run 1
The checklist
Ten rules, one per idea in the workshop. The last chapter ends with the same list.
- 01Count pages from 1 for people and from 0 for code, and convert in one place.
- 02Give every list a default limit and a maximum, and refuse or clamp anything outside them.
- 03End every ORDER BY with a unique column, so the order is total.
- 04Use a cursor, not an offset, for any list that changes while people read it.
- 05Make the cursor the whole sort key, and seek strictly after it.
- 06Keep cursors opaque, and put the whole query inside them: filters, sort and direction.
- 07Return a next cursor, and let only its absence mean the end.
- 08Fetch one row more than the page to know if there is a next one. Count only when someone needs the number.
- 09Sync changes by a version that only goes up and that no two writes share, never by a timestamp.
- 10Test pagination by walking every page while the data changes, and check each row appears exactly once.