# Pagination

> Cursor paging that never repeats or drops a row, and why there is no page parameter.

Source: https://pokemontcgapi.com/docs/pagination

Collections page with an opaque cursor. There is no `page` and no `offset` — not as a simplification, but because offset paging silently duplicates and drops rows near a page boundary, which is the section below.

## The envelope

`page.json`

```json
{
  "data": [ /* up to `limit` rows */ ],
  "meta": {
    "limit": 250,
    "count": 250,
    "has_more": true
  },
  "links": {
    "next": "https://api.pokemontcgapi.com/v1/cards?limit=250&q=set.code%3As4a&cursor=eyJrIjpbIjI1MCJdLCJpZCI6InM0YS0yNTAifQ"
  }
}
```

| Field | Meaning |
| --- | --- |
| `meta.limit` | The page size actually applied, after clamping. |
| `meta.count` | Rows in **this** page. |
| `meta.total_count` | Rows matching the filter. Present on sets and artists — 615 and 399 respectively on an unfiltered read of 2026-08-27 — and **absent on card queries**, because counting a filtered card query exactly costs more than the query. Do not treat its absence as zero. |
| `meta.has_more` | Whether another page exists. Equivalent to `links.next` being present. |
| `links.next` | The complete URL of the next page, filters and sort included. Follow it verbatim. |

## The loop

Follow `links.next` until it is gone. That is the whole protocol.

`pages.ts`

```ts
export async function* pages(start: string, key: string) {
  let next: string | null = start;

  while (next) {
    const res = await fetch(next, { headers: { "X-Api-Key": key } });
    if (!res.ok) throw new Error((await res.json()).error.code);

    const page = await res.json();
    yield page.data;

    // Never rebuild the URL: the cursor is signed with the sort order,
    // and a hand-assembled one is rejected rather than silently skewed.
    next = page.links?.next ?? null;
  }
}
```

## Why keyset, not offset

Two reasons, and the second one is the one that actually costs you data.

### Cost

With `OFFSET`, the database still reads and discards every preceding row, so page 400 costs four hundred times page 1. That is why offset-paged APIs end up capping the offset at some arbitrary depth and telling you to filter harder. With a keyset cursor the query jumps straight into the index: page 1 and page 10,000 cost the same, and there is nothing to cap.

### Correctness

This is the real one. An `ORDER BY` that is not unique leaves ties in undefined order, and the database is free to return them differently between two queries. Rows near a page boundary then appear twice or not at all — with no error, no warning, and nothing in your logs.

Every sort here ends with `id` as a tiebreaker — whether you asked for it or not — so the order is total and the boundary is exact. Pagination never repeats or drops a row, at any page depth.

> **The check that matters**
>
> Page a set that crosses the 250 boundary and count distinct ids. On 2026-08-27, `q=set.code:s4a` (Shiny Star V) paged in two requests and returned 330 rows and 330 distinct ids. If the two numbers agree, paging is sound. It is a two-minute test and it is worth running against any catalogue API you rely on, including this one.

## Sorting

`orderBy` takes up to four comma-separated keys; a leading `-` reverses one.

`orderBy.txt`

```text
orderBy=-release_date,name
```

- The tiebreaker (`id` for cards, `code` for sets, `slug` for artists) is appended automatically. Naming it explicitly only sets its direction; it never becomes a fifth key.
- Nulls sort last ascending and first descending, and the cursor predicate follows the same convention — so a nullable sort key pages correctly instead of stalling on the null group.
- An unknown key is `400 INVALID_PARAMETER` with the valid list in `details.valid_fields`. For cards on 2026-08-27 that list was `id`, `name`, `number`, `rarity`, `hp`, `index_eur`, `last_price_at`, `updated_at`, `created_at`, `release_date`, `set`, `artist`.

## Cursor rules

- **Opaque.** It is base64url JSON today; treat it as bytes. Its contents are not a contract.
- **Bound to the sort.** It carries a signature of `orderBy`. Reusing it under a different sort returns `400 INVALID_CURSOR` rather than silently skipping rows.
- **Not a bookmark.** It encodes a position in an ordering, not a snapshot. Rows inserted before your position while you page will not appear; that is inherent to keyset paging, not a defect.
- **Not durable.** Do not persist one for tomorrow. To resume across days, record the last `id` you imported and re-query from there, or re-read by `updated_at`.

## Choosing a page size

`limit` runs from 1 to 250 and defaults to 50. Because credits are charged per request and not per row, a bigger page is strictly cheaper: 250 rows in one call costs one credit, the same 250 rows in five calls costs five.

Above 250 you get `400 LIMIT_EXCEEDED`, with `details.max` and `details.requested` so the ceiling is machine-readable rather than something you learn from a message.

> **Filter before you page**
>
> Walking all 52,337 cards nightly (measured 2026-08-27) is the most expensive way to stay current. Every card carries `updated_at`, and `orderBy=-updated_at` is accepted — so re-read what moved instead of re-reading everything.
