Learning Objectives
- Read a MergeTree part's on-disk directory listing and explain what each file is for, including why a part is stored in Wide or Compact physical format
- Trace how a mark connects a granule to a physical byte position in a compressed column file, and explain why marks are a separate structure from the primary index
- Read a compressed block's on-disk header well enough to identify its codec directly from raw bytes, independent of any system table
- Diagnose "too many parts," "checksum doesn't match," and "a codec change didn't seem to take effect" from their symptoms, and confirm root cause using primary sources rather than guessing
Why This Matters
Every lesson since Module 2 has treated a part as a concept — an immutable unit a merge combines, a thing system.parts describes in rows and bytes. That's enough to design a schema or reason about a query plan, but it stops being enough the moment something actually breaks: a query throwing CHECKSUM_DOESNT_MATCH, a table's disk usage refusing to shrink after a compression change that should have helped, a part count climbing toward an error you've never seen before. None of those are solved by re-reading system.parts harder. They're solved by opening the part's directory on disk and reading it — the same way this course has taught you to read EXPLAIN output or a query plan, just one layer further down. This lesson is that layer: what's actually sitting in that directory, in what format, and how to read it like a diagnostic tool instead of a black box.
The mental model
A MergeTree part is a directory, and everything about how a query reads it follows from a small number of decisions about what goes in that directory and how it's laid out.
Every column's data lives in its own compressed file, unless the part is small enough that ClickHouse decided otherwise. ClickHouse's own documentation describes two physical part formats: "In Wide format each column is stored in a separate file in a filesystem, in Compact format all columns are stored in one file." Which one a part uses isn't a setting you pick per table — it's decided automatically per part, based on min_bytes_for_wide_part and min_rows_for_wide_part: a part smaller than those thresholds (10 MB by default, as of the version this behavior became default) is written Compact, storing every column consecutively in one file "to increase the spatial locality for reads and writes," which helps exactly the case this course has already warned about — lots of small, frequent inserts producing lots of small parts. A part large enough to cross that threshold gets Wide format instead, and Wide format is where the rest of this lesson's detail lives, because it's the shape a mature, actively-growing table's parts settle into.
Within a Wide-format part, a column's compressed data and the index that finds a specific granule inside it are two genuinely separate files, serving two different questions. column.bin holds the column's values, compressed, as a sequence of independent compressed blocks. A separate column.mrk (or .mrk2, with adaptive granularity, or .mrk3 for Compact parts) file stores one entry per granule — not the granule's data, but where that granule's data physically sits: "The first offset ('block_offset') is locating the block in the compressed column data file... the second offset ('granule_offset') provides the location of the granule within the uncompressed block data." That two-part offset exists because compression operates on blocks of many rows at once, not per-row — so pinpointing one granule means first finding which compressed block it's in, then where inside that block (once decompressed) it starts.
The primary index answers a different question than the marks do, and conflating them is the single easiest mistake to make at this depth. sparse-primary-index, back in Module 3, already established that primary.idx is a small, uncompressed array holding one primary-key entry per granule, used to decide which granules could possibly match a filter via binary search. The per-column .mrk files answer a completely different question, asked only after the primary index has already narrowed the search: given that granule 4,821 is one we need, where physically is it, in this specific column's .bin file? One structure (primary.idx) is about narrowing a search space; the other (per-column marks) is about physically locating what that search already selected. They're both "marks" in casual conversation, which is exactly why it's worth being precise: the primary index has its own array, and every column has its own separate mark file, and a query touching five columns consults five different mark files, once each, after the primary index has already done its job once.
A checksum manifest ties the whole directory together. checksums.txt records, per this course's research into MergeTreeDataPartChecksum (the source class handling this — see the Codebase Tour below), a checksum for every file in the part; columns.txt records the part's column list and types; count.txt and partition.dat record the part's row count and partition value respectively. This manifest is what makes a corrupted part detectable rather than silently wrong — covered directly in this lesson's failure mode gallery.
Codebase tour: where this lives in ClickHouse's source
Codebase Tour
src/Storages/MergeTree/IMergeTreeDataPart.h
The base abstraction every part format implements. This is the right file to start from if you're trying to understand "what is a part, structurally" from the source rather than from documentation — it defines the interface both
MergeTreeDataPartWideandMergeTreeDataPartCompactimplement differently.src/Storages/MergeTree/MergeTreeDataPartWide.h / .cpp
The Wide format implementation described in this lesson's mental model — one-or-more files per column. The header comments describe it as "the regular format of parts for MergeTree," used above the
min_bytes_for_wide_part/min_rows_for_wide_partthresholds.src/Storages/MergeTree/MergeTreeDataPartCompact.h / .cpp
The Compact format counterpart — every column's data consolidated into one file, for parts under the wide-format thresholds.
src/Storages/MergeTree/MergeTreeDataPartChecksum.h / .cpp
Checksum manifest handling — the source behind
checksums.txt. Worth opening directly if you ever need to know exactly what aCHECKSUM_DOESNT_MATCHerror is actually comparing, rather than guessing from the error text alone.src/Storages/MergeTree/MergeTreeData.h / .cpp
The top-level class managing a table's parts as a collection — where "which parts currently exist, which are active, which need merging" is tracked above the level of any single part's internal format.
src/Storages/MergeTree/KeyCondition.h / .cpp
Evaluates a query's
WHEREclause against the primary index to compute which granule ranges could match — the source-level home of the binary searchsparse-primary-indexwalked through conceptually in Module 3.src/Storages/MergeTree/MarkRange.h / .cpp
A mark range is exactly what it sounds like — a contiguous span of marks
KeyConditiondecided are worth reading. This is the structure that turns "granules 4820 through 4830" from a concept into something the reader can actually iterate.src/Compression/ICompressionCodec.h and src/Compression/CompressionInfo.h
The codec interface and the compressed-block header format this lesson's debugging walkthrough reads by hand below. If you ever need to add or inspect a codec, this is the interface every one of them (LZ4, ZSTD, Delta, DoubleDelta, Gorilla, and the rest from
metrics-and-time-series) implements.
Reading a compressed block's bytes directly
Every compressed block ClickHouse writes — inside any column's .bin file — starts with the same fixed header, documented precisely enough to read by hand:
checksum 16 bytes CityHash128 of (header + compressed_data)
raw_size 4 bytes length of (header + compressed_data)
data_size 4 bytes size of the data once decompressed
mode 1 byte codec identifier
[compressed_data follows]
The mode byte is the codec identifier, and a handful of its values are worth recognizing on sight: 0x02 for no compression (checksums only), 0x82 for LZ4, and 0x90 for ZSTD. That single byte is a genuinely useful diagnostic fact: you don't need system.columns or any query at all to know what codec compressed a given block — you can open the .bin file and read it directly, which is exactly what the next section does.
Debugging walkthrough: "we changed the codec, why hasn't disk usage changed?"
Symptom. A team runs ALTER TABLE events MODIFY COLUMN payload String CODEC(ZSTD(3)) on a large, long-running table, expecting payload's stored size to shrink — ZSTD generally compresses better than the LZ4 default. A day later, system.parts shows almost no change in bytes_on_disk. Is the codec change even working?
Step 1 — confirm the setting actually changed. ALTER TABLE ... MODIFY COLUMN ... CODEC(...) is a metadata-only operation: it updates the table's schema so future writes use the new codec, but it does not rewrite any bytes that already exist on disk — "new codec applied only to new parts, or after background merges or after mutations," in the words of the ClickHouse user who filed a real feature request asking for per-part codec visibility in system.part_columns, precisely because this metadata-only behavior leaves a table's parts on genuinely mixed codecs until something rewrites them. That single fact already explains the symptom: existing parts, written before the ALTER, are still compressed with the old codec, sitting untouched on disk.
Step 2 — find a specific part's real path. system.parts has a path column pointing at the part's directory on disk — the same table this course has used since Module 3, now used to go one level deeper:
SELECT name, path, bytes_on_disk
FROM system.parts
WHERE table = 'events' AND active
ORDER BY modification_time ASC
LIMIT 1;
┌─name───────────┬─path──────────────────────────────────────────────────────┬─bytes_on_disk─┐
│ 202401_1_50_3 │ /var/lib/clickhouse/store/d9f/d9f36a1a.../202401_1_50_3/ │ 284091822 │
└────────────────┴──────────────────────────────────────────────────────────┴───────────────┘
Step 3 — read the codec byte directly, without trusting any system table. This is the payoff of Step 1's mental model: open payload.bin for this specific old part and look at the first block's header.
offset 0-15 checksum (CityHash128) — 16 bytes, skip for this check
offset 16-19 raw_size — 4 bytes
offset 20-23 data_size — 4 bytes
offset 24 mode — 1 byte <-- the codec
0x82 observed here → LZ4, not the ZSTD(3) the ALTER requested
For this old part, the byte at offset 24 reads 0x82 — LZ4, exactly as the byte-value table above defines it, confirming the part predates the ALTER and was never touched by it.
Step 4 — confirm the fix, and the mechanism, without guessing. Since MODIFY COLUMN ... CODEC only affects new and re-merged parts, the two ways to actually get old data onto the new codec are the same two levers this course has used for every other "existing parts don't reflect a recent change" situation: wait for background merges to naturally rewrite old parts (Module 3), or force it immediately with OPTIMIZE TABLE events FINAL — which, like every other FINAL use in this course, does real, synchronous work proportional to how much data it has to rewrite, not a free operation.
Failure mode gallery
"Too many parts." The exact, verbatim error — DB::Exception: Too many parts (300). Merges are processing significantly slower than inserts — is real and common enough that ClickHouse maintains an official knowledge-base page specifically for it, and multiple real, publicly filed GitHub issues show the identical message across different users' incidents. The signature: writes suddenly start failing outright, not just running slowly, once a partition's active part count crosses a hard threshold — background merges have fallen behind the insert rate for long enough that ClickHouse refuses new parts rather than let the count grow unbounded. Root cause is almost always upstream of the table itself: too many small, frequent inserts, the exact anti-pattern loading-data and log-ingestion-pipelines warned about, now surfacing as a hard failure instead of a soft inefficiency.
CHECKSUM_DOESNT_MATCH — a part fails its own integrity check. This is checksums.txt doing its job: ClickHouse recomputes a file's checksum when reading or merging a part and compares it against the manifest, and a mismatch throws Code: 40. DB::Exception: ... (CHECKSUM_DOESNT_MATCH). The most common root cause for a single replica is exactly what it sounds like at the hardware level. ClickHouse's own creator, Alexey Milovidov, built a diagnostic tool directly into the project specifically to distinguish this: "if checksum difference is caused by single bit flip, we can be sure that this is hardware error, because random bit flips have low probability to happen due to software bugs" — reporting that this was worth building because it showed up "a few times a year in a fleet of ~1200 ClickHouse servers," traced to causes like bad RAM or a bad CPU on a network switch, or disk bit rot. A checksum error's exact wording can even name the location: ClickHouse users report messages like "The mismatch is caused by single bit flip in data block at byte 37672, bit 7" — precise enough to make "is this hardware" a question with a real answer, not a guess. On a replicated table, the fix path this course's own replicated-engines lesson set up conceptually now has a concrete tool: ALTER TABLE ... DETACH PART moves the suspect part into a detached directory — "the server forgets about the detached data partition as if it does not exist" — after which a healthy replica's copy resyncs to replace it, rather than trying to repair bytes that are already known to be wrong.
A codec change that "didn't take effect." Covered in full in the debugging walkthrough above — worth including in this gallery on its own because it's easy to mistake for a bug report ("the ALTER didn't work") when it's actually expected behavior once you know MODIFY COLUMN ... CODEC is metadata-only. The signature: system.parts (or a direct byte-level check like the walkthrough's) shows old parts still compressed with the old codec, indefinitely, until something — a background merge or a forced OPTIMIZE ... FINAL — actually rewrites them.
Hands-on forensics: reading a part end to end
A compact mental checklist for approaching an unfamiliar part directory, in the order an experienced SME would actually work through it:
- Find the part's path.
SELECT path FROM system.parts WHERE table = '...' AND name = '...'. - List the directory. Expect either many
<column>.bin/<column>.mrk2pairs (Wide format) or a single shared data file (Compact format) — which one tells you immediately whether this part crossed the wide-format size threshold. - Check
checksums.txtexists and the part isn't already sitting indetached/. A part that's been moved todetachedis already known-bad and waiting for a decision, not a mystery to re-diagnose. - For a specific column's storage question, read
<column>.mrk2before<column>.bin. The mark file tells you how many granules exist and roughly how block boundaries fall; jumping straight into the compressed.binbytes without this is like reading a book by opening to a random page. - For a compression question specifically, read the
modebyte at the start of a block, per this lesson's walkthrough, rather than trusting a setting's current value to describe data written under a previous setting.
Reasoning Prompt
A three-replica ClickHouse cluster hosts one MergeTree table. One specific replica — and only that one — starts throwing CHECKSUM_DOESNT_MATCH on a particular part whenever a background merge touches it; the other two replicas merge the equivalent data without any error. All three replicas run the identical ClickHouse version and the identical table schema. Form a hypothesis about what's actually different about the failing replica before reading further, and name the specific check you'd run first to start narrowing it down.
Model Answer⌄
The single most informative fact here is which replicas are affected: one out of three, not all three. If the merge algorithm itself were non-deterministic, or if all replicas were running a compression library version mismatch, the corruption would show up everywhere the same input passes through the same code — not on exactly one machine. An error isolated to one replica, with identical version and schema across all three, points hardest at something physically local to that one machine: the actual bytes on that replica's disk (or in its memory, in transit) are wrong, not the logic producing them.
That reframes the hypothesis from "something about our table or our ClickHouse version is broken" to "this one machine's storage or memory is suspect" — consistent with this lesson's own root-cause note that a CHECKSUM_DOESNT_MATCH is frequently explained by "single bit flips in data blocks, which is most likely due to hardware failure."
The first check: rather than digging into ClickHouse internals further, check the failing replica's own hardware and OS-level signals first — dmesg or the system log for disk I/O errors, a SMART check on the underlying disk, and whether ECC memory errors (if the hardware supports ECC) have been logged around the same timeframe as the failures. If those come back clean, the next check moves back into ClickHouse territory: whether this replica's checksums.txt for the affected part actually matches its two healthy peers' manifests for the equivalent merged output, which would help distinguish "this replica wrote bad bytes" from "this replica is correctly detecting bytes that were already bad when they arrived over the network from another replica."
The reasoning pattern worth keeping, independent of this specific scenario: when a symptom is isolated to one instance out of several otherwise-identical ones, the deterministic parts of the system (the code, the algorithm, the schema) are the least likely explanation, and the physically distinct parts (this machine's specific disk, memory, network path) are the most likely ones — the opposite priority order from a bug that reproduces identically everywhere.
Teach It Back
Explain to a colleague who's comfortable with ClickHouse day to day — they've read EXPLAIN output, they know what a part and a merge are — but has never opened a part's directory on disk: what's actually in there, and why are the primary index and a column's marks two different things rather than one? Use the "which granules, then where are they" framing this lesson built, in your own words, as if they just asked you "wait, I thought the primary index was the mark file."
Summary
A MergeTree part is a directory whose physical layout is chosen automatically — Compact (all columns in one file) below a size threshold, Wide (one-or-more files per column) above it — and Wide format is where the mechanism this lesson focused on lives: each column's values sit in a .bin file as a sequence of independently compressed blocks, and a separate .mrk/.mrk2 file records, per granule, a two-part offset (which compressed block, and where inside its decompressed bytes) that physically locates that granule. This is a genuinely different structure from primary.idx, which is smaller, uncompressed, and answers a different question — which granules could match a filter — consulted once per query via binary search, versus the per-column marks consulted once per selected column to physically find what the primary index already chose. Every compressed block carries a fixed, documented header — a 16-byte checksum, two 4-byte size fields, and a 1-byte codec identifier — precise enough to read by hand, which is exactly the technique this lesson's debugging walkthrough used to confirm that a MODIFY COLUMN ... CODEC change hadn't silently failed, just hadn't reached parts written before it, because that ALTER is metadata-only. checksums.txt is what turns a corrupted part from silently wrong into a detectable, CHECKSUM_DOESNT_MATCH failure, and "too many parts" is the hard-failure endpoint of an insert pattern this course has warned about since Module 2, now visible as a specific, citable, official error rather than an abstract inefficiency. None of this replaces system.parts or EXPLAIN as everyday tools — it's what's underneath them, for the days those tools raise a question they can't answer on their own.