swecrets

How a Query Becomes an Execution Plan

Internals & Failure — Rung 530 min read

Learning Objectives

  • Trace a SELECT from raw AST through the Analyzer's query tree to a query plan, and match each of EXPLAIN AST / SYNTAX / QUERY TREE / PLAN to the specific data structure it dumps
  • Explain why a query's logical plan (a single ordered tree) and its physical pipeline (a live, concurrently-running graph of processors) are different objects, and read EXPLAIN PIPELINE output — including × N replication and Resize nodes — to see the pipeline's actual shape
  • Diagnose a slow query using system.processors_profile_log, distinguishing a processor doing genuine work (elapsed_us) from one blocked on a neighbor (input_wait_elapsed_us / output_wait_elapsed_us)
  • Navigate ClickHouse's own source — Parsers, Analyzer, Planner, Processors, and Interpreters — to confirm, from primary source, which class is responsible for a specific stage of turning a query into a running pipeline

Why This Matters

select-execution-lifecycle, back in Module 2, gave you four stages — Parse, Analyze, Plan, Execute — and that model has been enough to reason about EXPLAIN, predict which errors get caught where, and understand vectorized execution as "processing happens in blocks." It was never wrong. It was simplified on purpose, the same way this course simplified MergeTree merges before Module 7's earlier lesson opened an actual part's directory on disk. That simplification stops being enough the moment you're staring at a query that's mysteriously slow, EXPLAIN says the plan looks fine, and you need to know which specific piece of the running system is actually the bottleneck — not "execution," as one undifferentiated stage, but one specific processor among dozens running concurrently. This lesson replaces "Execute" with what actually happens: a query plan compiles into a live graph of small, concurrently-running units called processors, connected by ports, driven by a thread pool — and ClickHouse gives you a direct window into every layer of that translation, from raw text to running graph, through EXPLAIN's less-familiar keywords and one system table most people never open until they need it.

The mental model

Parse, Analyze, Plan, and Execute are still the right four questions. What this lesson adds is that each of the first three produces one specific, inspectable data structure — and "Execute" isn't a stage at all, it's a different kind of thing: not a step in a sequence, but a graph that runs.

Parse produces an AST. Exactly as Module 2 described: grammar only, nothing checked against real tables or columns. EXPLAIN AST dumps it directly.

Analyze produces a query tree, not just a validated AST. This is the first real addition. ClickHouse's Analyzer takes the AST and builds a query treebuildQueryTree(ASTPtr, ContextPtr), a semantically resolved structure where every identifier is tied to a real column, * is expanded, aliases are substituted, and every expression has a concrete type. EXPLAIN QUERY TREE dumps this structure directly — a QUERY node with resolved COLUMN nodes carrying their result_type and a source_id pointing at exactly which table they came from. This matters because it's a genuinely different architecture from ClickHouse's older analysis path: ClickHouse's own docs describe the new Analyzer's defining change as that "query validation takes place before the optimization step," a shift from the previous system, which applied optimizations before full validation. EXPLAIN SYNTAX sits adjacent to this: it shows the AST after running the query tree through its resolution and optimization passes and converting it back to AST form — which is why a three-way self-join's aliases come out rewritten as __table1, __table2, __table3 once run_query_tree_passes = 1 is set, even though nothing about the query's meaning changed.

Plan produces a query plan — logical, still untouched by data. The Planner class takes the resolved query tree and builds a QueryPlan: a tree of IQueryPlanStep nodes (ReadFromMergeTree, FilterStep, AggregatingStep, SortingStep, LimitStep, and others) — the same tree EXPLAIN shows you by default, exactly as Module 2 described.

"Execute" is really two things: building a pipeline, then running it. QueryPlan::buildQueryPipeline(...) walks the plan tree and calls each step's updatePipeline(), and each step turns itself into one or more concrete processorsIProcessor objects with input and output ports that pull blocks in, do work, and push blocks out. The result is a query pipeline — not a sequence of stages that finish one after another, but a graph, and EXPLAIN PIPELINE shows you that graph directly. A PipelineExecutor then runs it across worker threads: each processor's prepare() method reports its own status — NeedData, PortFull, Ready, Async, or Finished — and the executor calls work() on whichever processors are Ready, over and over, until the whole graph reports Finished.

