swecrets

Reading the ClickHouse Source: A Guided Tour

Internals & Failure — Rung 530 min read

Learning Objectives

  • Navigate from a raw SQL query to the specific ClickHouse source directory responsible for each stage of its execution — parsing, interpreting, planning, pipeline execution, storage — using the codebase's own entry-point functions and classes as landmarks, without a memorized file map
  • Read and explain the mental model behind ClickHouse's Processor framework (IProcessor, ports, pipelines) well enough to predict where a specific operation would live in source
  • Diagnose a "too many parts" or merge-not-keeping-up symptom by tracing it from system tables to the exact source-level heuristic responsible for the observed behavior
  • Form and test a hypothesis about an unfamiliar ClickHouse behavior by locating the relevant interface and reading its implementation, rather than accepting a secondhand summary of it

Why This Matters

Every lesson before this one in the course answered a question you could eventually find answered somewhere — official docs, a well-written blog post, this course itself. Production debugging doesn't work that way. The error message is unfamiliar, the setting's docstring is one sentence, the blog post you find is about a different ClickHouse version, and the behavior you're staring at genuinely isn't documented anywhere except the code that implements it. At that point the only durable skill is being able to open the ClickHouse source, find the file actually responsible for what you're seeing, and read it — not paste the error into an AI assistant and accept whichever plausible-sounding explanation comes back, unverified against the thing that would actually confirm or refute it. This lesson isn't about memorizing ClickHouse's ~500,000-line C++ codebase. It's about the much smaller thing that makes a 500,000-line codebase navigable: knowing that the code's own shape mirrors a query's own journey, so you always know roughly where to start looking.

The mental model: the codebase mirrors the query's journey

Open github.com/ClickHouse/ClickHouse and the top level of src/ looks intimidating — dozens of directories, no obvious reading order. The mental model that makes it navigable: almost every one of those directories corresponds to one stage a SQL query passes through, in order, from the moment it's typed into a client to the moment rows come back. If you can hold the stages of a query's journey in your head, you already know which directory a given question belongs in, before you've read a single file inside it.

The stages, at the level this lesson cares about:

  1. A query arrives at the server. Something has to receive bytes off a socket and recognize "here's a query." (src/Server, programs/server)
  2. The query text becomes a structured tree. SQL is a string; nothing downstream wants to work with a string. (src/Parsers)
  3. Something decides what kind of query this is, and dispatches to code that knows how to run that kind. A SELECT is handled completely differently from an INSERT or an ALTER. (src/Interpreters)
  4. For SELECT specifically, the query tree gets analyzed and turned into a plan — a tree of logical steps: read this table, filter these rows, aggregate, sort, limit. (src/Analyzer, src/Planner, src/Processors/QueryPlan)
  5. The logical plan becomes a physical pipeline — a graph of small, composable units that actually pull, transform, and push blocks of data. (src/Processors, src/QueryPipeline)
  6. Something runs that pipeline, scheduling its units across threads until data comes out the other end. (src/Processors/Executors)
  7. Underneath all of it, something actually stores and retrieves the data — for this course, almost always a MergeTree table. (src/Storages, src/Storages/MergeTree)

Every question you'll ever have about ClickHouse's behavior is, underneath, a question about one of these seven stages. "Why is my INSERT slow" is stage 7. "Why did my JOIN run out of memory" is stage 5 (specifically, one particular kind of processing unit). "Why does EXPLAIN show this plan and not that one" is stage 4. Before opening any file, name the stage — it cuts the codebase from "500,000 lines" to "the one directory that actually matters for this question."

