Skip to content
Chapter 38Lesson 6

Cursor pagination

The senior default for paging Drizzle lists that grow or change under the user, seeking past the last row seen instead of counting offsets from the front.

You can already page through a list: orderBy, then limit(20).offset(40) for page three. When you learned that in the first lesson of this chapter, two ideas were left for later. Offset, you were told, is “the simple default with a ceiling,” and the habit of pairing a sort column with the primary key as a tiebreaker would “matter later in this chapter.” Both come due in this lesson, and they turn out to be two halves of the same idea.

Think about the kind of screen that forces the issue: an invoice list that grows month after month, or an activity feed that’s being written to while someone scrolls it. Offset pagination works fine for those right up until it doesn’t, and when it breaks, it breaks in two ways that a small admin table never reveals. So before shipping any list endpoint, ask the question an experienced engineer asks. When a list could grow large, or change under the user as they page through it, how do you serve it page by page while avoiding two problems: getting slow the deeper someone pages, and showing the same row twice or skipping one entirely?

The answer is cursor pagination , the default choice for any list that grows or moves. Offset doesn’t disappear; it stays the right call for small, stable tables, but it stops being the default. By the end of this lesson you’ll be able to do four things: pick the right one on real criteria, write the cursor query with its tiebreaker baked in, pack the cursor into a URL-safe token and validate it on the way back, and tell the UI “there’s another page” without a second round-trip. We’ll start by making offset’s two limits concrete, because the cursor is the fix for both.

Here is offset in one sentence: you sort the rows, skip the first N, and take the next page’s worth.

const pageThree = await db
.select()
.from(invoices)
.where(eq(invoices.organizationId, orgId))
.orderBy(desc(invoices.createdAt), desc(invoices.id))
.limit(20)
.offset(40);

Skip forty rows, take twenty: page three. It reads the way you’d describe it out loud, and for a list of a few hundred rows that nobody pages deep into, it really is the right tool. Nothing in the rest of this lesson should talk you out of that.

Two failures hide behind that clean syntax, though, and neither shows up on your laptop with ten seeded rows. You need to see both to understand why the cursor exists.

Failure one: it gets slow the deeper you page

Section titled “Failure one: it gets slow the deeper you page”

It’s easy to assume OFFSET 10000 is cheap, on the grounds that you’re not asking for those ten thousand rows, only the twenty after them. The database doesn’t get to skip them, though. To return rows 10,001 through 10,020 in sorted order, Postgres has to produce the first 10,000 in that order too, walk past every one of them, and throw them away. The skipped rows aren’t free; they’re computed and then discarded. So the cost of a page grows with how deep into the list it sits. Page one is instant; page five hundred drags, because page five hundred is really “build and discard ten thousand rows, then hand me twenty.”

A cursor doesn’t count from the front, so it never pays this cost. That contrast is the whole reason it’s faster, and the diagram below makes it concrete.

Offset
scanned, then discarded
the page you wanted
Cursor
seek straight to the boundary
Offset walks and discards every row before the page; a cursor seeks straight to the boundary. The deeper the page sits, the more offset builds and throws away — the cursor's cost stays flat.

Failure two: it shows the wrong rows on live data

Section titled “Failure two: it shows the wrong rows on live data”

This failure has nothing to do with size. It hits a small list too, the moment that list is changing while someone reads it.

Offset addresses rows by their position in the current ordering: “give me rows 41 through 60.” But position is relative to whatever is in the table right now. Suppose a user loads page one, the twenty newest invoices, reads it, and clicks “next.” Between those two clicks, a new invoice gets created and lands at the top of the list, since the list is newest first. Now every row has shifted down one position, so the invoice that was row 20 is now row 21. When page two asks for “rows 21 through 40,” row 21 is a row the user already saw at the bottom of page one. They see it twice. A delete above their position does the mirror-image damage: every row shifts up, and one row gets skipped entirely, falling into the gap between the two pages.

Step through it below and watch the window land on a row it already showed.

  1. page 1 — returned
    1
    #105
  2. 2
    #104
  3. 3
    #103
  4. 4
    #102
  5. 5
    #101
  6. 6
    #100
Page 1 loads: the three newest invoices, #105 #104 #103. The user reads them.
  1. 1
    #106 new
  2. 2
    #105
  3. 3
    #104
  4. 4
    #103
  5. 5
    #102
  6. 6
    #101
  7. 7
    #100