This is the single most important correction this lesson makes to the Module 2 model: a plan is a tree with one order; a pipeline is a graph with no single order at all. During execution, a ReadFromMergeTree source, several ExpressionTransforms, and an AggregatingTransform aren't "stages" that run one after another — they're independent processors, several of them alive and doing work at the same instant, only blocking on each other when a port genuinely has no data to pull or nowhere to push it. "First it reads, then it filters, then it aggregates" is the plan's story. The pipeline's story is closer to: dozens of small workers, each watching its own ports, all running whenever they have something to do.

Why the pipeline has multiple copies of the same processor. ClickHouse doesn't run one ReadFromMergeTree and hand its output to one AggregatingTransform — it runs N parallel copies of each, one per processing lane, where N is bounded by max_threads (which defaults to the number of CPU cores available). Resize processors appear in the pipeline specifically to redistribute blocks evenly across lanes when the number of lanes changes between steps, or when some lanes are naturally getting more data than others — "faster lanes effectively help out slower ones, optimizing overall query runtime." And critically: max_threads is an upper bound, not a promise. ClickHouse only allocates as many concurrent read streams as the selected data actually justifies, governed by merge_tree_min_rows_for_concurrent_read (163,840 rows by default) and merge_tree_min_bytes_for_concurrent_read (2,097,152 bytes by default) — a filtered query that only touches a small slice of a table can run with far fewer active lanes than max_threads allows, and nothing about that is visible in EXPLAIN (plan); it only shows up once you look at EXPLAIN PIPELINE's actual × N counts.

ASTEXPLAIN ASTAnalyzer: buildQueryTree()+ optimization passesQuery Treeidentifiers resolved, types inferredEXPLAIN QUERY TREE / SYNTAXPlanner classQuery Plantree of IQueryPlanStep — no data touchedEXPLAIN / EXPLAIN PLANbuildQueryPipeline(),each step's updatePipeline()Query Pipelinea graph of processors + ports — severalrunning concurrently, driven by PipelineExecutorEXPLAIN PIPELINEBoxes 1–3 are trees with one order.Box 4 is a live graph with no single order — the point of this lesson.
Four artifacts, four EXPLAIN keywords, one thing that's not a stage at all. A vertical flow of four boxes with arrows between them, each box naming the data structure and which class builds it, plus which EXPLAIN keyword reveals it. Box one: 'AST — built by the parser from raw SQL text. EXPLAIN AST.' Arrow down, labeled 'Analyzer: buildQueryTree() + optimization passes.' Box two: 'Query Tree — identifiers resolved, types inferred, wildcards expanded. EXPLAIN QUERY TREE (and EXPLAIN SYNTAX, which converts it back to AST form).' Arrow down, labeled 'Planner class.' Box three: 'Query Plan — a tree of IQueryPlanStep nodes: read, filter, aggregate, sort, limit. No data touched yet. EXPLAIN / EXPLAIN PLAN.' Arrow down, labeled 'QueryPlan::buildQueryPipeline(), each step's updatePipeline().' Box four, visually distinct (dashed border instead of solid, to signal it is not another tree): 'Query Pipeline — a graph of processors connected by ports, several running concurrently, driven by a PipelineExecutor across worker threads. EXPLAIN PIPELINE.' A final caption below all four boxes reads: 'Boxes one through three are trees with one order. Box four is a live graph with no single order — that is the whole point of this lesson.'

Codebase tour: where this lives in ClickHouse's source

