Skip to content
pokemontcgapi.com
Documentation

Query syntax

The complete Lucene-style grammar accepted by the q parameter: fields, phrases, boolean operators, negation, exact match, wildcards and ranges — and which fields hold data today.

The q parameter takes a Lucene-style query grammar: field:value terms, quoted phrases, AND/OR/NOT, - for exclusion, * and ? wildcards, and [a TO b] ranges. If you have written a Lucene, Elasticsearch or Solr query before, you already know most of it.

This page documents the whole grammar as we implement it — every construct, with the exact behaviour and the exact error. Every example is runnable against this API as written, and every example that is not explicitly marked as matching nothing was checked on 2026-08-27 to return rows.

query.sh
curl -s -G "https://api.pokemontcgapi.com/v1/cards" \
  --data-urlencode 'q=name:charizard rarity:rare hp:[300 TO *]' \
  -H "X-Api-Key: $PTCG_API_KEY"

URL-encode it

Queries contain spaces, colons, brackets and quotes. With curl use -G --data-urlencode; in code use URLSearchParams or your client’s params. Hand-built query strings are where most "why does this return nothing" tickets come from.

Two spellings, and only one of them is a query

This is the single trap on this page, so it comes before the grammar rather than after it.

Query fields are camelCase and dotted. Response keys are snake_case.

They are two different vocabularies and neither one is accepted in the other’s place. q=set.code:bs is a valid query; the card it returns carries "set_code": "bs". q=set_code:bs is 400 INVALID_QUERY — verified live on 2026-08-27. The same applies to nationalPokedexNumbers / national_pokedex_numbers, flavorText / flavor_text, regulationMark / regulation_mark and convertedRetreatCost / converted_retreat_cost.

Field names are matched case-insensitively within the query vocabulary, so set.releasedate and set.releaseDate resolve to the same field. What is not accepted is the response spelling: the underscore, not the capital letter, is what makes set_code unknown. An unknown field is 400 INVALID_QUERY with the token in details.field, its offset in details.position, and the complete accepted list in details.valid_fields — so you never have to hardcode the list below.

The grammar at a glance

Every construct the parser accepts, in one table. Each has its own section below with runnable examples.

ConstructWritten asDoes
Field termname:charizardSubstring match on a named field.
Free textcharizardSearches card names, whole words and substrings.
Phrasename:"venusaur v"Groups words into one term.
Implicit ANDa bBoth clauses must hold.
ANDa AND b / a && bSame as adjacency, written out.
ORa OR b / a || bEither clause.
Grouping(a OR b) cOverrides precedence.
Negation-a / NOT aExcludes, null-safe.
Exact match!name:charizardPromotes "contains" to "is exactly".
Wildcard, manyname:char*Any run of characters.
Wildcard, onename:char?zardExactly one character.
Inclusive rangehp:[1 TO 100]Both endpoints included.
Exclusive rangehp:{1 TO 100}Neither endpoint included.
Open rangehp:[300 TO *]One end unbounded.
Nested fieldset.releaseDate:…Reaches into a relation or a JSON column.

Precedence is the standard one: NOT binds tightest, then AND, then OR. Parentheses override it.

Field terms

field:value is the basic unit. Text fields match on substring, case- and accent-insensitively.

QueryMatches
name:charizardAny card whose name contains "charizard" — including "Dark Charizard" and "Charizard ex".
rarity:rareEvery rarity containing "rare": Rare, Rare Holo, Rare Rainbow.
artist:arita"Mitsuhiro Arita", without typing the whole name.
set.name:"obsidian flames"Substring search on the set name.

Free text

A bare term with no field searches card names two ways at once: a full-text index for whole words and a trigram index for substrings. They are complementary — charizard is found by the first, zard only by the second.

QueryMatches
charizardFree-text search across card names.
zardSubstring — still finds Charizard.

Phrases

Double quotes group words into one term. Inside quotes, * and ? are literal characters rather than wildcards.

QueryMatches
name:"venusaur v"The two words adjacent, in that order.
name:venusaur vSomething different — see implicit AND below.
set.series:"scarlet & violet"Every set in the series, ampersand and all.

Escape a quote inside a phrase with a backslash: set.name:"the \"promo\" set". The parser accepts it; whether anything matches is a separate question.

Boolean operators

Implicit AND

Two adjacent clauses are ANDed. a b and a AND b compile to exactly the same query.

QueryMeaning
name:charizard rarity:"rare holo"Both conditions hold.
name:charizard AND rarity:"rare holo"Identical to the line above.
name:charizard && rarity:"rare holo"Also identical.

OR and grouping

OR (or ||) widens. Parentheses control precedence, which is the standard Lucene order: NOT binds tightest, then AND, then OR.

QueryMeaning
set.code:bs OR set.code:obfEither set.
(set.code:bs OR set.code:obf) rarity:rareEither set and a rare.
set.code:bs OR set.code:obf rarity:rareAll of Base Set, plus the rares from Obsidian Flames. Without parentheses AND binds first.