Between clicks, a new invoice #106 is created. Newest-first, it lands at the very top — and pushes every row below it down one position.
  1. skipped (offset 3)
    1
    #106
  2. 2
    #105
  3. 3
    #104
  4. page 2 will return these
    4
    #103
  5. 5
    #102
  6. 6
    #101
  7. 7
    #100
The user clicks ‘next’. Page 2 asks for rows 4–6 by position: offset 3, limit 3. Position 4 is now #103.
  1. 1
    #106
  2. 2
    #105
  3. 3
    #104
  4. page 2's window
    4
    #103 also page 1
  5. 5
    #102
  6. 6
    #101
  7. 7
    #100
But #103 was the last row of page 1. The shift pushed it from position 3 into position 4 — squarely inside page 2's window.
Page 1
#105
#104
#103 duplicate
Page 2
#103 duplicate
#102
#101
Result: the user sees #103 twice — once at the bottom of page 1, once at the top of page 2. (A delete above them would skip a row instead.)

Notice what went wrong: offset asked for rows by position, and position is a moving target the instant anyone writes to the table. The fix is to stop addressing rows by where they sit and start addressing them relative to the last row the user actually saw. A new invoice at the top can’t change which row that was. That anchor is a cursor , the single idea the rest of the lesson builds on. Every property of cursor pagination follows from one sentence:

A cursor remembers the last row you saw and asks for the rows after it; offset counts from the front and re-counts every time.

The deep-page slowness is a nuisance; the duplicated row is a real bug. The cursor fixes both with the same move.

Before building the cursor, draw the line the other way, because there’s an opposite mistake: over-correcting and reaching for a cursor on every list. Offset isn’t broken; it just fits a narrower set of cases than people assume. It’s the right tool, sometimes the better one, when all three of these roughly hold:

  • The list is small and bounded. An admin table of a few hundred feature flags that won’t grow without limit. You never page deep enough for the re-scan to cost anything.
  • The data is read-mostly. It isn’t mutating under the user as they page, so the positional shift never happens.
  • The UI genuinely wants random page access or a total count. Offset pairs naturally with a “page 3 of 12” control: numbered pages, jump-to-page, a known total. A cursor can’t offer that; it only knows whether there’s a next page.

There are two triggers, and crossing either one means offset is no longer safe. The first is a list that could grow past the low thousands, where the re-scan cost starts to add up. The second is a list users see while it’s being written to, like a feed, an inbox, or an activity log, where the positional shift can happen. Most SaaS list endpoints cross at least one of these triggers, which is exactly why cursor is the default and offset is the justified exception, not the other way around.

Before we build anything, sort a few concrete lists into the right bin. This decision is the part worth practicing, since it’s easy to get backwards.

Sort each list into the pagination it should use. Drag each item into the bucket it belongs to, then press Check.

Offset is fine Small, bounded, read-mostly
Reach for a cursor Grows past low thousands, or mutates under the user
An internal admin table of ~200 feature flags, edited rarely
A customer-facing activity feed, thousands of rows, written to live
A report with a fixed “page 4 of 9” control over a static nightly export
The invoices list — grows every month, shows newly created invoices at the top
A one-page settings screen of ~30 rows, no paging UI at all
An inbox the user reads while new messages keep arriving

The pattern in the cursor bucket is always the same: the list grows or moves. Once you see that, the build ahead is just the mechanics of “the rows after this one.”

We’ll build the query in stages, so the tiebreaker arrives as the fix to a concrete break rather than as a rule handed down. Start from the core idea: give me the rows after the last one I saw.

The invoices list is newest-first, sorted by createdAt. The last row of the current page has some createdAt; the next page is the rows that come after it in that ordering. Since the list descends, “after” means “older than,” so the obvious shape filters for rows whose createdAt is less than the cursor’s:

const page = await db
.select()
.from(invoices)
.where(
and(
eq(invoices.organizationId, orgId),
cursor ? lt(invoices.createdAt, cursor.createdAt) : undefined,
),
)
.orderBy(desc(invoices.createdAt))
.limit(pageSize);

Read it and it sounds right: rows older than the last one we showed, newest of those first, capped at a page. Two details are worth noting before we break it. The cursor is an object holding the last row’s sort value, just { createdAt } for now. And on the very first page there’s no last row yet, so cursor is undefined and that branch drops out of the and. Drizzle skips an undefined condition, so the first page is just “newest, limited.” (organizationId is always in the where; we’ll come back to why it lives there and not in the cursor.)