Codebase Tour

  1. src/Parsers/ASTSelectQuery.h

    The parsed shape of a SELECT: an ASTSelectQuery node with children for the select list, FROM, WHERE, GROUP BY, and the rest. Pure grammar — nothing here has been checked against a real table.

  2. src/Analyzer/QueryTreeBuilder.h / .cpp

    buildQueryTree(ASTPtr query, ContextPtr context) — the function that turns an AST into an unresolved QueryTreeNodePtr. Start here if you want to see, in code, the exact moment a query stops being "just grammar."

  3. src/Analyzer/QueryTreePassManager.h / .cpp and src/Analyzer/Passes/

    Where the query tree actually gets resolved: identifier resolution, type inference, alias substitution, and query-tree-level optimizations all run here as a sequence of passes over the tree QueryTreeBuilder produced. EXPLAIN QUERY TREE's dump_passes setting shows you which passes ran.

  4. src/Planner/Planner.h / .cpp

    Takes a resolved QueryTreeNodePtr and builds the QueryPlangetQueryPlan() returns the tree EXPLAIN (plain, or EXPLAIN PLAN) shows you. This is the class that turns "what the query means" into "what steps will run, in what order."

  5. src/Processors/QueryPlan/QueryPlan.h, IQueryPlanStep.h, and concrete steps (ReadFromMergeTree.h, FilterStep.h, AggregatingStep.h, SortingStep.h, LimitStep.h)

    QueryPlan holds the step tree and exposes buildQueryPipeline(). Every concrete step implements IQueryPlanStep::updatePipeline() — the method that actually creates processors and wires them into a pipeline. Open ReadFromMergeTree.h specifically if you want to see where parallel read-stream count gets decided.

  6. src/Processors/IProcessor.h

    The processor state machine every source, transform, and sink implements: input/output ports, and a prepare() method returning NeedData, PortFull, Ready, Async, Finished, or UpdatePipeline. The file's own header comment is worth reading in full — it defines source, sink, simple transform, accumulating transform, and resize entirely in these terms.

  7. src/Processors/Executors/PipelineExecutor.h / .cpp

    execute(size_t num_threads, bool concurrency_control) — the multi-threaded loop that actually drives the graph, calling prepare()/work() on whichever processors are ready until the whole pipeline reports Finished. Backed by ExecutingGraph, in the same directory, which tracks every processor's live status.

  8. src/Interpreters/InterpreterSelectQueryAnalyzer.h / .cpp and InterpreterFactory.cpp

    The entry point that ties every stage above together for a SELECT: constructed from an ASTPtr, it builds (or accepts) a query tree, hands it to Planner, and exposes the resulting plan. InterpreterFactory is where a parsed AST gets routed to the right interpreter class for its query type in the first place.

Reading a real EXPLAIN PIPELINE end to end

Here's a captured, real EXPLAIN PIPELINE run against a MergeTree table (uk_price_paid_simple, on a 59-core test server), reading from the bottom up — the same direction EXPLAIN (plan) reads in:

EXPLAIN PIPELINE SELECT max(price) FROM uk.uk_price_paid_simpletext
    ┌─explain───────────────────────────────────────────────────────────────────────────┐
 1. │ (Expression)                                                                      │
 2. │ ExpressionTransform × 59                                                          │
 3. │   (Aggregating)                                                                   │
 4. │   Resize 59 → 59                                                                  │
 5. │     AggregatingTransform × 59                                                     │
 6. │       StrictResize 59 → 59                                                        │
 7. │         (Expression)                                                              │
 8. │         ExpressionTransform × 59                                                  │
 9. │           (ReadFromMergeTree)                                                     │
10. │           MergeTreeSelect(pool: PrefetchedReadPool, algorithm: Thread) × 59 0 → 1 │
    └───────────────────────────────────────────────────────────────────────────────────┘

Line 10 is the bottom of the graph: 59 independent MergeTreeSelect processors, one per processing lane, each reading its own slice of granules. Line 9's (ReadFromMergeTree) in parentheses isn't a processor — it's the plan step that produced everything below it, kept in the output as a label so you can tie the physical processors back to the logical plan they came from. Resize and StrictResize (lines 4 and 6) both redistribute blocks between groups of processing lanes so that later steps stay evenly fed even if the read lanes didn't all pull the same amount of data — the exact difference between the two isn't important for this lesson's purposes; both exist to rebalance data across lanes, not to change what work happens. And the × 59 on nearly every line is max_threads made visible: this query genuinely ran with 59 concurrent copies of ExpressionTransform and AggregatingTransform, each with its own independent partial-aggregation state, all alive at once.

