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.
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` 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 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
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 4base1-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
# 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 has the live counts per region and per locale.
Reading prices without flattening them
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 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.
"""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)id,qty
bs-4,1
mew-199,2
sv8-116,1Run 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
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 in the examples repository does exactly that with discord.py.
Errors you can branch on
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 existsEvery 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 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
- Keep the key in
PTCG_API_KEY; the client reads it, and it never ends up in a notebook you share. - Iterate pages instead of counting them, and use
to_list(max=...)when you really want a list. - Pick price rows by source, measure and date. Never average EUR with USD.
- Batch: 50 ids per
prices.current, 100 percards.batch. - Read
meta["withheld"]before concluding a card has no graded or non-English rows.
Where to go next
The SDK page lists every method with the route it wraps, in Python, TypeScript and Go. The package README has the change feed, conditional requests and photo recognition. The same valuer in Go is its own article.
Questions
- Is there a free Pokémon TCG API key for Python projects?
- Yes. The trial key needs an email and no card, carries 800 credits once the address is confirmed, and lasts 30 days. The Python SDK reads it from the PTCG_API_KEY environment variable. Get a free API key
- Does the Python SDK work in Jupyter notebooks?
- Yes. The sync client works in any cell. The async client works too, because notebooks already run an event loop: await its methods directly in a cell instead of calling asyncio.run.
- How do I get Japanese card prices in Python?
- Search with q="set.region:JP" to get Japanese printings, then read their prices with prices.card or prices.current. Each price row carries its locale, and rows in print languages other than English are served from the plan named on the pricing page. Pricing