This works in every demo you’ll ever build. Then it meets real data and silently loses a row.

Two invoices can share a createdAt. A bulk import writes a batch in the same instant; a traffic burst stamps two rows the same millisecond. It isn’t an edge case you can wish away: at any real volume, timestamp collisions happen.

Now suppose the page boundary lands between two invoices with the identical createdAt, where one is the last row of this page and the other should be the first row of the next. The next-page query asks for lt(createdAt, boundary): rows strictly older than that timestamp. But the row that should start the next page has the same timestamp as the cursor, not an older one, so it isn’t strictly less than the boundary. It fails the filter and never comes back. The user pages right past it. The row is just gone from their view, and nothing errors.

The fix is the habit you practiced in the first lesson, and here it does real work: don’t compare on createdAt alone, compare on the pair (createdAt, id). The primary key is unique, so two rows can tie on createdAt but never on id. Comparing the pair gives every row a single, unambiguous position in the order, so the boundary is never stuck between two indistinguishable rows. In a predicate, “the pair (createdAt, id) is less than (cursor.createdAt, cursor.id)” expands to:

or(
lt(invoices.createdAt, cursor.createdAt),
and(eq(invoices.createdAt, cursor.createdAt), lt(invoices.id, cursor.id)),
)

In words: rows strictly older than the cursor’s timestamp, OR rows with the same timestamp but a smaller id. The first branch handles the common case; the second branch is the tiebreaker, reaching back into the tied group to grab exactly the rows on the far side of the boundary, the ones the single-key version dropped. This is the compound-cursor shape Drizzle’s own pagination guide recommends, here in its descending form (lt rather than gt) because the list runs newest-first. So this is what the tiebreaker was for all along. It was never just tidiness: without it, the cursor skips rows at every page seam where two timestamps collide.

Stage three: the sort has to match the cursor

Section titled “Stage three: the sort has to match the cursor”

There’s one more piece, and it’s the one people forget. The compound predicate decides which rows are “after the cursor” by comparing (createdAt, id) as a pair, descending. For that boundary to mean anything, the rows have to actually come back in that same order. Otherwise “after” is defined against one ordering while the result follows another, and the comparison no longer lines up with what the user sees. So the orderBy carries the same two columns in the same two directions:

.orderBy(desc(invoices.createdAt), desc(invoices.id))

The rule to hold onto: the cursor predicate and the orderBy are one design, same columns and same directions, or the boundary is meaningless. Here’s the whole query assembled. It’s worth stepping through the where slowly, because the compound predicate is the densest part of this lesson and the place where attention tends to slip.

const page = await db
.select()
.from(invoices)
.where(
and(
eq(invoices.organizationId, orgId),
cursor
? or(
lt(invoices.createdAt, cursor.createdAt),
and(
eq(invoices.createdAt, cursor.createdAt),
lt(invoices.id, cursor.id),
),
)
: undefined,
),
)
.orderBy(desc(invoices.createdAt), desc(invoices.id))
.limit(pageSize);

The main branch: rows strictly older than the cursor’s timestamp. This is the common case, and almost every row of the next page comes back through here. On its own, though, it silently drops any row that ties the cursor’s timestamp.

const page = await db
.select()
.from(invoices)
.where(
and(
eq(invoices.organizationId, orgId),
cursor
? or(
lt(invoices.createdAt, cursor.createdAt),
and(
eq(invoices.createdAt, cursor.createdAt),
lt(invoices.id, cursor.id),
),
)
: undefined,
),
)
.orderBy(desc(invoices.createdAt), desc(invoices.id))
.limit(pageSize);

The tiebreaker branch: same createdAt as the cursor, but a smaller id. Without it, two invoices sharing a timestamp at the page seam mean the next page skips one. The unique id breaks the tie so no row falls through.

const page = await db
.select()
.from(invoices)
.where(
and(
eq(invoices.organizationId, orgId),
cursor
? or(
lt(invoices.createdAt, cursor.createdAt),
and(
eq(invoices.createdAt, cursor.createdAt),
lt(invoices.id, cursor.id),
),
)
: undefined,
),
)
.orderBy(desc(invoices.createdAt), desc(invoices.id))
.limit(pageSize);

