Anatomy of a search
From query text to rows: tokenizing, the intermediate representation, and how it compiles to SQL.
A search goes through three stages, and each one can explain a surprising result. The syntax reference covers what to type. This covers what happens next.
1. Text becomes a tree
The query string is tokenized and parsed into an intermediate representation: a tree of conditions and groups.
t:creature (r:rare OR r:mythic) -kw:defenderbecomes an AND group containing a condition, an OR group of two conditions, and a negated condition. Parentheses in the text become nesting in the tree. The tree is what gets compiled, so grouping changes results.
Two properties of the parser decide how a malformed query behaves:
- It is best-effort. A token it cannot parse becomes a bare name search.
t:creature garbage!!searches for creatures whose name containsgarbage!!and returns rows. - The tree round-trips. The same module serializes a tree back to a query string, so the query the system ran can always be shown back to you. This is how the AI's searches are displayed.
A query with bare words and no operators is recognized as a plain name search
and routed to a typo-tolerant lookup. That is why lightening bolt finds
Lightning Bolt while o:lightening finds nothing.
2. The tree becomes SQL
Each condition maps to a filter on a column of the cards table, or on a
denormalized array beside it. The mapping is looser than it looks:
| You write | It filters |
|---|---|
t:wolf | Type line substring or the parsed subtype list |
o:flying | A generated column concatenating every face of the card |
set:cmm | The set-code array on the card, with no join to printings |
tag:removal | A denormalized tag array, with no join to associations |
price<=1 | The cheapest-price sidecar, joined as a view |
The denormalized arrays exist because joining through association tables on every search measured slower on a table this size, with no benefit. They are maintained by the jobs named in the reference pages, so in principle they can lag the tables they derive from.
OR across different fields cannot always be compiled to a single filter chain.
Two things have no expression inside an OR group: exact mana costs (m:) and
strict colour subsets (c<, ci<). A query using them there fails with an
error naming what to split out. The alternative would be to drop the branch and
answer with the unfiltered superset, which is a wrong result that looks like a
successful one.
3. Rows come back
Results are read from a view that joins cards to its price sidecar, so price
can be filtered and sorted in the same query even though it lives in a separate
table on a separate refresh schedule.
Counts come from the database as exact counts. A result count is the size of the full match set, not of the page you were given.
Why a search surprised you
In rough order of likelihood:
- A bare word was treated as a name. Anything the parser could not read becomes a name term. Check the query that actually ran.
t:matched a subtype you did not intend.t:bearmatches the creature type.o:matched the back face. Double-faced cards match on text you cannot see on the front.- A colour operator meant the other direction. Bare
c:is "contains" and bareci:is "fits inside". Write>=or<=explicitly. The asymmetry is Scryfall's convention and we match it. - The query used
m:or a strict colour subset inside anOR. That combination raises an error. Run the branches as separate searches.
Where the code lives
The IR and its serializer are in packages/core/src/query/, as pure TypeScript
with no database access. The compiler is apps/web/lib/search/executor.ts. The
split lets the query language be parsed, validated and round-tripped anywhere,
including in the browser and inside the AI pipeline.