That parallelism isn't guaranteed, though. On the same table, adding a selective WHERE town = 'LONDON' filter — one that only matches 282 of 3,609 granules — drops the read-side count from 59 to 30, with max_threads unchanged at 59: MergeTreeSelect(pool: PrefetchedReadPool, algorithm: Thread) × 30. Nothing in EXPLAIN (plan) shows you that difference — the logical plan for both queries looks the same shape, just with or without a Filter step. Only EXPLAIN PIPELINE's actual × N counts reveal it.

Debugging walkthrough: "the plan looks fine — so which processor is actually slow?"

Symptom. A GROUP BY event_type aggregation over the course's events table (user_id, event_type, event_time) has gotten noticeably slower after a schema change added a dictGet() call — a lookup against a small "campaign metadata" dictionary — to the SELECT list, enriching each row. EXPLAIN (plan) still shows the expected shape: read, filter, an expression step (where the dictGet() call lives), aggregate, sort, limit. Nothing about the plan itself looks wrong.

Step 1 — confirm the plan's shape, then check whether the pipeline is actually parallel. EXPLAIN (plan) confirms the logical steps are what you'd expect. EXPLAIN PIPELINE is the next question, and it's a different one: are there really N copies of each transform running, matching max_threads, or did this query quietly fall into the low-concurrency case the previous section just walked through? If the × N counts are already lower than expected, that's a real, separate finding worth chasing on its own — but assume here they match max_threads, so the parallelism itself isn't the problem.

Step 2 — the plan and the pipeline shape both look right, so look at what each processor actually did. This is exactly what neither EXPLAIN variant can tell you: they show the graph's shape, not how any processor in it behaved on a specific run. Enable log_processors_profiles and re-run the query:

Capturing per-processor timing for one querysql
SELECT event_type, count(), dictGet('campaign_metadata', 'name', event_type) AS campaign
FROM events
WHERE event_time >= '2024-01-15 00:00:00'
GROUP BY event_type, campaign
SETTINGS log_processors_profiles = 1;

Step 3 — query system.processors_profile_log for that query_id, and read elapsed_us against the wait columns together, not elapsed_us alone. The table's own documented example demonstrates the exact interpretive pattern: for a query containing sleep(1), the ExpressionTransform doing the sleeping shows elapsed_us over one million, while every processor downstream of it shows input_wait_elapsed_us over one million instead — they weren't slow, they were blocked waiting on the one processor that was. The same logic applies here: a processor with high elapsed_us did real, slow work; a processor with low elapsed_us but high input_wait_elapsed_us was idle, waiting on whoever feeds it; high output_wait_elapsed_us means its own output port was full — its consumer couldn't keep up with it.

A plausible result for this walkthrough's query (illustrative, not a captured real run)text
┌─name─────────────────┬─elapsed_us─┬─input_wait_elapsed_us─┬─output_wait_elapsed_us─┐
│ ExpressionTransform   │     842910 │                  3120 │                  210400 │
│ AggregatingTransform  │      41200 │                798300 │                    1800 │
│ MergeTreeSelect       │       9800 │                  4400 │                  831200 │
└───────────────────────┴────────────┴───────────────────────┴──────────────────────────┘

The ExpressionTransform — the one evaluating dictGet() per row — has by far the highest elapsed_us: it's the one doing genuinely slow work, not merely reporting a symptom of something upstream. MergeTreeSelect's huge output_wait_elapsed_us confirms the direction of the bottleneck: the reader has data ready and is stuck waiting for the ExpressionTransform ahead of it to accept it, exactly the PortFull pattern IProcessor's own documentation describes. AggregatingTransform's high input_wait_elapsed_us says the same thing from the other side — it's starved, waiting on the same slow expression step to hand it anything to aggregate. Three processors, three different symptoms, one actual root cause, and the wait-column pattern is what tells you where the root cause sits without guessing.

Step 4 — confirm the hypothesis by tying the processor back to its plan step. system.processors_profile_log carries plan_step_name and plan_step_description columns specifically so a physical processor can be matched back to the logical step that created it. Filtering to the slow ExpressionTransform's plan_step_name confirms it's the expression step containing the dictGet() call, not some other expression step in the same query — closing the loop between "this processor is slow" and "this is the specific part of the query causing it," which is a per-row external dictionary lookup, not the aggregation or the read.