The or unions the two branches: strictly after on the sort key, or tied on the sort key but after on the id. Together they describe exactly “every row after this specific row,” a total, gap-free boundary.

const page = await db
.select()
.from(invoices)
.where(
and(
eq(invoices.organizationId, orgId),
cursor
? or(
lt(invoices.createdAt, cursor.createdAt),
and(
eq(invoices.createdAt, cursor.createdAt),
lt(invoices.id, cursor.id),
),
)
: undefined,
),
)
.orderBy(desc(invoices.createdAt), desc(invoices.id))
.limit(pageSize);

Same two columns, same descending directions as the predicate. The boundary only makes sense if the rows come back in the order the comparison assumes, so the where and the orderBy are a matched pair.

1 / 1

Two more mistakes sit close to this query, both separate from the missing tiebreaker. They’re worth naming so you recognize each one on its own.

Sort only on a stable key. A cursor works only if the sort value can’t change for a row mid-pagination. createdAt is set once at insert and never touched, which is exactly what you want. updatedAt is the opposite. Touch a row, say mark an invoice paid, while a user is paging, and its updatedAt jumps to now. Under newest-first, that jump moves the row to the top of the list, back across the cursor boundary the user has already passed. The user then sees it again on a later page, or it vanishes from where it should have been. Sorting on a mutable column brings back the exact instability you adopted the cursor to escape, so sort cursor lists on creation time or another immutable key, never on a “last modified” column.

The tenant scope rides on the request, not the cursor. The where still carries eq(invoices.organizationId, orgId); the org-scoping habit from the first lesson still applies here. But look at where orgId comes from: your auth context, on the server, not the cursor. This matters because the cursor is a token you hand to the client and the client hands back. If the tenant id were baked into it, a user could decode that token, swap in another organization’s id, and send it back. That would let one tenant read another tenant’s data, an access-control hole you’d have built in by design. So the cursor carries only the sort key and tiebreaker, { createdAt, id }, and nothing that identifies the tenant. The request authenticates the tenant; the cursor only marks a position within it.

That compound predicate has a formal name you’ll meet in the docs: keyset pagination . It’s the same idea under a different name, and now you’ll recognize it when it comes up.

Now write the predicate that does the real work. The table below is seeded newest-first, and the key detail is that two invoices share the exact same created_at, straddling the page boundary. A naive lt(createdAt, …) cursor drops one of them; only the compound or(lt(createdAt), and(eq(createdAt), lt(id))) returns the full, correct next page. Write the where.

Page 1 ended on the invoice in `cursor` — and its `created_at` is shared with another, lower-id invoice that belongs on page 2. Finish the `where` so it returns every row after `cursor` under newest-first order with `id` as the tiebreaker: rows with an older `created_at`, OR the same `created_at` but a smaller `id`. The `orderBy` is already wired to match.

View schema & seed rows
Schema (Drizzle)
export const organizations = pgTable('organizations', {
  id: integer('id').primaryKey(),
  name: text('name').notNull(),
});

export const invoices = pgTable('invoices', {
  id: integer('id').primaryKey(),
  organizationId: integer('organization_id')
    .references(() => organizations.id)
    .notNull(),
  amountDue: numeric('amount_due', { precision: 12, scale: 2 }).notNull(),
  status: text('status').notNull(),
  createdAt: timestamp('created_at', { mode: 'string' }).notNull(),
});
Seed rows (SQL)
INSERT INTO organizations (id, name) VALUES (1, 'Acme');

INSERT INTO invoices (id, organization_id, amount_due, status, created_at) VALUES
  (10, 1, '100.00', 'sent', '2026-05-10 10:00:00Z'),
  (9,  1, '200.00', 'sent', '2026-05-09 10:00:00Z'),
  (8,  1, '300.00', 'sent', '2026-05-08 10:00:00Z'),
  (7,  1, '400.00', 'sent', '2026-05-08 10:00:00Z'),
  (6,  1, '500.00', 'sent', '2026-05-07 10:00:00Z'),
  (5,  1, '600.00', 'sent', '2026-05-06 10:00:00Z');

The query needs a { createdAt, id } object. But that object lives on the server, while the next page is a fresh request from the client. So the cursor has to make a round trip: out to the client in the response, then back to the server in the next request’s URL. That raises two questions: what form does it travel in, and how do you trust it when it comes back?

