# 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.

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

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

The Go SDK, [`github.com/pokemontcgapi/sdk-go`](https://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](https://pokemontcgapi.com/blog/pokemon-tcg-api-python), 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](https://pokemontcgapi.com/free-api-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

```go
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 4
```

Ids 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

```go
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](https://pokemontcgapi.com/coverage) has the live counts per region.

## Two calls at once

```go
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](https://pokemontcgapi.com/blog/how-we-compute-eur-and-usd-card-prices) 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.

`main.go`

```go
// 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, &quota):
			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

```go
_, err = client.Cards.Get(ctx, "sv8-116", nil)
var notFound *pokemontcgapi.NotFoundError
if errors.As(err, &notFound) {
	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

1. Pass a context with a deadline to every call; the SDK never blocks without one.
2. Range over `page.All(ctx)` and check the error inside the loop.
3. Pick price rows by source, measure and date, and keep EUR and USD apart.
4. Batch: 50 ids per `Prices.Current`, 100 per `Cards.Batch`.
5. Turn on `WithETagCache` for anything that polls.

> **Where to go next**
>
> The [SDK page](https://pokemontcgapi.com/sdk) lists every method with the route it wraps; [pkg.go.dev](https://pkg.go.dev/github.com/pokemontcgapi/sdk-go) has the full reference. A complete service built on the module, with tests, is the Go version of the [Discord price bot](https://github.com/pokemontcgapi/pokemon-tcg-discord-bot/tree/main/go).