Codebase Tour

  1. programs/server/Server.cpp, src/Server/TCPHandler.cpp

    Stage 1. programs/server/Server.cpp is the actual main()-adjacent entry point for the clickhouse-server binary — it wires up configuration, starts listening sockets, and hands connections off to protocol-specific handlers. For a query sent over the native protocol (what clickhouse-client uses by default), src/Server/TCPHandler.cpp is the class that owns one client connection and reads the query off the wire — TCPHandler itself inherits Poco's TCPServerConnection, the standard one-instance-per-accepted-connection pattern. src/Server/HTTPHandler.cpp is the equivalent for HTTP-interface queries. If you're debugging something protocol-specific — connection handling, an HTTP-only setting, a native-protocol quirk — this is where to start. For almost everything else, this is a stage you note and move past.

  2. src/Parsers/parseQuery.cpp, src/Parsers/IAST.h

    Stage 2. parseQuery(), declared in src/Parsers/parseQuery.h, is the function that turns raw query text into an ASTPtr — the Abstract Syntax Tree this course's Module 2 glossary already defined. src/Parsers/ is enormous (nearly 400 files) mostly because ClickHouse has one ASTSomethingQuery/ParserSomethingQuery pair per SQL construct — ASTCreateQuery, ASTAlterQuery, ASTSelectQuery, and so on. You almost never need to read most of this directory; you need to know it exists, and that it's the layer where "is this even valid SQL" gets decided, before anything downstream sees the query at all.

  3. src/Interpreters/executeQuery.cpp, src/Interpreters/InterpreterFactory.cpp, src/Interpreters/IInterpreter.h

    Stage 3, the seam. executeQuery(), in src/Interpreters/executeQuery.cpp, is documented in its own header as doing exactly what its name says: "Parse and execute a query." It's the single narrowest waypoint in the whole codebase — practically every query, of every kind, over every protocol, passes through this one function. It doesn't know how to run a SELECT any more than it knows how to run an ALTER, though — it hands the parsed AST to InterpreterFactory::get(), which looks at what kind of AST node it is and returns the one IInterpreter subclass built for that query type. IInterpreter's one essential method, virtual BlockIO execute() = 0, is documented directly: "For queries that return a result (SELECT and similar), sets in BlockIO a stream from which you can read this result. For queries that receive data (INSERT), sets a thread in BlockIO where you can write data." This is the single most useful pattern in this whole tour: dozens of Interpreter*Query classes (InterpreterInsertQuery, InterpreterAlterQuery, InterpreterCreateQuery, and around forty more), one factory, one interface. Once you know a query type's name, you know its interpreter's name, and you know it lives in this directory.

  4. src/Analyzer/, src/Planner/, src/Processors/QueryPlan/, src/Processors/IProcessor.h, src/QueryPipeline/, src/Processors/Executors/

    Stages 4 and 5, at map-level detail only — this course's query-to-execution-plan lesson is the deep dive on this exact stretch, tracing it through EXPLAIN's own keywords and real captured pipeline output; this stop exists only so the map has no gap. Since ClickHouse 24.3, enable_analyzer has been on by default, so a SELECT's AST is resolved into a query tree by src/Analyzer (via buildQueryTree()), turned into a logical query plan — a tree of IQueryPlanStep subclasses like ReadFromMergeTree, AggregatingStep, and roughly sixty more — by src/Planner/Planner.cpp, and then compiled into a physical pipeline: a graph of IProcessor instances connected by input/output ports, assembled by src/QueryPipeline/QueryPipelineBuilder.h and driven across worker threads by src/Processors/Executors/PipelineExecutor.cpp. ReadFromMergeTree (the next stop) is this tour's one deliberate zoom-in on that stretch, because it's the exact seam where the query pipeline hands off to the storage engine this course has spent five modules on.

  5. src/Processors/QueryPlan/ReadFromMergeTree.h

    Where the query side first touches storage. ReadFromMergeTree is the IQueryPlanStep representing "read from this MergeTree table," and it's a genuinely useful bookmark regardless of how deep you go into the plan/pipeline machinery above: it's the exact seam where "what should happen" (the logical plan) becomes a decision about "which parts, which granules, using which index" (the physical read). Everything this course's Module 3 taught about sparse primary indexes and data-skipping indexes is, at the source level, logic this file and its neighbors are responsible for invoking.

  6. src/Storages/IStorage.h, src/Storages/MergeTree/MergeTreeData.h, src/Storages/MergeTree/IMergeTreeDataPart.h

    Stage 6. Every table engine this course has covered — plain MergeTree, ReplacingMergeTree, SummingMergeTree, Distributed, all of it — implements the same IStorage interface, the storage-layer sibling of IInterpreter and IProcessor from earlier stages. MergeTreeData (src/Storages/MergeTree/MergeTreeData.h) is the class holding a MergeTree table's in-memory state — which parts exist, which are active, current settings — and IMergeTreeDataPart (src/Storages/MergeTree/IMergeTreeDataPart.h) represents a single on-disk part, with sibling headers for exactly the concepts this course named earlier without pointing at source: MergeTreeDataPartChecksum.h, MergeTreeIndexGranularity.h, MergeTreePartition.h. If a question is genuinely about what's physically on disk, this is the directory, and this is the class hierarchy's root.

  7. src/Storages/MergeTree/MergeTreeDataMergerMutator.h, src/Storages/MergeTree/Compaction/MergeSelectors/SimpleMergeSelector.h

    The background half of stage 6 — merges aren't triggered by a query at all, but this is still where a huge share of real production questions ("why do I have so many parts") end up. MergeTreeDataMergerMutator's own doc comment: "Can select parts for background processes and do them. Currently helps with merges, mutations and moves." The actual heuristic for which parts to merge next lives one level deeper, in SimpleMergeSelector — the default selector — whose header comment is unusually candid about the trade-off it's balancing: minimizing write amplification (how much total data gets rewritten by merges) against minimizing the number of live parts at any moment, because "it will slow down SELECT queries significantly" if too many accumulate. This file is the debugging walkthrough's destination — see below.

  8. src/Interpreters/HashJoin/HashJoin.h

    One more stage-5 landmark, because JOIN questions are common enough to earn their own bookmark. ClickHouse's default JOIN algorithm — the hash join this course's glossary already defines — is implemented in src/Interpreters/HashJoin/, a dedicated subdirectory (not a single file) containing HashJoin.h/.cpp plus the build-side/probe-side machinery split out per join type (InnerHashJoinAll.cpp, LeftHashJoinAsof.cpp, and roughly thirty siblings). IJoin.h, one directory up, is the interface HashJoin, GraceHashJoin, FullSortingMergeJoin, and the other join algorithms this course's earlier lessons named all implement — the same interface-plus-implementations shape as every other stage in this tour.