The form is an opaque token. You don’t ship the client a tidy { createdAt, id } it can read and rebuild. You encode it into a meaningless-looking string and let the client treat it as a black box: copy it, send it back, never parse it. The reason to hide it is coupling. The moment a client can read or construct cursors, it depends on your exact field names and sort columns, and then you can’t change how the list is ordered without breaking every caller. Keeping the token opaque preserves that freedom to change the ordering later. It’s opaque to the client but not to you: the server still has to decode it, so “opaque” here means hidden from the client, not encrypted.

The encoding is two steps: serialize to JSON, then base64url it:

const token = Buffer.from(JSON.stringify(cursor)).toString('base64url');

That base64url matters, and it’s not the same as plain base64. Standard base64 uses + and / and trailing =, all of which have meaning in a URL and would need escaping. base64url swaps in URL-safe characters and drops the padding, so the result drops straight into a query string with nothing to escape: ?cursor=eyJjcmVhdGVkQXQ.... (The page size travels as its own plain param, ?pageSize=20, with a server-side cap, typically 100, and a default that lives in a constants file rather than scattered across queries.)

Coming back in, decode the token and then validate before you trust it, the step beginners tend to skip. The cursor is a value a user controls. It could be malformed, stale from an old sort, or hand-crafted by someone poking at your API. So a bad cursor has to be rejected as bad input, the way a 400 rejects any malformed request. It must not be swallowed silently, and it must not be allowed to crash deep in the query layer when cursor.createdAt turns out to be undefined.

const decoded = JSON.parse(Buffer.from(raw, 'base64url').toString());
const result = cursorSchema.safeParse(decoded);
if (!result.success) {
return err('validation', 'Invalid cursor');
}
const cursor = result.data; // { createdAt: string; id: number } — safe to query with

Two of those names are forward references, so read them lightly. cursorSchema.safeParse is Zod, which the forms chapter a few units on covers in full; here just read it as “confirm the decoded object really is { createdAt, id }.” And err('validation', …) is the course’s way of returning a failure rather than throwing it. A bad cursor is expected user input, not a crash, so it travels back as a typed error that the caller turns into a 400, and the error-handling chapter formalizes that channel.

The durable point underneath both is one you already met in the first lesson: a cursor is just another request parameter, so it gets the same validate-at-the-boundary defense as any query string or body field. This also settles a worry the first lesson raised: decoding a cursor does not reopen the SQL-injection door. The decoded createdAt and id still flow into lt(...) and eq(...) like any other value, so they’re bound as $1 placeholders, never spliced into SQL text. The data is untrusted, but it’s parameterized, which is exactly the posture you want.

In practice these two steps become a parseCursor and encodeCursor pair that lives in your lib/ folder rather than inlined into the query, and the code conventions settle that structure later. The point here is the shape: encode on the way out, decode and validate on the way in.

A paginated UI needs one more piece of information: is there another page, or is this the end? You could answer it with a separate count() query, but that’s a whole round-trip spent on a yes/no. There’s a sharper trick, and it costs you exactly one extra row.

Ask for one more row than the page actually needs, with limit(pageSize + 1). Then read the result by its length. If you got pageSize + 1 rows back, there’s at least one more row out there, so a next page exists, and you slice that extra row off before returning, because it belongs to the next page, not this one. If you got pageSize or fewer, you’ve reached the end. The extra row never gets shown; its mere existence is the signal.

const rows = await query.limit(pageSize + 1);
const hasNextPage = rows.length > pageSize;
const items = rows.slice(0, pageSize);
const nextCursor = hasNextPage ? encodeCursor(items.at(-1)) : null;
return { items, nextCursor };

The next cursor is the key of the last row you actually return, which is items.at(-1) after the slice, never the probe row you threw away. Encode it, and that token becomes the ?cursor= for the request that fetches the following page; when there’s no next page, it’s null and the UI knows to stop. The return shape, { items, nextCursor }, is the one the code conventions describe for a paginated read: an array, plus the cursor metadata to fetch more.

returned (the page) handed back: 5 rows
probe sliced off — “there’s a next page”
Fetch pageSize + 1. If the probe row comes back, there's a next page — slice it off and hand back the first pageSize. One extra row replaces a whole count query, and the next cursor is the key of the last row you return, never the probe.