Operators are uppercase and bare

AND, OR and NOT are operators only when written in capitals and unqualified. and is a search term, and so is name:AND — which really does return cards, because "Rare Candy" contains it. Otherwise looking for a card literally named "AND" would be impossible.

Negation

A leading - excludes. NOT does the same thing.

QueryMeaning
name:charizard -rarity:commonCharizards that are not commons.
name:charizard NOT rarity:commonThe same.
-rarity:commonEverything that is not common — including cards with no rarity recorded at all.

That last row is the subtle one. Negation is null-safe: a card with no rarity still matches -rarity:common. A plain SQL NOT would evaluate to unknown on a null column and drop those rows, which is not what "not common" means to anybody.

A hyphen inside a value is just a hyphen

- is negation only at the start of a token. set.id:cn-cbb6c and name:ho-oh work exactly as written.

Exact match with !

A leading ! promotes a term from "contains" to "is exactly". It is not negation — - is negation — and it is the only way to tell apart values where one contains the other.

QueryMatches
!name:charizardCards named exactly "Charizard". Excludes "Charizard ex" and "Dark Charizard".
name:charizardAll of them.
!rarity:rareThe rarity "Rare" alone, not "Rare Holo" or "Rare Rainbow".

! cannot be combined with a range: !hp:[1 TO 100] returns 400 INVALID_QUERY with a message saying so.

Wildcards

* stands for any run of characters, ? for exactly one. Matching is per word, not against the whole field.

QueryMatches
name:char*"Charizard", "Charmander", and "Dark Charizard" — word-anchored, so the prefix may start any word.
name:char*der"Charmander". Not "Charizard".
name:char?zardOne character in the gap: "Charizard".
set.id:sv*Every set whose id starts with sv.

Leading wildcards are rejected

A pattern starting with * or ? returns 400 QUERY_UNSUPPORTED. There is no prefix to search on, so every row in the table has to be examined — slow across 52,337 cards, an incident when ten clients repeat it. You almost never need one: free-text search already matches substrings, so zard finds Charizard.

Ranges

Ranges work on numeric and date fields. Square brackets are inclusive, curly braces exclusive, and * is an open end.

QueryMeaning
hp:{1 TO 100}Strictly between 1 and 100 — neither endpoint.
hp:[* TO 100]Up to and including 100, no lower bound.
hp:[300 TO *]300 or more.
hp:[1 TO 100}Mixed ends are legal: inclusive lower, exclusive upper.
hp:[* TO *]The field is present at all — a null check, not a no-op.
set.releaseDate:[1999-01-01 TO 1999-12-31]Dates, YYYY-MM-DD or YYYY/MM/DD.
number:[1 TO 50]Collector numbers 1–50, sorted on the numeric part so "TG12" behaves.

A range on a text field is 400 QUERY_UNSUPPORTED; a non-integer or impossible date is 400 INVALID_QUERY. Both are deliberate: passing them through would be a database cast error, which is to say a 500 caused by a client mistake.

Nested fields

Dotted names reach into related records and into structured columns. Nothing about the dot is magic — they are ordinary field names.

Set fields

These are the nested fields that carry data across the whole catalogue.

QueryMeaning
set.id:sv3Set by canonical code or alternate id. Both resolve, so a query built from stored ids works unchanged.
set.code:bsOur canonical code only.
set.name:"obsidian flames"Substring match on the set name.
set.ptcgoCode:OBFThe code used by the game client.
set.series:"scarlet & violet"Every set in the series.
set.releaseDate:[2020-01-01 TO *]Sets released since 2020.

set.id:sv3 matches sv3 and not sv3a: identifier fields always compare whole values, never substrings, even without !. Use a wildcard — set.id:sv3* — when you do want the family.

Legalities

The parser validates the value against legal, banned, restricted and rotated. The column itself is empty catalogue-wide, so rather than answering zero rows the whole field is rejected with 400 and an explanation: a query that silently matches nothing reads as "no card is Standard-legal", which is a different and wronger statement than "we do not hold legalities".

Fields that accept a query but match nothing

A grammar that accepts a field it can never satisfy is a trap, so here is the measurement rather than a promise. On 2026-09-03 we counted, over all 52,337 cards rather than a sample, how many rows carry a non-empty value in each column. The game text arrived that day and it is English, so it sits on the 20,725 Western printings and not on the Japanese and Chinese ones: divide by 20,725 instead of by 52,337 to read these as Western coverage, which for attacks is 83% rather than 33%.

