# The Pokémon TCG API in Python: cards, sets and a collection valuer

> The pokemontcgapi Python SDK from the first import to a script that values a collection in EUR and USD, with dates. Sync and async, typed, one dependency.

- **Published**: 2026-09-25
- **Updated**: 2026-09-25
- **Tags**: sdk, prices

Source: https://pokemontcgapi.com/blog/pokemon-tcg-api-python

Python is where most card projects start: a notebook that pulls a set, a script that prices a binder, a cron job that writes a CSV every morning. This article takes the [`pokemontcgapi`](https://github.com/pokemontcgapi/sdk-python) package from the first import to a script that answers the question everyone asks eventually, "what is my collection worth today", with a European and an American figure and the date of each. Every snippet below type-checks with `mypy --strict` against the published package.

You need Python 3.10 or newer and an API key. The [trial key](https://pokemontcgapi.com/free-api-key) needs an email and no card, and carries 800 credits once the address is confirmed, which is a long way for everything here. Install with `pip install pokemontcgapi`; the only runtime dependency is httpx.

## The first call

```python
from pokemontcgapi import PokemonTcgApi

client = PokemonTcgApi()  # reads PTCG_API_KEY from the environment

card = client.cards.get("base1-4", include=["images"])
print(card["id"], card["name"], card["set_name"], card["number"])
# bs-4 Charizard Base 4
```

`base1-4` and `bs-4` are the same card. Ids are the printed coordinate, set code, dash, collector number, and a historical alias resolves on the same route, so a list of ids you already have does not start with a matching problem. Responses are the decoded JSON, typed as `TypedDict`: you read `card["name"]` exactly as the documentation shows it, and your editor completes the keys. A single lookup costs 1 credit, and asking for the images does not change that.

## Searching, paging, and the Japanese catalogue

```python
# Every Umbreon printing, newest set first. Iterating the page follows links.next for you.
for c in client.cards.search(q="name:umbreon*", order_by="-release_date", limit=250, select=["id", "name", "set_name"]):
    print(c["id"], c["name"], c["set_name"])

# Japanese sets are their own rows, with their own numbering and release dates.
for s in client.sets.list(region="JP", limit=250):
    print(s["code"], s["name"], s["release_date"])

# Names in Japanese; lang changes the name field, not the print region.
jp = client.cards.search(q="set.region:JP name:pikachu*", lang="ja", limit=5)
print([c["name"] for c in jp.data])
```

Every list method returns a page you can iterate; the SDK follows `links.next` until the last row, so there is no cursor to thread through your code and none to build by hand (the cursor is signed, and a hand-made one is rejected). When you need a ceiling, `page.to_list(max=...)` asks for one explicitly. `select` keeps the payload small without changing the price of the call.

The Japanese and Simplified Chinese print lines are separate sets with their own numbering, not translations of the Western ones: `set.region:JP` in a query, or `region="JP"` on the set list, reads them. `lang` swaps the card name for a translation and falls back to English where one is missing. The [coverage page](https://pokemontcgapi.com/coverage) has the live counts per region and per locale.

## Reading prices without flattening them

```python
prices = client.prices.card("mew-199")  # Charizard ex, 151
for q in prices["data"]["quotes"]:
    print(q["source"], q["variant"], q["amount"], q["currency"], q["as_of"], q["locale"])
print("withheld by the plan:", prices["meta"].get("withheld", []))
```

`prices.card` costs 2 credits and returns rows, not a number. Each row says which market saw it (`CARDMARKET` in EUR, `TCGPLAYER` in USD, and a few more), which measure it is (`LOW`, `MARKET`, 7 and 30-day averages), the basis (an asking price or a published guide figure), the print language, the printing, the grade if any, and the day it is for. The composite index in EUR sits in `data["index"]`. [How the rows are built](https://pokemontcgapi.com/blog/how-we-compute-eur-and-usd-card-prices) has the whole model.

Two things catch people. A market can return more than one row of the same measure for a card, one per printing and sometimes an older row next to a fresher one, so pick by `as_of` instead of taking the first. And what your plan does not include is named, not hidden: `meta["withheld"]` lists `graded` and `non_english_locales` on the trial, and rows in other print languages come from the Developer plan up. A short table is often the plan speaking, not missing data.

## A collection valuer in forty lines

The script below reads a CSV of card ids and quantities, prices the whole list in batches of fifty with `prices.current`, takes the most recent ungraded `LOW` row per market, and prints one dated line per card and a total per currency. Unresolved ids come back in `missing`, with a suggestion when the id belongs to another set, instead of failing the batch.

`value_collection.py`

```python
"""What is my collection worth today? One CSV in, one dated EUR and USD figure per card out."""

import csv
import sys

from pokemontcgapi import PokemonTcgApi, QuotaExceededError, RateLimitedError
from pokemontcgapi.types import Price

client = PokemonTcgApi()  # reads PTCG_API_KEY


def newest(rows: list[Price], source: str) -> Price | None:
    """The most recent LOW row of one market: a July row can sit next to a September one."""
    low = [q for q in rows if q["source"] == source and q["variant"] == "LOW" and q["grading"] is None]
    return max(low, key=lambda q: q["as_of"], default=None)


with open(sys.argv[1], newline="", encoding="utf-8") as f:
    owned = {row["id"]: int(row["qty"]) for row in csv.DictReader(f)}

ids = list(owned)
total = {"EUR": 0.0, "USD": 0.0}
for start in range(0, len(ids), 50):  # the price batch takes up to 50 ids
    try:
        batch = client.prices.current(ids[start : start + 50])
    except RateLimitedError as error:
        sys.exit(f"rate limited, try again in {error.retry_after or 1} s")
    except QuotaExceededError:
        sys.exit("quota spent for this period: see the account page")
    for card in batch["data"]:
        for source in ("CARDMARKET", "TCGPLAYER"):
            q = newest(card["quotes"], source)
            if q is None:
                continue
            total[q["currency"]] += q["amount"] * owned[card["card_id"]]
            print(f"{card['card_id']:<14} {q['amount']:>9.2f} {q['currency']}  {q['as_of']}  {q['provenance']}")
    for missing in batch.get("missing", []):
        print(f"{missing['id']:<14} not found", missing.get("suggested_id") or "")

print(f"\nTotal: {total['EUR']:.2f} EUR (Cardmarket), {total['USD']:.2f} USD (TCGplayer)")
if client.last_response is not None:
    print("credits spent on the last call:", client.last_response.credits_cost)
```

`collection.csv`

```text
id,qty
bs-4,1
mew-199,2
sv8-116,1
```

Run it with `python value_collection.py collection.csv`. The two totals stay apart on purpose: converting Cardmarket EUR into dollars and adding it to TCGplayer USD produces a number nobody can sell at. The batch route costs 4 credits per 25 ids, so a binder of 500 cards is 80 credits per run; once a day on the trial is a fortnight of history, and a daily cron on a paid plan is a rounding error.

## Async, when the calls do not depend on each other

```python
import asyncio

from pokemontcgapi import AsyncPokemonTcgApi


async def main() -> None:
    async with AsyncPokemonTcgApi() as api:
        # Two independent calls, one round trip of wall time.
        card, prices = await asyncio.gather(api.cards.get("bs-4"), api.prices.card("bs-4"))
        print(card["name"], len(prices["data"]["quotes"]))


asyncio.run(main())
```

`AsyncPokemonTcgApi` has the same methods as the sync client, awaited, and its pages iterate with `async for`. It shares the request and response objects of the sync client, so switching a module over is a matter of adding `await`. A bot or a web handler that needs a card and its prices runs both at once; the [Discord bot](https://github.com/pokemontcgapi/pokemon-tcg-discord-bot) in the examples repository does exactly that with discord.py.

## Errors you can branch on

```python
from pokemontcgapi import NotFoundError

try:
    client.cards.get("sv8-116")
except NotFoundError as error:
    print(error.code, error.details, error.request_id)
    # CARD_NOT_FOUND, with a suggestion in details when a historical candidate exists
```

Every API error is a typed exception carrying `code`, `status`, `details` and `request_id`. `RateLimitedError` has `retry_after`, and the client already retried it with backoff before raising; `QuotaExceededError` is never retried, because waiting does not refill a quota. Quote the `request_id` in a support message: it is the only thing that can be looked up.

## What it costs

| Call | Method | Credits |
| --- | --- | --- |
| One card, set or search page | `cards.get`, `cards.search`, `sets.list` | 1 |
| Current prices of one card | `prices.card` | 2 |
| Current prices of up to 50 cards | `prices.current` | 4 per 25 ids |
| A price history | `prices.history` | 5 |

Every response says what it cost in `X-Credits-Cost`, and `client.last_response` keeps the headers of the last one: `credits_cost`, `quota_remaining`, and on the trial `trial_expires_at`. Pass `on_response=` to the client to keep a running total across a job.

## Coming from another API

If your code already calls a different Pokémon card API, the [migration guide](https://pokemontcgapi.com/docs/migrate-from-pokemontcg-io) maps the fields, the ids and the pagination one by one, and explains how to capture the change feed position before a full import so the copy stays current afterwards. The short version: ids resolve through their historical aliases, pages are followed by URL rather than by number, and prices come back as rows with a source and a date.

## Practical rules

1. Keep the key in `PTCG_API_KEY`; the client reads it, and it never ends up in a notebook you share.
2. Iterate pages instead of counting them, and use `to_list(max=...)` when you really want a list.
3. Pick price rows by source, measure and date. Never average EUR with USD.
4. Batch: 50 ids per `prices.current`, 100 per `cards.batch`.
5. Read `meta["withheld"]` before concluding a card has no graded or non-English rows.

> **Where to go next**
>
> The [SDK page](https://pokemontcgapi.com/sdk) lists every method with the route it wraps, in Python, TypeScript and Go. The [package README](https://github.com/pokemontcgapi/sdk-python) has the change feed, conditional requests and photo recognition. The same valuer in Go is [its own article](https://pokemontcgapi.com/blog/pokemon-tcg-api-go).