Everything so far has been about correctness: returning the right rows, in the right order, with no skips or duplicates. Underneath that sits a performance condition this lesson names but does not teach, and the query and this condition must always ship together. Cursor pagination is fast only when an index covers the cursor’s columns in the cursor’s sort direction. Without that index, “seek to the boundary” quietly degrades into a sequential scan of the table, and a cursor that scans is slower than the offset it replaced. You’d have taken on all the complexity and lost the payoff.

Here’s the shape of the index the query needs:

index('idx_invoices_org_created_at_id').on(
invoices.organizationId,
invoices.createdAt.desc(),
invoices.id.desc(),
)

Compare it to the query column by column. The tenant scope comes first: organizationId is an equality filter (eq), so it leads. Then come the two cursor columns, createdAt and id, in the same descending order the orderBy uses. With those three in one index, Postgres can satisfy the org filter, produce the rows already in sort order, and seek straight to the cursor boundary, all from a single structure and with no scanning. The columns and directions match across this index, the where, and the orderBy, and that match is not a coincidence: it’s the whole reason the seek is cheap.

One thing to be clear about: cursor pagination is a query shape, not a feature of any one API. You met two ways to read in this chapter, the SQL builder (db.select) and the relational query builder (db.query), and the cursor rides on whichever one the feature already uses. The model is identical; only the surface differs. Here’s the exact same (createdAt, id) boundary, the same desc/desc order, the same pageSize + 1 probe, written both ways.

const rows = await db
.select()
.from(invoices)
.where(
and(
eq(invoices.organizationId, orgId),
cursor
? or(
lt(invoices.createdAt, cursor.createdAt),
and(
eq(invoices.createdAt, cursor.createdAt),
lt(invoices.id, cursor.id),
),
)
: undefined,
),
)
.orderBy(desc(invoices.createdAt), desc(invoices.id))
.limit(pageSize + 1);

Reach here when the read is flat. When the page rows are a plain list of invoices, already a db.select, the cursor is just the compound where you built above, plus the + 1 probe. The boundary is the or(...) block; the orderBy carries the same pair in the same direction.

Look past the syntax and it’s the same query: the same pair compared in the same direction, the same probe row. The only real difference is how you write the predicate. The SQL builder takes operator helpers in a where(...) call; the relational builder takes a filter object and drops to its RAW callback for the one predicate the object can’t express. So there’s no “which pagination API” decision to make. You paginate whatever read the feature already does: a flat list through db.select, a tree through db.query. The cursor mechanics are the same either way.

The cursor isn’t free, and knowing what it costs keeps you from being surprised later, or from over-engineering to win back something the product doesn’t need.

No total count, no “page 3 of 12.” A cursor knows whether there’s a next page; it has no idea how many pages exist in total. That’s a real loss if your UI shows numbered pages, and it’s why most 2026 SaaS lists don’t. They lean into the cursor: a “Load more” button or infinite scroll instead of a page picker, and either no count or an approximate one (“200+”). If the product genuinely needs an exact total, that’s a separate count() query, sometimes cached, and you pay for it deliberately, only where it earns its keep.

Forward-only, by default. The cursor you built pages forward. “Previous page” is usually not a second, backward cursor; it’s re-anchoring from a position the UI already kept, or just the browser’s own back button. True bidirectional cursors do exist: a reversed predicate over a reversed order, with the rows flipped back at the end. In early SaaS they rarely earn the implementation cost, so it’s enough to recognize the shape and reach for it only when the product clearly needs it.

One last connection ties this back to earlier work. The cursor lives in the URL’s search params, which is what makes a paged view shareable and able to survive a refresh, and you’ve already met that primitive. The URL-state lesson back in Unit 4 read ?cursor= from searchParams, validated it at the boundary, and made the point that the cursor token is opaque base64 on purpose. What’s still ahead is putting it to work in a live list view: wiring that ?cursor= param to client navigation, syncing it as the user moves through pages, and keeping the URL the source of truth. The list-view chapters in Unit 10 own that part. Here you built the cursor’s mechanics; there you’ll wire it to the address bar of a real, navigable list.

Step back and you’ve turned the first lesson’s tiebreaker habit into a real mechanism. Pairing a sort column with the primary key looked like a minor nicety at the time, but it’s the exact thing that makes the cursor correct: it’s what lets the boundary land cleanly between any two rows. And the cursor it powers is the default choice for every list that grows or moves. It seeks instead of scanning, so it stays fast at depth, and it anchors to a row instead of counting from the front, so it stays stable under writes. Every list view in the units ahead is built on this shape.