ClickHouse Rewrites the Query Before It Plans It
Between the parser and the planner, ClickHouse inlines aliases into a typed query tree, runs 47 ordered rewrite passes over it, prunes projection columns and rewrites count(*) into a metadata read.
TL;DR: The query you type is not the query that gets planned. ClickHouse first turns the AST into a typed query tree, inlining every alias as it goes, then runs 47 ordered rewrite passes over that tree — pruning unused columns, normalising
count(NULL)tocount(), collapsingifchains — and finally, in the planner, recognises thatSELECT count() FROM tnever needs to read a single row and answers it from the parts’ metadata.
A query arrives at the server as an AST: a tree of parser nodes with
ASTIdentifier leaves and no type information at all. The plan that actually
runs is something else entirely — a typed ActionsDAG per expression, a tree
of QueryPlanSteps, and a set of processors reading parts in a particular
order.
The gap between the two is closed by a pipeline of rewrites. Most of them
happen on the query tree, and the last and most spectacular one happens in the
planner, where a count() query is not executed at all.
Stage 1: aliases are inlined
SQL lets you write
SELECT a + b AS s, s * 2 FROM t;and the standard says the second column may reference the first. ClickHouse resolves this by substituting the aliased expression wherever the alias appears — but there are two different mechanisms for it, depending on which pipeline you are in.
The analyzer: the alias is a cloned expression node
In the current pipeline the query is a tree of typed nodes, and an alias is an entry in a per-scope map from name to expression node:
// src/Analyzer/Resolve/ScopeAliases.h:10
struct ScopeAliases
{
/// Alias name to query expression node
std::unordered_map<std::string, QueryTreeNodePtr> alias_name_to_expression_node;
/// Alias name to lambda node
std::unordered_map<std::string, QueryTreeNodePtr> alias_name_to_lambda_node;
/// Alias name to table expression node
std::unordered_map<std::string, QueryTreeNodePtr> alias_name_to_table_expression_node;
/// Nodes with duplicated aliases
QueryTreeNodes nodes_with_duplicated_aliases;
/// Cloned resolved expressions with aliases that must be removed
QueryTreeNodes node_to_remove_aliases;
UnorderedMapWithMemoryTracking<std::string, DataTypePtr> alias_name_to_expression_type;The map is filled by a visitor that clones every aliased expression, so the stored node is a snapshot of the expression, not a reference to a node that will keep changing:
// src/Analyzer/Resolve/QueryExpressionsAliasVisitor.h:97
const auto & alias = node->getAlias();
auto cloned_alias_node = node->clone();
switch (node_type)
{
case QueryTreeNodeType::LAMBDA:
{
auto [_, inserted] = aliases.alias_name_to_lambda_node.emplace(alias, cloned_alias_node);
if (!inserted || aliases.alias_name_to_expression_node.contains(alias))
addDuplicatingAlias(cloned_alias_node);
break;
}There are three namespaces — expression, lambda, table expression — because
SELECT 1 AS x, arrayMap(x -> x + 2, [1,2,3]) must not let the lambda
parameter x bind to the projection alias x.
When an identifier is resolved and it turns out to be an alias, the alias node is cloned again and resolved in place of the identifier:
// src/Analyzer/Resolve/QueryAnalyzer.cpp:1462
auto node_type = alias_node->getNodeType();
if (!identifier_lookup.isTableExpressionLookup())
{
alias_node = alias_node->clone();
scope_to_resolve_alias_expression->aliases.node_to_remove_aliases.push_back(alias_node);
}The clone is essential: the same alias can be referenced from several places,
and each reference needs its own resolved copy (a ColumnNode bound to a
specific table expression, a function node with a resolved overload). The
documentation block above the function spells out the whole algorithm:
// src/Analyzer/Resolve/QueryAnalyzer.cpp:1400
* Below cases should work:
* Example:
* SELECT id AS id FROM test_table;
* SELECT value.value1 AS value FROM test_table;
* SELECT (id + 1) AS id FROM test_table;
* SELECT (1 + (1 + id)) AS id FROM test_table;
*
* Below cases should throw cyclic aliases exception:
* SELECT (id + b) AS id, id as b FROM test_table;
* SELECT (1 + b + 1 + id) AS id, b as c, id as b FROM test_table;
*
* 3. Depending on IdentifierLookupContext select IdentifierResolveScope to resolve aliased expression.
* 4. Clone query tree of aliased expression (do not clone table expression from join tree).
* 5. Resolve the node. It is important in case of compound expressions.
* Example: SELECT value.a, cast('(1)', 'Tuple(a UInt64)') AS value;The cycle detection is not a separate pass — it falls out of the resolution stack. If the alias currently being resolved is on the stack, resolving it again would be a cycle:
// src/Analyzer/Resolve/QueryAnalyzer.cpp:1470
/* Do not use alias to resolve identifier when it's part of aliased expression. This is required to support queries like:
* 1. SELECT dummy + 1 AS dummy
* 2. SELECT avg(a) OVER () AS a, id FROM test
*/
if (scope.expressions_in_resolve_process_stack.getExpressionWithAlias(identifier_bind_part) != nullptr)
{
/* This is an important fallback in the identifier resolution. In the case of transitive aliases,
* we may end up in the situation when we try to resolve the same expression, but it's not a cycle.
* Example: WITH path('clickhouse.com/a/b/c') AS x SELECT x AS path;
* Here, identifier `x` would be resolved one time as a projection column and one time during `path`
* function lookup. The second lookup must fail here to break the alias cycle.
*/
return {};
}The AST normaliser: the older way
There is a second, older implementation of the same idea, still used by the legacy interpreter’s tree rewriter and by the rewriting of user-defined SQL functions. It works on the AST and substitutes aliases by cloning the aliased AST node over the identifier:
// src/Interpreters/QueryNormalizer.cpp:106
if (it_alias != data.aliases.end() && current_alias != node.name())
{
if (!IdentifierSemantic::canBeAlias(node))
return;
/// We are alias for other column (node.name), but we are alias by
/// ourselves to some other column
const auto & alias_node = it_alias->second;
String our_alias_or_name = alias_node->getAliasOrColumnName();
std::optional<String> our_name = IdentifierSemantic::getColumnName(alias_node);
String node_alias = ast->tryGetAlias();
if (current_asts.contains(alias_node.get()) /// We have loop of multiple aliases
|| (node.name() == our_alias_or_name && our_name && node_alias == *our_name)) /// Our alias points to node.name, direct loop
throw Exception(ErrorCodes::CYCLIC_ALIASES, "Cyclic aliases");The current_asts set is the cycle detector: SELECT a AS b, b AS a is a
CYCLIC_ALIASES error rather than a stack overflow. The RestoreAliasOnExitScope
guard restores the previous alias name when the visitor leaves a subtree, so
an alias defined inside a subquery does not leak out of it.
The substitution has to preserve the alias of the substituted node, which is why there are two nearly identical branches:
// src/Interpreters/QueryNormalizer.cpp:124
/// Let's replace it with the corresponding tree node.
if (!node_alias.empty() && node_alias != our_alias_or_name)
{
/// Avoid infinite recursion here
auto opt_name = IdentifierSemantic::getColumnName(alias_node);
bool is_cycle = opt_name && *opt_name == node.name();
if (!is_cycle)
{
/// In a construct like "a AS b", where a is an alias, you must set alias b to the result of substituting alias a.
/// Check size of the alias before cloning too large alias AST
alias_node->checkSize(data.settings.max_expanded_ast_elements);
ast = alias_node->clone();
ast->setAlias(node_alias);checkSize is the guard against the quadratic blow-up: an alias that is
cloned into a thousand places is a thousand AST nodes, so the size of the
cloned subtree is checked against max_expanded_ast_elements before the
clone.
Two subtleties worth noting:
Lambdas hide their parameters.
SELECT 1 AS x, arrayMap(x -> x + 2, [1,2,3])must not substitute the outerxinto the lambda body. The normaliser extracts the lambda’s own parameter names and removes them from the alias map for the duration of the lambda body:cpp// src/Interpreters/QueryNormalizer.cpp:240 /// For lambda functions we need to avoid replacing lambda parameters with external aliases, for example, /// Select 1 as x, arrayMap(x -> x + 2, [1, 2, 3]) /// shouldn't be replaced with Select 1 as x, arrayMap(x -> **(1 as x)** + 2, [1, 2, 3]) Aliases extracted_aliases; if (func_node->name == "lambda") { Names lambda_aliases = RequiredSourceColumnsMatcher::extractNamesFromLambda(*func_node); for (const auto & name : lambda_aliases) { auto it = data.aliases.find(name); if (it != data.aliases.end()) { extracted_aliases.insert(data.aliases.extract(it)); } } }A column name beats an alias if you ask for it. The setting
prefer_column_name_to_aliasletsSELECT x FROM (SELECT 1 AS x)resolve to the columnxrather than the alias.
The normaliser also memoises: finished_asts maps an already-processed AST to
its rewritten form, so an expression that appears in a thousand places is
rewritten once and shared:
// src/Interpreters/QueryNormalizer.cpp:341
finished_asts[initial_ast] = ast;Stage 2: the query tree and its 47 passes
The new pipeline does not use the AST as its working representation. The
parser’s output is converted into a query tree — typed nodes
(ColumnNode, FunctionNode, ConstantNode, LambdaNode,
WindowFunctionNode, QueryNode, JoinNode, …) — and every node in it
carries its resolved data type.
The rewriting is then done by a pipeline of passes, and the order of the passes is part of the design:
// src/Analyzer/QueryTreePassManager.cpp:269
void addQueryTreePasses(QueryTreePassManager & manager, bool only_analyze, bool positional_arguments_already_resolved)
{
manager.addPass(std::make_unique<QueryAnalysisPass>(only_analyze, positional_arguments_already_resolved));
manager.addPass(std::make_unique<GroupingFunctionsResolvePass>());
manager.addPass(std::make_unique<AutoFinalOnQueryPass>());
/// This pass should be run for the secondary queries
/// to ensure that the only required columns are read from VIEWs on the shards.
manager.addPass(std::make_unique<RemoveUnusedProjectionColumnsPass>());
manager.addPass(std::make_unique<PruneArrayJoinColumnsPass>());
manager.addPass(std::make_unique<DictGetTupleElementPass>());
manager.addPass(std::make_unique<ConvertEmptyStringComparisonToFunctionPass>());
manager.addPass(std::make_unique<FunctionToSubcolumnsPass>());
manager.addPass(std::make_unique<ConvertLogicalExpressionToCNFPass>());
manager.addPass(std::make_unique<RegexpFunctionRewritePass>());
manager.addPass(std::make_unique<RewriteSumFunctionWithSumAndCountPass>());
manager.addPass(std::make_unique<CountDistinctPass>());
manager.addPass(std::make_unique<UniqToCountPass>());
manager.addPass(std::make_unique<RewriteArrayExistsToHasPass>());
manager.addPass(std::make_unique<RewriteArrayFilterLengthToArrayCountPass>());
manager.addPass(std::make_unique<RewriteHasToInPass>());
manager.addPass(std::make_unique<NormalizeCountVariantsPass>());The list continues for another forty passes — AggregateFunctionOfGroupByKeysPass,
OptimizeGroupByFunctionKeysPass, MultiIfToIfPass, IfConstantConditionPass,
OrderByTupleEliminationPass, FuseFunctionsPass, LogicalExpressionOptimizerPass,
OptimizeTrivialGroupByLimitPass — and the ordering constraints are spelled
out in the comments:
// src/Analyzer/QueryTreePassManager.cpp:306
/// These passes can rewrite a predicate so that it depends on base columns instead of
/// the derived expression that appears in `GROUP BY`. They must run before
/// `OptimizeGroupByFunctionKeysPass` and `OptimizeGroupByInjectiveFunctionsPass`,
/// because those passes remove grouping keys based on the current expression tree.
/// For example, `SELECT toYear(d), toYear(d) = 2024, count() FROM ... GROUP BY ALL`:
/// `OptimizeGroupByFunctionKeysPass` can keep only `toYear(d)` as the grouping key,
/// and if `OptimizeDateOrDateTimeConverterWithPreimagePass` runs later it rewrites
/// `toYear(d) = 2024` to a range on raw `d`. After aggregation only `toYear(d)` and
/// `count()` remain, so the final projection would need `d` that is no longer present.
manager.addPass(std::make_unique<InverseDictionaryLookupPass>());
manager.addPass(std::make_unique<OptimizeDateOrDateTimeConverterWithPreimagePass>());
manager.addPass(std::make_unique<ComparisonTupleEliminationPass>());That is a dependency between passes, documented in prose right where the dependency is enforced. There are about a dozen such comments in this one function.
The manager runs them in order and, in debug builds, validates the tree after every pass:
// src/Analyzer/QueryTreePassManager.cpp:195
void QueryTreePassManager::run(QueryTreeNodePtr & query_tree_node)
{
auto current_context = getContext();
size_t passes_size = passes.size();
for (size_t i = 0; i < passes_size; ++i)
{
passes[i]->run(query_tree_node, current_context);
#if defined(DEBUG_OR_SANITIZER_BUILD)
ValidationChecker(passes[i]->getName()).visit(query_tree_node);
#endif
}
}and the validator is not a syntax check — it re-checks that every function’s declared argument types still match the types of the arguments it actually has, which is the invariant that a sloppy rewrite pass would break:
// src/Analyzer/QueryTreePassManager.cpp:159
if (!expected_argument_type->equals(*actual_argument_type))
{
/// Aggregate functions remove low cardinality for their argument types
if ((function->isAggregateFunction() || function->isWindowFunction()) &&
expected_argument_type->equals(*recursiveRemoveLowCardinality(actual_argument_type)))
continue;
throw Exception(ErrorCodes::LOGICAL_ERROR,
"Function {} expects argument {} to have {} type but receives {} after running {} pass",
function->toAST()->formatForErrorMessage(),
i + 1,
expected_argument_type->getName(),
actual_argument_type->getName(),
pass_name);
}That is a lot of machinery for a pipeline, and it is worth remembering that
the first pass is the one that does the actual name resolution: it turns
ASTIdentifier leaves into ColumnNodes bound to a specific table
expression, and ASTFunction into FunctionNodes with a resolved overload
and a result type.
Stage 3: pruning the projection of a subquery
The single most valuable pass for a distributed query is
RemoveUnusedProjectionColumnsPass, and it runs fourth — before almost
everything else — for exactly the reason its comment gives:
// src/Analyzer/QueryTreePassManager.cpp:274
/// This pass should be run for the secondary queries
/// to ensure that the only required columns are read from VIEWs on the shards.
manager.addPass(std::make_unique<RemoveUnusedProjectionColumnsPass>());The mechanism is a bottom-up collection of used column names per subquery, followed by a rewrite of the subquery’s projection. The collection is a traversal of the query tree that records, for every query/union node used as a table expression, the names of the columns that the outer query actually reads:
// src/Analyzer/Passes/RemoveUnusedProjectionColumnsPass.cpp:115
/// Collect information about what columns are used in the query.
traverseQueryTree(node_to_visit,
[&subqueries_nodes_to_visit, &node_to_used_columns](
const QueryTreeNodePtr & /*parent*/,
const QueryTreeNodePtr & child
)
{
if (isQueryOrUnionNode(child))
{
subqueries_nodes_to_visit.insert(child);
auto * query_node = child->as<QueryNode>();
auto * union_node = child->as<UnionNode>();
const auto & correlated_columns = query_node != nullptr ? query_node->getCorrelatedColumns() : union_node->getCorrelatedColumns();
for (const auto & correlated_column : correlated_columns)
{
auto * column_node = correlated_column->as<ColumnNode>();
auto column_source_node = column_node->getColumnSource();
auto column_source_node_type = column_source_node->getNodeType();
if (column_source_node_type == QueryTreeNodeType::QUERY || column_source_node_type == QueryTreeNodeType::UNION)
{
if (auto it = node_to_used_columns.find(column_source_node); it != node_to_used_columns.end())
it->second.insert(column_node->getColumnName());
}
}
return false;
}
return true;
},and the pruning itself:
// src/Analyzer/Passes/RemoveUnusedProjectionColumnsPass.cpp:167
/// Pass information about used columns to subqueries and remove unused projection columns
for (auto & [query_or_union_node, used_columns] : node_to_used_columns)
{
/// can't remove columns from distinct, see example - 03023_remove_unused_column_distinct.sql
if (auto * query_node = query_or_union_node->as<QueryNode>())
{
if (query_node->isDistinct())
continue;
}
auto used_projection_indexes = convertUsedColumnNamesToUsedProjectionIndexes(query_or_union_node, used_columns);
updateUsedProjectionIndexes(query_or_union_node, used_projection_indexes);
/// Keep at least 1 column if used projection columns are empty
if (used_projection_indexes.empty())
used_projection_indexes.insert(0);Two details show how careful this has to be:
DISTINCTblocks the pruning, becauseDISTINCTdeduplicates over the whole row, so dropping a column changes the number of groups.Aggregate functions and
arrayJoinin the projection block the pruning of that column, even if nobody reads it, because they change the number of rows:cpp// src/Analyzer/Passes/RemoveUnusedProjectionColumnsPass.cpp:79 for (size_t i = 0; i < projection_nodes_size; ++i) { const auto & projection_node = projection_nodes[i]; if ((!query_node.hasGroupBy() && hasAggregateFunctionNodes(projection_node)) || hasFunctionNode(projection_node, "arrayJoin")) used_projection_columns_indexes.insert(i); }
The payoff is a distributed query like
SELECT a FROM (SELECT a, b, c FROM t) SETTINGS distributed_group_by_no_merge = 1;where the subquery is rewritten to SELECT a FROM t before the plan is sent
to the shards, so b and c are never read, never serialised over the
network, and never joined.
Stage 4: normalising the aggregates
Some passes change the meaning of the query only in the strictest sense.
NormalizeCountVariantsPass rewrites count(NULL) to count() and
sum(1) to count():
// src/Analyzer/Passes/NormalizeCountVariantsPass.cpp:30
void enterImpl(QueryTreeNodePtr & node)
{
if (!getSettings()[Setting::optimize_normalize_count_variants])
return;
auto * function_node = node->as<FunctionNode>();
if (!function_node || !function_node->isAggregateFunction() || (function_node->getFunctionName() != "count" && function_node->getFunctionName() != "sum"))
return;
if (function_node->getArguments().getNodes().size() != 1)
return;
/// forbid the optimization if return value of sum() and count() differs:
/// count() returns only UInt64 type, while sum() could return Nullable().
if (!function_node->getResultType()->equals(DataTypeUInt64()))
return;and then:
// src/Analyzer/Passes/NormalizeCountVariantsPass.cpp:54
if (function_node->getFunctionName() == "count" && !first_argument_constant_literal.isNull())
{
function_node->getArguments().getNodes().clear();
resolveAggregateFunctionNodeByName(*function_node, "count");
}
else if (function_node->getFunctionName() == "sum" &&
first_argument_constant_literal.getType() == Field::Types::UInt64 &&
first_argument_constant_literal.safeGet<UInt64>() == 1)
{
function_node->getArguments().getNodes().clear();
resolveAggregateFunctionNodeByName(*function_node, "count");
}Why does this matter? Because count() and count(expr) are different
aggregates with different implementations: count() has a specialised
“count the rows” fast path, and count(expr) has to evaluate the expression
on every row and check for NULL. count(NULL) is count() semantically
(the argument is always NULL, so nothing is counted), and sum(1) is
count() too. Rewriting them lets the trivial-count optimisation fire on
queries the user wrote as count(NULL).
Stage 5: the trivial count — a query that reads no data
The last rewrite is the most dramatic, and it happens in the planner rather than in the pass pipeline, because it replaces the plan, not the tree.
// src/Core/Settings.cpp:5792
DECLARE(Bool, optimize_trivial_count_query, true, R"(
Enables or disables the optimization to trivial query `SELECT count() FROM table` using metadata from MergeTree. If you need to use row-level security, disable this setting.The planner asks the storage whether the query is a trivial count. The preconditions are checked in one place:
// src/Planner/PlannerJoinTree.cpp:576
QueryTreeNodePtr getTrivialCountAggregate(
const SelectQueryInfo & select_query_info,
const TableNode * table_node,
const TableFunctionNode * table_function_node,
const QueryTreeNodePtr & query_tree,
const ContextPtr & query_context)
{
const auto & settings = query_context->getSettingsRef();
if (!settings[Setting::optimize_trivial_count_query])
return nullptr;
/// The rewrite produces a `ReadFromPreparedSource` leaf that the Cascades optimizer cannot
/// clone; a distributed plan counts the rows with a distributed read instead.
if (settings[Setting::make_distributed_plan] && settings[Setting::enable_cascades_optimizer])
return nullptr;
const auto & storage = table_node ? table_node->getStorage() : table_function_node->getStorage();
/// `totalRows` and the column stats describe the live table, not the snapshot pinned for this query.
if (query_context->getPinnedStorageSnapshot(storage->getStorageID().uuid))
return nullptr;
if (getEffectiveRowPolicyFilter(*storage, query_context))
return nullptr;
if (select_query_info.additional_filter_ast)
return nullptr;Each of these exclusions is a correctness rule, not a performance heuristic:
- Row-level security — the row policy filters rows, so the metadata count is wrong. Hence the setting’s own advice: “If you need to use row-level security, disable this setting.”
- A pinned storage snapshot —
totalRows()describes the live table, not the snapshot the query is pinned to. - An additional filter — anything that filters rows invalidates the metadata count.
GROUP BYorPREWHERE— the count is no longer “the number of rows”.
and then the aggregate itself must be exactly one count:
// src/Planner/PlannerJoinTree.cpp:615
QueryTreeNodes aggregates = collectAggregateFunctionNodes(query_tree);
if (aggregates.size() != 1)
return nullptr;
const auto & function_node = aggregates.front()->as<const FunctionNode &>();
chassert(function_node.getAggregateFunction() != nullptr);
if (!typeid_cast<const AggregateFunctionCount *>(function_node.getAggregateFunction().get()))
return nullptr;
if (!storage->supportsTrivialCountOptimization(
table_node ? table_node->getStorageSnapshot() : table_function_node->getStorageSnapshot(), query_context))
return nullptr;The rewrite itself replaces the entire plan with a one-row source:
// src/Planner/PlannerJoinTree.cpp:674
/// Get number of rows.
/// MergeTree maintains total count for all parts in Active state and it simply returns that number for trivial
/// `select count() from table` query. But if we have current transaction, then we should return number of rows in current snapshot
/// (that may include parts in Outdated state), so the totalRows for transactional case should return the number of rows visible for
/// the current transaction.
std::optional<UInt64> num_rows = storage->totalRows(query_context);
if (!num_rows)
return false;and:
// src/Planner/PlannerJoinTree.cpp:712
auto block_with_count = std::make_shared<const Block>(Block{
{createSingleCountStateColumn(aggregate_function, num_rows.value()),
std::make_shared<DataTypeAggregateFunction>(aggregate_function, aggregate_function->getArgumentTypes(), Array{}),
trivial_count_column_name}});
auto source = std::make_shared<SourceFromSingleChunk>(block_with_count);
auto prepared_count = std::make_unique<ReadFromPreparedSource>(Pipe(std::move(source)));
prepared_count->setStepDescription("Optimized trivial count");
query_plan.addStep(std::move(prepared_count));Note the shape: the aggregate function’s state is built directly
(createSingleCountStateColumn makes a state that already holds the final
count), so the plan does not even need an aggregation step. The whole query
becomes “read one row from a one-chunk source”.
There is one more trap the code handles explicitly — the distributed and parallel-replica cases, where every replica would answer the same total and the result would be multiplied by the number of replicas:
// src/Planner/PlannerJoinTree.cpp:664
/// Do not apply trivial count optimization if we're the follower. Because if every follower blindly returns totalRows(),
/// the result will be R times the actual count of rows, where R is the number of replicas.
/// It's important to make this check after we've set the `select_query_info.optimize_trivial_count` flag, so that storage could apply
/// some optimization during the read phase.
/// TODO: We could still avoid reading data for MergeTree on the followers: we could read the parts metadata for the set of parts
/// we were handed out by the initiator. This would require consuming the `select_query_info.optimize_trivial_count` during the read
/// phase and acting accordingly.
if (query_context->canUseParallelReplicasOnFollower())
return false;The ordering in the comment is deliberate: the flag optimize_trivial_count
is set before the check, because some storages (e.g. StorageURL) act on
the flag during the read phase, and the check must not prevent that.
The sparsity-filter variant
A newer, more interesting extension handles SELECT count() FROM t WHERE col <op> const, where the predicate partitions the rows into “rows where col
equals its default value” and “rows where it does not”. MergeTree keeps
per-column statistics for exactly that:
// src/Planner/PlannerJoinTree.cpp:729
bool applyTrivialCountWithSparsityFilterIfPossible(and the count comes from the statistics:
// src/Planner/PlannerJoinTree.cpp:787
UInt64 num_rows = (classified->predicate_class == SparsityPredicateClass::MatchesDefault)
? stats->num_defaults
: (stats->num_rows - stats->num_defaults);So SELECT count() FROM t WHERE sparse_col = 0 can be answered from
(num_rows, num_defaults) without reading a single row — as long as the
predicate is exactly a comparison against a constant that equals the column’s
default value, and the aggregate is a zero-argument count():
// src/Planner/PlannerJoinTree.cpp:758
/// Only zero-argument `count()` / `count(*)` counts rows. `count(expr)` counts non-null
/// argument values, which the rewrite (which seeds the state with a row count derived from
/// the per-column `num_defaults`) does not preserve for Nullable/expression counts.
const auto & function_node = count_aggregate->as<const FunctionNode &>();
if (!function_node.getArguments().getNodes().empty())
return false;The picture
SQL text
|
| QueryTreeBuilder: AST -> typed query tree
| QueryAnalysisPass: names and functions resolved,
| aliases inlined as cloned expression nodes
v
query tree
|
| ~40 passes, in a fixed order:
| resolve names & functions
| RemoveUnusedProjectionColumnsPass (prune subquery projections)
| NormalizeCountVariantsPass (count(NULL) -> count())
| MultiIfToIfPass / IfConstantConditionPass / ...
| LogicalExpressionOptimizerPass (CNF, constant folding)
v
resolved query tree
|
| Planner: build the plan
| PlannerJoinTree: is this a trivial count?
| yes -> ReadFromPreparedSource(one chunk with the count)
| no -> real plan
v
QueryPlanTrade-offs
What the rewrite pipeline buys:
- Correctness by construction for the common cases.
count(NULL)andcount()are the same thing, but they are different aggregate functions; the pass pipeline makes them the same. - A distributed query reads less on every shard. Column pruning happens before the plan is serialised to the shards, so the shards never even mention the pruned columns.
- A
count()query costs O(parts), not O(rows). The metadata path reads one number per active part. - Validation as a safety net. The
ValidationCheckerruns after every pass in debug builds and re-checks type consistency of every function in the tree. For a pipeline of 47 independent rewrites, this is the only practical way to know that pass 37 did not break pass 12’s invariant.
What it costs:
- The pass order is load-bearing. A dozen comments in
addQueryTreePassesexist purely to explain why pass n must run before pass m. Changing the order is a semantic change, and nothing in the code enforces it — the ordering is a convention documented in prose. - Alias inlining duplicates AST. Cloning an aliased expression into every
use site is O(size of the expansion).
max_expanded_ast_elementsexists precisely because this can explode, and the check happens before the clone, so a malicious query is rejected rather than allowed to allocate. - The trivial count has a long list of preconditions, and each one is a potential bug if a new storage engine or a new setting breaks the assumption. The exclusions are conservative, but they are spread over three functions and two files.
- Metadata counts can be stale.
totalRows()is the count of parts in theActivestate at the time of the query; a concurrent merge or an ongoing transaction makes it approximate relative to a full scan. The code handles the transactional case by asking the storage for a snapshot-aware count.
The one thing to remember: the plan you get is not the query you wrote. Every stage between the parser and the planner is a chance to do less work — and the biggest wins (column pruning, trivial count) happen before the plan is built, when the shape of the data is still cheap to change.
The part people get wrong: SELECT count() FROM t with a WHERE clause
is not the trivial count — the metadata only knows the row count, not how
many rows pass an arbitrary predicate. It is answered from the metadata only
when the predicate is one the storage can evaluate from its own statistics
(the sparsity filter), or when there is no predicate at all.
What to go do about it: run EXPLAIN PLAN and EXPLAIN PIPELINE on a
query and compare them with the query you wrote. The plan shows what will
actually run. When it differs from the query in an interesting way — a
projection with fewer columns, an Optimized trivial count step, a read that
is not a full scan — the difference is one of the rewrites above.