Column fill rate over all 52,337 cards, measured 2026-09-03
FieldCards with a valueWhat that means for q
rarity73.5%Reliable. 67 distinct values — read them from GET /v1/reference.
hp48.6%Reliable on Pokémon; Trainers and Energy have none by nature.
types42.7%Western printings.
nationalPokedexNumbers41.5%Western printings; several entries on a card depicting more than one Pokémon.
subtypes38.4%26 distinct values. Match the printed form: subtypes:"Stage 2", not stage2.
attacks.name / .damage / .text32.9%83% of the Western printings. damage is a string: 100, 180+, 30×.
convertedRetreatCost32.6%A range filter works: convertedRetreatCost:[0 TO 1].
retreatCost30.9%Slightly under the converted form, because a free retreat is 0 there and [] here.
weaknesses.type / .value30.8%value carries its own operator: ×2, +20.
flavorText19.6%Many printings carry none at all.
evolvesTo14.6%Derived from the evolution graph, not read off the card.
evolvesFrom13.7%The share that evolves from something, not a gap.
resistances.type / .value9.1%Most printings have none.
abilities.name / .text / .type7.7%The real share of printings that have one.
rules5.6%Trainer text.
legalities.standard / .expanded / .unlimited0%Rejected with 400 and an explanation, not answered with zero rows.
level0%Always zero rows: no source we hold carries it.

Zero rows is not the same as a broken query

q=subtypes:stage2 is syntactically perfect, is accepted, and returns {"data":[],"meta":{"count":0}} — because the printed value is Stage 2 with a space, so q=subtypes:"Stage 2" is the query that finds them. The grammar validates the field name, not the data behind it, and never the spelling of a value. If a query you believe is correct returns nothing, check this table and the vocabulary in GET /v1/reference before you check your encoding. What is populated everywhere: names, ids, numbers, set membership and release dates, images and prices.

Every searchable field

This is the list the API itself returns in details.valid_fields on a 400 INVALID_QUERY, reproduced verbatim as of 2026-08-27. Thirty-three fields, and nothing else is accepted.

details.valid_fields, verbatim, 2026-08-27
FieldKindPopulatedNotes
abilities.namejsonEmptySubstring inside the array.
abilities.textjsonEmptySubstring inside the array.
abilities.typejsonEmptySubstring inside the array.
artisttextYesIllustrator name. 399 distinct artists.
attacks.damagejsonEmptySubstring inside the array.
attacks.namejsonEmptySubstring inside the array.
attacks.textjsonEmptySubstring inside the array.
convertedRetreatCostintegerEmptyRanges supported by the parser.
evolvesFromtextEmptyPre-evolution name.
evolvesToarrayEmptyEvolution names.
flavorTexttextEmptySubstring.
hpintegerYesRanges supported. Null on Trainers and Energy.
ididentifierYesOur id or the alternate {set}-{number} id. Whole-value match.
legalities.expandedlegalityEmptylegal / banned / restricted / rotated.
legalities.standardlegalityEmptySame values.
legalities.unlimitedlegalityEmptySame values.
leveltextEmptyPrinted level.
nametextYesAlso the target of free-text terms.
nationalPokedexNumbersinteger arrayPartialRanges supported. Newer sets only.
numbernumberYesExact match on the string, ranges on the numeric part.
raritytextYesFree text across eras. 67 distinct rarities.
regulationMarkidentifierPartialSingle letter, on the eras that print one.
retreatCostarrayEmptyOne entry per symbol.
rulesarray of phrasesEmptySubstring inside each rule sentence.
set.codeidentifierYesCanonical code.
set.ididentifierYesCanonical code or alternate id.
set.nametextYesSubstring.
set.ptcgoCodeidentifierPartialGame client code, where one exists.
set.releaseDatedateYesRanges supported.
set.seriestextYesSubstring.
subtypesarrayEmptyCase-insensitive unless prefixed with !.
supertypetextYesPokémon, Trainer, Energy — three values.
typesarrayPartialEnergy types. Newer sets only.

Limits

A query that stops being a search and starts being a load test gets 400 QUERY_TOO_COMPLEX rather than a slow response:

LimitValue
Leaf clauses32
Parenthesis depth4
Length of q2048 characters
orderBy keys4

If you are brushing against the clause limit, you are usually looking up a known list of ids — `/v1/cards/batch` does that in one request and one credit per 25 ids.

Worked examples

All five return rows against the live catalogue.

  • q=set.code:bs -rarity:common — Base Set without the commons.
  • q=rarity:"rare holo" artist:arita — one illustrator’s holos.
  • q=(set.code:bs OR set.code:obf) hp:[100 TO *] — the big Pokémon of two sets, 1999 and 2023.
  • q=!name:pikachu set.releaseDate:[1999-01-01 TO 2000-12-31] — cards named exactly Pikachu from the first two years.
  • q=types:fire set.code:sv3 — where types is populated, it composes like any other field.

When a query returns nothing

In order: check the fill-rate table above, then check that you used the camelCase query spelling and not the snake_case response spelling, then check encoding. A query with unencoded spaces reaches us truncated, and a truncated query is usually still valid — it just means something else.

view this page as markdown