The plan looks parallel; the pipeline isn't. EXPLAIN (plan) never shows stream counts — it's a logical tree, and "how many copies of this step will actually run" is a physical-pipeline question. A query that reads only a handful of granules can silently run with far fewer streams than max_threads, governed by merge_tree_min_rows_for_concurrent_read and merge_tree_min_bytes_for_concurrent_read. The signature: someone tunes max_threads up expecting more parallelism, sees no change, and assumes the setting is broken — when the actual constraint is how much data the query selected, visible only in EXPLAIN PIPELINE's real × N counts, never in the plan.

Blaming the busy-looking neighbor instead of the actual bottleneck. Sorting system.processors_profile_log by elapsed_us alone finds who did the most work — not necessarily who's making the query slow. A processor waiting on a slow neighbor shows near-zero elapsed_us and a large input_wait_elapsed_us or output_wait_elapsed_us instead; skipping those columns and fixating on whichever processor "did the most" is exactly the misread the debugging walkthrough above was built to avoid, and it's the documented teaching point of processors_profile_log's own official example.

A hash join spilling to disk, visible in the plan if you know the notation. EXPLAIN (plan) with actions = 1 shows a join step's chosen algorithm directly — including Algorithm: SpillingHashJoin(HashJoin) when the build side didn't fit comfortably in memory and ClickHouse fell back to a disk-spilling variant of the hash join hash-join (Module 4) described as the default. The failure signature: a join that used to be fast gets progressively slower as one side's table grows, with no error and no obvious plan change — just this one line in EXPLAIN (plan) quietly switching from HashJoin to SpillingHashJoin(HashJoin), trading join speed for a bounded memory footprint exactly as this course's glossary describes.

Mistaking the Query Tree's rewritten form for a bug in the query. EXPLAIN SYNTAX run_query_tree_passes = 1 (or EXPLAIN QUERY TREE) shows identifiers rewritten into an internal form — __table1.number instead of a.number, in the case of a self-joined system.numbers query — because the Analyzer's resolution passes have already run. Someone unfamiliar with this stage can read that rewritten form as evidence the query itself is malformed or that ClickHouse "changed" their query; it's the expected, correct output of a stage that runs on every query, visible specifically because EXPLAIN QUERY TREE and EXPLAIN SYNTAX are two of the only tools that show post-resolution form at all — plain EXPLAIN (plan) never surfaces it.

Hands-on forensics: a diagnostic checklist for a slow query with a clean-looking plan

  1. Run EXPLAIN (plan) first. Confirm the logical shape — read, filter, aggregate, sort, limit — is what you'd expect. If it isn't, that's a planning-level problem (a missing index, an unexpected full scan) and you're done before reaching for anything below.
  2. Run EXPLAIN PIPELINE next. Check the actual × N counts against max_threads. A lower-than-expected count is a real, separate finding — chase it via the concurrent-read thresholds before assuming the bottleneck is inside any single processor.
  3. If the plan and the pipeline's parallelism both look right, enable SETTINGS log_processors_profiles = 1 and re-run the query.
  4. Query system.processors_profile_log for that query_id, and read elapsed_us next to input_wait_elapsed_us / output_wait_elapsed_us together — never elapsed_us alone. High elapsed_us means genuine work; high wait times mean "blocked on a neighbor," and which wait column is high tells you which direction the block runs.
  5. Match the slow processor's plan_step_name back to the logical plan step that created it, closing the loop between "this specific processor is slow" and "this is the part of the query responsible" — the same join between the physical pipeline and the logical plan EXPLAIN (plan) and EXPLAIN PIPELINE show separately.

Reasoning Prompt

A GROUP BY aggregation over the events table runs with max_threads = 16. EXPLAIN PIPELINE confirms AggregatingTransform × 16 — the pipeline genuinely has 16 parallel lanes. After enabling log_processors_profiles = 1 and querying system.processors_profile_log for this query's query_id, sorted by elapsed_us descending, one AggregatingTransform instance shows an elapsed_us roughly 12× higher than the next-highest one, and most of the other 15 instances show near-zero elapsed_us with high input_wait_elapsed_us. event_type (the GROUP BY key) has thousands of distinct values — plenty of cardinality, nothing obviously skewed about the key itself.

