Skip to content
pokemontcgapi.com
Documentation

Pagination

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

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
{
  "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"
  }
}
FieldMeaning
meta.limitThe page size actually applied, after clamping.
meta.countRows in this page.
meta.total_countRows 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_moreWhether another page exists. Equivalent to links.next being present.
links.nextThe 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
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
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.

view this page as markdown