Explain plans and runs
Preview the plan
Call explain() to see the query plan without running it:
query = session.sql("...", dialect="snowflake")
print(query.explain())Quail runs the query planner and produces the full execution plan, but does not load the model or run any GPU work.
The output has two parts.
Logical plan shows the query structure. Read it bottom-up: scans are at the leaves, the projection is at the root. Each predicate appears under the operator that evaluates it.
Project: r.id, a.aspect
SemanticJoin (full)
PROMPT('...', r.body, a.aspect) (selectivity=15%)
Join
cross
SemanticFilter
PROMPT('...', r.body) (selectivity=60%)
Scan reviews as r [body, id]
Scan aspects as a [aspect]Physical plan shows what the execution engine will do: the
model and token budgets, then one line per operator with the
planner's estimates in columns to the right. Each AI.IF or
join predicate is listed under the operator that runs it, numbered
by its position in the query text (predicate 2 is the second
AI.IF you wrote). When the planner reorders them, the output
says which runs first.
physical: backend=quail, model=qwen3-4b-fp8, workers=1
KV=bf16, chunk budget=110,376 tokens, admission budget=362,250 tokens
est. rows est. pass est. time
Limit: 1 1
Project: r.id 1.5
AiFilter: r 1.5 1.09 ms
KV rewind=on
1st: predicate 2 PROMPT('DOCUMENT:\n{0}\n\nq2:') 6 50%
2nd: predicate 1 PROMPT('DOCUMENT:\n{0}\n\nq1:') 3 50%
Scan reviews as r 6
tokens=12, mean_doc_tokens=2The time estimates assume peak GPU throughput with no host overhead.
Actual runtimes are longer, for many reasons, such as kernel launch
latency and low MFU. Use explain(analyze=True) on a GPU to see
measured times next to the estimates. We are working on better
latency estimates.
On an operator line, est. rows is how many rows the planner expects
it to produce. On a predicate line, est. rows is how many documents
or pairs the planner expects to reach that predicate, and est. pass
is the selectivity it planned with. A selectivity you did not set
shows as 20%*, with a footnote.
explain(verbose=True) adds node ids, port connections, KV retention
decisions, and planner remarks.
Measure the plan
explain(analyze=True) works like EXPLAIN ANALYZE in a database:
it runs the query, then prints actual rows, time, and tokens next to
each estimate, followed by the query time, throughput, and GPU cost.
print(query.explain(analyze=True)) est. rows rows est. pass pass est. time time fresh tokens
Limit: 1 1 1 <1 ms
Project: r.id 1.5 2 <1 ms
AiFilter: r 1.5 2 1.09 ms <1 ms 60
KV rewind=on
1st: predicate 2 PROMPT('DOCUMENT:\n{0}\n\nq2:') 6 6 50% 66.7%
2nd: predicate 1 PROMPT('DOCUMENT:\n{0}\n\nq1:') 3 4 50% 50%
Scan reviews as r 6 6 <1 ms
tokens=12, mean_doc_tokens=2
run:
query time 1 s (model startup excluded)
startup 500 ms
throughput 6 documents/second over 6 input documents
tokens 1,234 fresh
GPU cost $0.0011 per query (1 GPU at $3.9492/hour, startup excluded)Here is how to read the table:
- Each
est.column is what the planner predicted. The column next to it is what actually happened. Compareest. rowswithrows,est. passwithpass,est. timewithtime. rowson a filter or join line is how many documents or pairs the model evaluated.passis the fraction that returned TRUE.- When
est. passandpassare far apart, the planner's selectivity guess was wrong. That is the most common reason a plan takes longer than its estimate. fresh tokensis the total input tokens the model processed in forward passes for that operator.
explain(analyze=True, verbose=True) adds more detail per operator,
including KV hits, KV misses, and GPU seconds.
The query runs once for the measurement. To keep the result rows too, run the query yourself and print the plan from the result:
result = query.run()
rows = result.collect()
print(result.explain())Refusals
If the planner cannot execute the query, explain() prints the
reason instead of a plan, and run() raises quail.RefusalError.
For example, if a document is too long to fit in one forward pass:
Refused: a document in 'r' needs 131,072 tokens with its prompt,
but the forward pass budget is 110,376 tokensOther common refusals:
the model needs 4 GPUs of memory, but Quail loads one complete copy per GPUper-batch apply() requires one GPU; use apply_table() or set gpus=1one join pair anchored on 'r' needs 150,000 tokens, but the forward pass budget is 110,376 tokens
Running a query
session.sql compiles the statement in this process and returns a
Query. Running it executes the plan on the GPU. On Modal, collect
the rows before the function returns, because a QueryResult reads
its rows when you ask for them.
result = query.run()A session created with endpoint returns a
RemoteQuery from sql().
Query
A compiled query bound to the session that parsed it.
Attributes
Prop
Type
Methods
plan
Query.plan()Build the physical plan. The first call plans the query. Later calls return that same plan.
Returns
PhysicalPlan. The plan the executor will run, including the model,
the worker count, and the estimated seconds.
explain
Query.explain(*, verbose=False, analyze=False)Print the plan. The shape of the text is the one in Preview the plan and Measure the plan.
Parameters
Prop
Type
Returns
str.
run
Query.run(plan=None)Execute the query in this process.
Parameters
Prop
Type
Returns
QueryResult.
collect
Query.collect(limit=None, batch_rows=65536)Execute the query and return one Arrow table. This calls run(), then
QueryResult.collect.
Parameters
Prop
Type
Returns
pyarrow.Table.
execute_stream
Query.execute_stream(batch_rows=65536, limit=None)Execute the query and stream the rows.
Parameters
Prop
Type
Returns
pyarrow.RecordBatchReader.
QueryResult
The rows of a finished query, plus the report and the answer tables.
The rows stay unread until collect, to_rows, or execute_stream
asks for them.
Attributes
Prop
Type
Methods
collect
QueryResult.collect(limit=None, batch_rows=65536)Read the result into one Arrow table.
Parameters
Prop
Type
Returns
pyarrow.Table.
execute_stream
QueryResult.execute_stream(batch_rows=65536, limit=None)
Stream the result as Arrow record batches.
Parameters
Prop
Type
Returns
pyarrow.RecordBatchReader.
to_rows
QueryResult.to_rows(limit=None)Read the result as a list of tuples, one tuple per row, in column order.
Parameters
Prop
Type
Returns
list[tuple].
count
QueryResult.count()Return the number of rows. len(result) calls this.
Returns
int.
explain
QueryResult.explain(*, verbose=False)Print the executed plan with measured rows and time beside the operators.
Parameters
Prop
Type
Returns
str. The text is no physical plan was executed when plan is
None.
Execution report
result.report is a dict with query time, token counts, and
per-stage metrics. See Metrics for
every field.