Debugging walkthrough: "why isn't my table's part count going down?"

This is one of the single most common real ClickHouse symptoms, and it's a good exercise precisely because the honest answer requires source, not just system tables — the system tables tell you what is happening, and only the source tells you why.

The symptom. INSERTs into an events table have been running fine for weeks. Over the last few days, INSERT latency has crept up, and one just failed outright with an error mentioning "Too many parts."

Step 1 — confirm what the system tables already show. This course's Module 2 and Module 3 already taught system.parts; it's the first stop, not new territory:

SELECT partition_id, count() AS active_parts
FROM system.parts
WHERE table = 'events' AND active
GROUP BY partition_id
ORDER BY active_parts DESC
LIMIT 5;
┌─partition_id─┬─active_parts─┐
│ 202607       │         1842 │
│ 202606       │           41 │
│ 202605       │            9 │
└──────────────┴──────────────┘

One partition — the current month — has an order of magnitude more active parts than the others. That's the anomaly, not "parts exist at all."

Step 2 — check whether merges are even running.

SELECT count() FROM system.merges WHERE table = 'events';
┌─count()─┐
│       3 │
└─────────┘

Merges are running — this rules out "the background merge process is entirely stuck," a different failure mode with a different source-level cause (see the failure gallery below). The question sharpens: merges are happening, so why isn't the part count actually falling?

Step 3 — check the actual thresholds against the actual count. MergeTreeSettings.cpp declares parts_to_delay_insert (default 1000) and parts_to_throw_insert (default 3000) directly in source, with the throw threshold's own doc comment stating plainly that past it, INSERT is interrupted with the "Too many parts" exception. At 1,842 active parts in one partition, this table is already past the delay threshold (inserts are being artificially slowed down as a backpressure mechanism) and closing in on the throw threshold. The system tables have now told you what: merges are running, but not fast enough to keep this partition's part count under control.

