The Pokémon TCG API in Go: a collection valuer in one file
The pokemontcgapi Go SDK: context on every call, a range-over-func paginator, errors.As, and a one-file CLI that values a card collection in EUR and USD.
The Go SDK, `github.com/pokemontcgapi/sdk-go`, is standard library only: net/http, encoding/json, and a context on every call. This article goes from the first lookup to a small command-line tool that values a card collection with the European and the American figure and the date of each, the same job as the Python article, so the two can be read side by side. Every snippet compiles against the published module.
You need Go 1.23 or newer (the paginator is a range-over-func iterator) and an API key; the trial key needs an email and no card and carries 800 credits once confirmed. Install with go get github.com/pokemontcgapi/sdk-go.
The first call
import "github.com/pokemontcgapi/sdk-go"
client := pokemontcgapi.New() // reads PTCG_API_KEY; or pokemontcgapi.WithAPIKey("...")
card, err := client.Cards.Get(ctx, "base1-4", &pokemontcgapi.CardGetParams{Include: []string{"images"}})
if err != nil {
return err
}
fmt.Println(card.ID, card.Name, card.SetName, card.Number) // bs-4 Charizard Base 4Ids are the printed coordinate, set code, dash, collector number, and a historical alias such as base1-4 resolves to the same card as bs-4. Structs mirror the JSON with Go names (SetName for set_name), and fields the API may leave empty are pointers or nil slices, so the compiler makes you handle the gap. A single lookup costs 1 credit.
Paging with range
page, err := client.Cards.Search(ctx, &pokemontcgapi.CardListParams{
Q: "name:umbreon*", OrderBy: "-release_date", Limit: 250, Select: []string{"id", "name", "set_name"},
})
if err != nil {
return err
}
// All follows links.next until the last row. The error arrives in the loop, with the row.
for c, err := range page.All(ctx) {
if err != nil {
return err
}
fmt.Println(c.ID, c.Name, c.SetName)
}page.All(ctx) is an iter.Seq2[Card, error]: the loop follows links.next until the last row, and a failure on page seven arrives as the error of the next iteration rather than as a partial slice. page.Next(ctx) gives you one page at a time, and page.Collect(ctx, max) builds a slice with an explicit ceiling. Cancel the context and the next request is not sent.
The same query language reaches the Japanese and Simplified Chinese print lines, which are separate sets with their own numbering: Q: "set.region:JP" on cards, Region: "JP" on the set list. The coverage page has the live counts per region.
Two calls at once
var (
card *pokemontcgapi.Card
prices *pokemontcgapi.PricesResponse[pokemontcgapi.CardPrices]
e1, e2 error
wg sync.WaitGroup
)
wg.Add(2)
go func() { defer wg.Done(); card, e1 = client.Cards.Get(ctx, "bs-4", nil) }()
go func() { defer wg.Done(); prices, e2 = client.Prices.Card(ctx, "bs-4", nil) }()
wg.Wait()
if err := errors.Join(e1, e2); err != nil {
return err
}
fmt.Println(card.Name, len(prices.Data.Quotes), prices.Meta.Withheld)The card and its prices are independent, so they run in two goroutines; the client is safe for concurrent use. Prices.Card costs 2 credits and returns rows with source, measure, basis, currency, print language, printing, grade and date, plus the composite index in EUR. Meta.Withheld names what the plan leaves out (graded and non_english_locales on the trial), so an empty table is never ambiguous. How the rows are built explains every field.
The valuer
One file, no dependency beyond the SDK. It reads a CSV of id,qty, prices the list in batches of fifty with Prices.Current, keeps the most recent ungraded LOW row per market (a market can return an older row next to a fresher one for the same card, so it picks by date instead of taking the first), and prints a dated line per card and a total per currency.
// ptcg-value: what is my collection worth today? One CSV in, one dated EUR and USD figure per card out.
package main
import (
"context"
"encoding/csv"
"errors"
"fmt"
"log"
"os"
"strconv"
"time"
"github.com/pokemontcgapi/sdk-go"
)
// newest is the most recent ungraded LOW row of one market: a July row can sit next to a September one.
func newest(rows []pokemontcgapi.Price, source string) *pokemontcgapi.Price {
var best *pokemontcgapi.Price
for i, q := range rows {
if q.Source == source && q.Variant == "LOW" && q.Grading == nil && (best == nil || q.AsOf > best.AsOf) {
best = &rows[i]
}
}
return best
}
func main() {
f, err := os.Open(os.Args[1])
if err != nil {
log.Fatal(err)
}
records, err := csv.NewReader(f).ReadAll() // header: id,qty
if err != nil {
log.Fatal(err)
}
owned := map[string]int{}
var ids []string
for _, r := range records[1:] {
qty, _ := strconv.Atoi(r[1])
owned[r[0]] = qty
ids = append(ids, r[0])
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
client := pokemontcgapi.New() // reads PTCG_API_KEY
total := map[string]float64{}
for start := 0; start < len(ids); start += 50 { // the price batch takes up to 50 ids
batch, err := client.Prices.Current(ctx, ids[start:min(start+50, len(ids))], nil)
var limited *pokemontcgapi.RateLimitedError
var quota *pokemontcgapi.QuotaExceededError
switch {
case errors.As(err, &limited):
log.Fatalf("rate limited, try again in %s", limited.RetryAfter)
case errors.As(err, "a):
log.Fatal("quota spent for this period: see the account page")
case err != nil:
log.Fatal(err)
}
for _, card := range batch.Data {
for _, source := range []string{"CARDMARKET", "TCGPLAYER"} {
if q := newest(card.Quotes, source); q != nil {
total[q.Currency] += q.Amount * float64(owned[card.CardID])
fmt.Printf("%-14s %9.2f %s %s %s\n", card.CardID, q.Amount, q.Currency, q.AsOf, q.Provenance)
}
}
}
for _, m := range batch.Missing {
fmt.Printf("%-14s not found %v\n", m.ID, m.SuggestedID)
}
}
fmt.Printf("\nTotal: %.2f EUR (Cardmarket), %.2f USD (TCGplayer)\n", total["EUR"], total["USD"])
}Build it with go build -o ptcg-value . and run ./ptcg-value collection.csv. Ids that do not resolve come back in Missing, with SuggestedID when the id belongs to another set, instead of failing the batch. The totals stay in their own currencies: a sum of Cardmarket euros and TCGplayer dollars is a number nobody can sell at. The batch route costs 4 credits per 25 ids, 80 credits for a binder of 500.
Errors with errors.As
_, err = client.Cards.Get(ctx, "sv8-116", nil)
var notFound *pokemontcgapi.NotFoundError
if errors.As(err, ¬Found) {
fmt.Println(notFound.Code, notFound.Details, notFound.RequestID)
// CARD_NOT_FOUND, with a suggestion in Details when a historical candidate exists
}Every typed error unwraps to *pokemontcgapi.APIError, so one errors.As on that type catches all of them, and the specific types (NotFoundError, RateLimitedError, QuotaExceededError, PlanRequiredError, TrialExpiredError) let you branch. The client retries rate limits, 5xx and network failures with backoff and honours Retry-After; it never retries an exhausted quota. Network failures are *ConnectionError, which carries no API code.
What it costs, and how to spend less
| 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 |
pokemontcgapi.New(pokemontcgapi.WithETagCache()) turns on conditional requests: the client stores each ETag and replays the body on a 304 Not Modified, which costs nothing, so a service that re-reads the same sets every few minutes pays only for what changed. client.LastResponse() has CreditsCost and QuotaRemaining, and WithOnResponse receives them on every call for a running total.
Practical rules
- Pass a context with a deadline to every call; the SDK never blocks without one.
- Range over
page.All(ctx)and check the error inside the loop. - Pick price rows by source, measure and date, and keep EUR and USD apart.
- Batch: 50 ids per
Prices.Current, 100 perCards.Batch. - Turn on
WithETagCachefor anything that polls.
Where to go next
The SDK page lists every method with the route it wraps; pkg.go.dev has the full reference. A complete service built on the module, with tests, is the Go version of the Discord price bot.
Questions
- Which Go version does the Pokémon TCG API SDK need?
- Go 1.23 or newer. The paginator is a range-over-func iterator, which arrived in 1.23, and the module has no dependency outside the standard library.
- Is the Go client safe to share between goroutines?
- Yes. Create one client at startup and share it; it wraps a standard http.Client. The ETag cache, when enabled, is guarded for concurrent use.
- Is there a free key to try it?
- Yes. The trial key needs an email and no card, carries 800 credits once confirmed, and lasts 30 days. The client reads it from PTCG_API_KEY. Get a free API key