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) to count(), collapsing if chains — and finally, in the planner, recognises that SELECT count() FROM t never 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

sql
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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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 outer x into 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_alias lets SELECT x FROM (SELECT 1 AS x) resolve to the column x rather 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

  • DISTINCT blocks the pruning, because DISTINCT deduplicates over the whole row, so dropping a column changes the number of groups.

  • Aggregate functions and arrayJoin in 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

sql
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():

cpp
// 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:

cpp
// 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.

cpp
// 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:

cpp
// 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 BY or PREWHERE — the count is no longer “the number of rows”.

and then the aggregate itself must be exactly one count:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// src/Planner/PlannerJoinTree.cpp:729
bool applyTrivialCountWithSparsityFilterIfPossible(

and the count comes from the statistics:

cpp
// 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():

cpp
// 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

text
   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
   QueryPlan

Trade-offs

What the rewrite pipeline buys:

  • Correctness by construction for the common cases. count(NULL) and count() 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 ValidationChecker runs 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 addQueryTreePasses exist 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_elements exists 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 the Active state 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.

ClickhouseInternalsQuery planOptimization