Before reading further: is a low-cardinality or skewed GROUP BY key a plausible explanation here? If not, what's a more likely one, and which specific column in system.processors_profile_log would you check next to confirm it?

Model Answer

A skewed or low-cardinality key isn't a plausible explanation for this symptom, and the scenario's own detail (thousands of distinct event_type values) is a hint pointing away from it. ClickHouse's parallel aggregation doesn't route rows to a specific thread's hash table based on a hash of the GROUP BY key while consuming input — each AggregatingTransform instance gets its own independent, private aggregation state (ManyAggregatedData holds one AggregatedDataVariants per thread) and builds a full local hash table over whatever rows it happened to receive, regardless of which keys those rows contain, and lanes are only rebalanced by data volume (via Resize), never by key value. That means one AggregatingTransform doing 12× the work of its siblings isn't about which keys landed where — it's about how many rows landed where.

The far more likely cause: uneven row distribution across the read streams feeding each aggregation lane. If the table's matching data isn't spread evenly across the granule ranges ReadFromMergeTree assigned to its 16 parallel MergeTreeSelect streams — some parts much larger than others, or the WHERE filter's selectivity varying sharply between them — one read stream ends up pulling far more rows than its peers, and the AggregatingTransform downstream of that specific stream inherits the imbalance directly, exactly the way Resize processors exist to prevent but can't always fully correct.

The check that would confirm it: query system.processors_profile_log for the same query_id again, this time looking at the MergeTreeSelect (or other ReadFromMergeTree-family) source processors specifically, and compare their input_rows / output_rows columns across all 16 instances. One source processor showing roughly 12× the output_rows of its siblings directly confirms read-side data skew as the root cause — and rules out the alternative, that something inside that one AggregatingTransform's per-row work was simply slower for unrelated reasons, which an even row count across sources would have ruled the other way.

Teach It Back

Explain to a colleague who's already comfortable with EXPLAIN (plan) — they can read a plan tree, they know what a query plan is — but has never looked past it: why does ClickHouse need both a query plan and a query pipeline as two separate things, instead of just running the plan directly? Use this lesson's "tree with one order vs. graph with no single order" framing, and explain, in your own words, why that distinction is exactly what makes system.processors_profile_log necessary — why couldn't EXPLAIN alone tell you which processor was actually the bottleneck on a real, specific run?

Summary

Parse, Analyze, and Plan each produce one specific, inspectable data structure: an AST (EXPLAIN AST), a resolved query tree built by the Analyzer's buildQueryTree() and its optimization passes (EXPLAIN QUERY TREE, EXPLAIN SYNTAX), and a query plan built by the Planner class as a tree of IQueryPlanStep nodes (EXPLAIN / EXPLAIN PLAN) — still logical, still untouched by data. "Execute" isn't a fourth stage in that same sequence; QueryPlan::buildQueryPipeline() and each step's updatePipeline() turn the plan into a query pipeline — a graph of IProcessor objects connected by ports, several running concurrently rather than one stage finishing before the next begins, driven by a PipelineExecutor across max_threads worker threads (itself an upper bound, not a guarantee, once merge_tree_min_rows_for_concurrent_read and merge_tree_min_bytes_for_concurrent_read are in play). EXPLAIN PIPELINE shows that graph directly, including × N replication and Resize processors that rebalance uneven lanes. Neither EXPLAIN variant can tell you how any specific processor behaved on one real run, though — that's system.processors_profile_log's job, read by comparing elapsed_us (genuine work) against input_wait_elapsed_us and output_wait_elapsed_us (blocked on a neighbor, in one direction or the other) across the processors in a single query_id, then tying the slow one back to its originating plan step via plan_step_name. That's the diagnostic chain this lesson built end to end: plan tree, to pipeline graph, to per-processor timing, to root cause — the layer underneath EXPLAIN's default output, for the days a clean-looking plan isn't the whole story.