Step 4 — read the source to understand why not enough, not just not enough. This is the step a symptom-only debugging approach skips, and it's where SimpleMergeSelector's own doc comment (quoted in the codebase tour above) becomes the actual answer, not background color: "Older data parts should be merged less frequently than newer data parts. Larger data parts should be merged less frequently than smaller data parts." That's not a bug — it's the selector deliberately trading some part-count growth for lower total write amplification, on the assumption that fewer, larger merges are cheaper overall than always aggressively flattening everything. If this table's actual ingestion pattern is many small, frequent INSERTs (a busy events pipeline, say), the selector's own designed-in caution about merging large parts too often can genuinely lose a race against a high enough small-part arrival rate — which is a capacity and ingestion-pattern problem, not a broken merge process.

The diagnosis this reasoning chain actually supports: not "merges are broken" (system.merges already ruled that out) and not "the selector has a bug" (its logic is doing exactly what its own comment says it will do) — the honest read is that this partition's sustained small-insert rate has outgrown what the selector's write-amplification-conscious heuristic can flatten in time, which points toward either batching inserts more aggressively upstream (fewer, larger INSERTs — a pattern this course's loading-data lesson already recommended for unrelated reasons) or, if the workload genuinely can't batch further, revisiting parts_to_delay_insert/max_parts_in_total with real understanding of the trade-off being adjusted, not as a number copied from a forum post.

"Too many parts" (DB::Exception, mentions parts_to_throw_insert). Signature: INSERT throughput degrades over hours-to-days, then a specific insert fails outright with an exception naming the setting. This is the walkthrough above, and it's real enough to have its own long-running GitHub issue thread. Root cause is almost never "ClickHouse is broken" — it's a mismatch between insert batching, partition key cardinality, and the merge selector's deliberately conservative heuristics, exactly the chain the walkthrough traced.

Memory limit (for query) exceeded on a query containing a JOIN. Signature: the query worked fine at smaller data volumes and started failing as one side of the join grew. Source-level cause: HashJoin (the file this tour's last stop pointed to) builds an in-memory hash table from one side of the join before streaming the other side past it — this course's glossary already documents that "the build side must fit comfortably in memory" for the default hash join, which is exactly the constraint this error is reporting a violation of. The fix isn't always "raise max_memory_usage" — GraceHashJoin and FullSortingMergeJoin exist specifically as non-memory-bound alternatives that spill to disk, which is worth knowing before reflexively raising a memory ceiling on a query whose actual data shape will keep growing.

Merges stop making progress on a specific partition or table, even though system.merges shows attempts. Signature: system.merges is genuinely empty (unlike the walkthrough above, where merges were running, just not fast enough) or repeatedly starts and aborts the same merge. Source-level cause to check: MergeTreeDataMergerMutator's part-selection path returns a typed failure — SelectMergeFailure::Reason, either CANNOT_SELECT or NOTHING_TO_MERGE — a real, if granular, distinction between "there's genuinely nothing eligible to merge right now" and "something is actively preventing an otherwise-eligible merge from being selected" (a stuck mutation, a TTL merge selector waiting on its own schedule, or a replication-related lock, depending on the specific deployment). This is a case where the enum's mere existence is diagnostically useful: a "merges aren't happening" symptom is at least two different source-level conditions, not one, and treating them identically wastes debugging time.

Hands-on forensics: the mental checklist

Given any "ClickHouse is doing something I don't understand" symptom, before opening a source file:

  1. Name the stage (the seven-stage model above). Is this about a query arriving, being parsed, being planned, running, or about data at rest?
  2. Find the interface, not the file. Almost every stage in this tour is one small interface (IInterpreter, IProcessor, IStorage, IJoin, IMergeSelector) with many implementations. Finding the interface tells you the shape of the answer before you've read a single implementation.
  3. Read the header's doc comment before the source. Every file this tour cited had a doc comment that, alone, answered most of the "what is this for" question — IProcessor.h, MergeTreeDataMergerMutator.h, and SimpleMergeSelector.h all did real explanatory work before a single line of implementation. ClickHouse's internals documentation isn't in a separate docs site for this layer — it's the comments, and they're frequently better than anything you'd find by searching.
  4. Cross-reference against a system table before trusting a source-level theory. The walkthrough above didn't jump straight to SimpleMergeSelector — it confirmed the shape of the symptom with system.parts and system.merges first. A hypothesis about internals that contradicts what the system tables actually show is a hypothesis to revise, not force onto the data.

Reasoning Prompt

A teammate reports: a SELECT query with a GROUP BY and a JOIN against a dimension table has started intermittently failing with Memory limit (for query) exceeded, only during a specific hour each day, on a table that hasn't changed size significantly in weeks. Their first instinct is to raise max_memory_usage on the query and move on. Before doing that: which stage of this lesson's seven-stage model does this symptom belong to, which source-level interface would you actually go read, and what specific question would you want that source to answer before agreeing raising the memory limit is the right fix? Write your reasoning down before revealing the model answer below.

Model Answer

This is stage 5 (pipeline execution) specifically, and the interface to go read is IJoin/HashJoin, not a general "memory tuning" question — the failure is scoped to a JOIN, and this course (both the codebase tour above and an earlier module) already established that ClickHouse's default hash join requires its build side to fit in memory.

The question worth answering before touching max_memory_usage: which side of the join is being used as the build side, and does that side's size change in the specific hour the failures cluster in? A dimension table that's normally small but gets a bulk reload or a temporary duplication once a day would explain intermittent, time-clustered failures far better than "the query needs more memory in general" — and raising max_memory_usage would mask that root cause rather than fix it, working until the reload grows further and the same failure returns at a higher ceiling.

What reading HashJoin.h and its neighbors would actually let you confirm: whether ClickHouse's build-side selection is automatic (this course's glossary already notes ClickHouse 24.12 added automatic build-side reordering, picking the smaller table) or whether this specific query's join order is forcing the larger table onto the build side regardless. If the automatic reordering isn't kicking in for some reason specific to this query's shape, that's a more precise, more permanent fix than a blanket memory increase — and if the dimension table really is temporarily ballooning during that hour, the real fix is upstream, in whatever process reloads it, not in this query at all.

A learner who jumped straight to "raise the memory limit" hasn't reasoned incorrectly about what would make the error go away — they've skipped the step this lesson is entirely about: using the source to find out which specific thing is actually consuming the memory, before deciding whether raising a ceiling is a fix or a delay.

Teach It Back

Explain to a peer who uses ClickHouse daily but has never opened its source — someone comfortable with MergeTree, ORDER BY, and system tables from earlier in this course, but who's never read a line of ClickHouse's C++ — how a SELECT query actually gets executed inside the codebase, from the moment it's typed into clickhouse-client to the moment rows come back. Don't list file names for their own sake; explain the stages in your own words, well enough that your peer could later open the source themselves and recognize which stage they're looking at.

Summary

ClickHouse's source tree is navigable not because it's small — it isn't — but because it mirrors a query's own journey: a connection handler receives it (src/Server), a parser turns it into an AST (src/Parsers), executeQuery() hands it to the right IInterpreter via InterpreterFactory (src/Interpreters), a SELECT specifically gets built into a query tree and logical plan (src/Analyzer, src/Planner), that plan becomes a pipeline of IProcessors connected by ports (src/Processors, src/QueryPipeline), something schedules that pipeline across threads (src/Processors/Executors), and underneath it all, IStorage implementations — MergeTreeData and IMergeTreeDataPart for this course's core engine — hold the actual data. A small number of interfaces (IInterpreter, IProcessor, IStorage, IJoin, IMergeSelector) recur at every stage, each with many named implementations; recognizing the pattern turns "which of five hundred files do I read" into "which interface is this question actually about." The debugging walkthrough showed this in practice: a "too many parts" symptom is confirmed with system tables this course already taught, but only reading SimpleMergeSelector's own deliberately-documented trade-off between write amplification and part count explains why merges running normally still aren't keeping pace — the kind of answer no summary of ClickHouse can give you as precisely as the comment sitting directly above the code making the decision.