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

Source: https://pokemontcgapi.com/docs/query-syntax

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`

```bash
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.

| Construct | Written as | Does |
| --- | --- | --- |
| Field term | `name:charizard` | Substring match on a named field. |
| Free text | `charizard` | Searches card names, whole words and substrings. |
| Phrase | `name:"venusaur v"` | Groups words into one term. |
| Implicit AND | `a b` | Both clauses must hold. |
| AND | `a AND b` / `a && b` | Same as adjacency, written out. |
| OR | `a OR b` / `a \|\| b` | Either clause. |
| Grouping | `(a OR b) c` | Overrides precedence. |
| Negation | `-a` / `NOT a` | Excludes, null-safe. |
| Exact match | `!name:charizard` | Promotes "contains" to "is exactly". |
| Wildcard, many | `name:char*` | Any run of characters. |
| Wildcard, one | `name:char?zard` | Exactly one character. |
| Inclusive range | `hp:[1 TO 100]` | Both endpoints included. |
| Exclusive range | `hp:{1 TO 100}` | Neither endpoint included. |
| Open range | `hp:[300 TO *]` | One end unbounded. |
| Nested field | `set.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.

| Query | Matches |
| --- | --- |
| `name:charizard` | Any card whose name contains "charizard" — including "Dark Charizard" and "Charizard ex". |
| `rarity:rare` | Every 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.

| Query | Matches |
| --- | --- |
| `charizard` | Free-text search across card names. |
| `zard` | Substring — still finds Charizard. |

## Phrases

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

| Query | Matches |
| --- | --- |
| `name:"venusaur v"` | The two words adjacent, in that order. |
| `name:venusaur v` | Something 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.

| Query | Meaning |
| --- | --- |
| `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.

| Query | Meaning |
| --- | --- |
| `set.code:bs OR set.code:obf` | Either set. |
| `(set.code:bs OR set.code:obf) rarity:rare` | Either set **and** a rare. |
| `set.code:bs OR set.code:obf rarity:rare` | All 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.

| Query | Meaning |
| --- | --- |
| `name:charizard -rarity:common` | Charizards that are not commons. |
| `name:charizard NOT rarity:common` | The same. |
| `-rarity:common` | Everything 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.

| Query | Matches |
| --- | --- |
| `!name:charizard` | Cards named exactly "Charizard". Excludes "Charizard ex" and "Dark Charizard". |
| `name:charizard` | All of them. |
| `!rarity:rare` | The 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.

| Query | Matches |
| --- | --- |
| `name:char*` | "Charizard", "Charmander", and "Dark Charizard" — word-anchored, so the prefix may start any word. |
| `name:char*der` | "Charmander". Not "Charizard". |
| `name:char?zard` | One 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.

| Query | Meaning |
| --- | --- |
| `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.

| Query | Meaning |
| --- | --- |
| `set.id:sv3` | Set by canonical code **or** alternate id. Both resolve, so a query built from stored ids works unchanged. |
| `set.code:bs` | Our canonical code only. |
| `set.name:"obsidian flames"` | Substring match on the set name. |
| `set.ptcgoCode:OBF` | The 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%.

| Field | Cards with a value | What that means for `q` |
| --- | --- | --- |
| `rarity` | 73.5% | Reliable. 67 distinct values — read them from `GET /v1/reference`. |
| `hp` | 48.6% | Reliable on Pokémon; Trainers and Energy have none by nature. |
| `types` | 42.7% | Western printings. |
| `nationalPokedexNumbers` | 41.5% | Western printings; several entries on a card depicting more than one Pokémon. |
| `subtypes` | 38.4% | 26 distinct values. Match the printed form: `subtypes:"Stage 2"`, not `stage2`. |
| `attacks.name` / `.damage` / `.text` | 32.9% | 83% of the Western printings. `damage` is a string: `100`, `180+`, `30×`. |
| `convertedRetreatCost` | 32.6% | A range filter works: `convertedRetreatCost:[0 TO 1]`. |
| `retreatCost` | 30.9% | Slightly under the converted form, because a free retreat is `0` there and `[]` here. |
| `weaknesses.type` / `.value` | 30.8% | `value` carries its own operator: `×2`, `+20`. |
| `flavorText` | 19.6% | Many printings carry none at all. |
| `evolvesTo` | 14.6% | Derived from the evolution graph, not read off the card. |
| `evolvesFrom` | 13.7% | The share that evolves from something, not a gap. |
| `resistances.type` / `.value` | 9.1% | Most printings have none. |
| `abilities.name` / `.text` / `.type` | 7.7% | The real share of printings that have one. |
| `rules` | 5.6% | Trainer text. |
| `legalities.standard` / `.expanded` / `.unlimited` | 0% | Rejected with `400` and an explanation, not answered with zero rows. |
| `level` | 0% | 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.

| Field | Kind | Populated | Notes |
| --- | --- | --- | --- |
| `abilities.name` | json | **Empty** | Substring inside the array. |
| `abilities.text` | json | **Empty** | Substring inside the array. |
| `abilities.type` | json | **Empty** | Substring inside the array. |
| `artist` | text | Yes | Illustrator name. 399 distinct artists. |
| `attacks.damage` | json | **Empty** | Substring inside the array. |
| `attacks.name` | json | **Empty** | Substring inside the array. |
| `attacks.text` | json | **Empty** | Substring inside the array. |
| `convertedRetreatCost` | integer | **Empty** | Ranges supported by the parser. |
| `evolvesFrom` | text | **Empty** | Pre-evolution name. |
| `evolvesTo` | array | **Empty** | Evolution names. |
| `flavorText` | text | **Empty** | Substring. |
| `hp` | integer | Yes | Ranges supported. Null on Trainers and Energy. |
| `id` | identifier | Yes | Our id or the alternate `{set}-{number}` id. Whole-value match. |
| `legalities.expanded` | legality | **Empty** | legal / banned / restricted / rotated. |
| `legalities.standard` | legality | **Empty** | Same values. |
| `legalities.unlimited` | legality | **Empty** | Same values. |
| `level` | text | **Empty** | Printed level. |
| `name` | text | Yes | Also the target of free-text terms. |
| `nationalPokedexNumbers` | integer array | Partial | Ranges supported. Newer sets only. |
| `number` | number | Yes | Exact match on the string, ranges on the numeric part. |
| `rarity` | text | Yes | Free text across eras. 67 distinct rarities. |
| `regulationMark` | identifier | Partial | Single letter, on the eras that print one. |
| `retreatCost` | array | **Empty** | One entry per symbol. |
| `rules` | array of phrases | **Empty** | Substring inside each rule sentence. |
| `set.code` | identifier | Yes | Canonical code. |
| `set.id` | identifier | Yes | Canonical code or alternate id. |
| `set.name` | text | Yes | Substring. |
| `set.ptcgoCode` | identifier | Partial | Game client code, where one exists. |
| `set.releaseDate` | date | Yes | Ranges supported. |
| `set.series` | text | Yes | Substring. |
| `subtypes` | array | **Empty** | Case-insensitive unless prefixed with `!`. |
| `supertype` | text | Yes | `Pokémon`, `Trainer`, `Energy` — three values. |
| `types` | array | Partial | Energy 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:

| Limit | Value |
| --- | --- |
| Leaf clauses | 32 |
| Parenthesis depth | 4 |
| Length of `q` | 2048 characters |
| `orderBy` keys | 4 |

If you are brushing against the clause limit, you are usually looking up a known list of ids — [`/v1/cards/batch`](https://pokemontcgapi.com/docs/api/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.
