ClickHouse Concepts
A columnar OLAP database built for logs, events and real-time aggregates: immutable sorted parts merged in the background, a sparse index over granules, and a sort order that decides almost everything.
Who needs this, what one row is, and why the obvious build breaks
Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.
You need aggregates over billions of event rows fast enough that a human keeps clicking rather than waiting. What makes that possible, and what did you agree to give up for it?
The consumers that batch warehouses serve badly: an operational dashboard refreshing every few seconds, an in-product analytics panel a customer is looking at right now, an on-call engineer slicing an hour of logs during an incident. All three need a low fixed cost per query and data that is queryable within seconds of arriving — and all three tolerate a narrower feature surface to get it (Who Actually Consumes This Data).
The physical unit is a part: an immutable directory of column files holding a contiguous, sorted run of rows produced by one insert. Inside a part, rows are addressed in granules of a fixed row count, and the primary index stores one entry per granule rather than per row. That sparseness is the whole design — the index stays small enough to be resident, and the price is that lookups resolve to a granule, not to a row (Physical Data Layout).
Create a table, give it an ORDER BY that mirrors how the data arrives — usually a timestamp — and insert events as they come. This works immediately and is genuinely fast, because a time-ordered table serves time-range queries very well and time-range queries are most of what anyone asks of an event table at first.
The product becomes multi-tenant and every dashboard query filters tenant_id = ? first. With ORDER BY (event_time) the sort order gives that predicate nothing: every granule spans every tenant, so a query for one small customer reads the whole time range (Clustering and Sort Order).
- The product becomes multi-tenant and every dashboard query filters
tenant_id = ?first. WithORDER BY (event_time)the sort order gives that predicate nothing: every granule spans every tenant, so a query for one small customer reads the whole time range (Clustering and Sort Order). - An event producer is changed to insert row by row instead of in batches. Each insert creates a part, part count climbs faster than background merges can reduce it, and the server starts rejecting inserts rather than falling further behind (File Size and the Small-Files Problem).
- A
ReplacingMergeTreeis used to deduplicate retries, and a dashboard shows inflated counts for a few minutes after each load — because replacement happens when parts merge, and the merge has not happened yet (Deduplication). - Someone puts a high-cardinality identifier first in the sort key. Compression on every following column gets worse because similar values are no longer adjacent, and prefix pruning is destroyed for every predicate that does not include that identifier (Dictionary, Run-Length, Delta and Bit Packing).
- A join is written between two very large tables, as it would be in a warehouse. The engine's design assumes wide, denormalized tables and small right-hand sides; the query is correct and the memory profile is not (Star Schema, Denormalization on Purpose).
- A
PARTITION BYis set to a day, or worse to an identifier, so parts cannot merge across partitions and the table accumulates far more parts than the merge machinery was designed for (Partition Cardinality).
What is actually happening
- The MergeTree family is a log-structured design applied to analytics. An insert writes a new immutable part whose rows are sorted by the table's
ORDER BY. A background process merges parts into larger sorted parts, exactly as an LSM tree merges runs (LSM Trees: Why Some Engines Favour Writes, Compaction: The Merge That Pays for Cheap Writes). - The primary index is sparse: one entry per granule of a fixed row count, holding the sort-key values at that granule's boundary. A query with a predicate on a prefix of the sort key binary-searches this index down to a range of granules and reads only those. Because the index is sparse it fits in memory even for very large tables, and because it is sparse it can never address a single row (The Index, Derived from First Principles). So the sort key is the index. There is no secondary structure doing the heavy lifting; skipping indices exist and are best-effort hints layered on top. This is the single most important structural fact about the system and the reason the sort order dominates its design (Clustering and Sort Order).
PARTITION BYis a data-management concept here, not the primary performance one: it decides which rows can be dropped or detached as a unit and which parts may merge together. Using it the way a warehouse partition column is used — fine-grained, to make queries fast — produces many small non-mergeable parts and makes things worse (Partitioning).- Sorting also drives compression. Adjacent rows in sort order have similar values, so run-length and delta encodings have something to work with; a column that varies randomly between adjacent rows compresses in proportion to its own entropy and no more (Why Analytical Data Compresses, Dictionary, Run-Length, Delta and Bit Packing).
- The specialised engines — replacing, summing, aggregating, collapsing — perform their transformation at merge time. Rows with the same sort key are collapsed when the parts holding them are merged, and not before. A query issued between the insert and the merge sees the pre-collapse state (Deduplication).
- Execution is vectorized: operators process batches of column values rather than rows, which is what lets a scan saturate memory bandwidth and use the CPU's wide instructions (Vectorized Execution, Columnar Execution). A cluster adds sharding and replication. A distributed table fans a query out to shards and merges the results; a shard is where the data lives, and the sharding key is a placement decision with the same character as a distribution key elsewhere (Partitioning and Sharding).
Inserts become parts, and parts become fewer parts
The write path is the whole operational story. An insert does not modify anything: it sorts the incoming batch by the table's ORDER BY and writes a new immutable part — a directory of compressed column files plus a sparse index over its granules. The part is visible to queries as soon as it lands, which is why data is queryable seconds after arriving.
A background process then merges parts into larger sorted parts. This is a log-structured merge, and it carries the same economics as one everywhere else: writes are cheap and sequential, reads pay a small price for having several sorted runs to look at, and the merge process is what keeps that price small (LSM Trees: Why Some Engines Favour Writes, Compaction: The Merge That Pays for Cheap Writes).
Everything that goes wrong operationally is a variation on parts arriving faster than they can be merged. Row-by-row inserts, over-fine partitioning that forbids merging across partitions, and a merge budget saturated by a heavy table all produce the same symptom, and the symptom is an ingestion failure rather than a slow query. That is the inversion worth internalising: on this engine, the ingestion pattern is the reliability risk and the query pattern is mostly a layout question (Backpressure).
- 1Batch at the producer
Accumulates events for an interval or a row count before sending one insert.
guarantees Nothing about delivery on its own — the buffer is a place data can be lost if it is not backed by a log.
fails by Being skipped entirely, so each event becomes its own insert and therefore its own part (Batch Ingestion).
- 2Insert
Sorts the batch by the table's ORDER BY and writes one immutable part.
guarantees Atomic at the part level: readers see the whole batch or none of it.
fails by Being rejected when the active part count is already too high — the server protecting itself from an unwinnable merge backlog.
- 3Visible
The new part joins the set a query reads.
guarantees Rows are queryable. Nothing is promised about collapsing, deduplication or aggregation yet.
fails by Consumers seeing pre-collapse duplicates and reporting them as a data bug (Duplicate Rows).
- 4Background merge
Combines sorted parts into larger sorted parts, applying the engine's collapse rule for rows sharing a sort key.
guarantees Eventual collapse. No schedule, no completion signal, no query-visible boundary.
fails by Falling behind under insert pressure, or never running across partitions when partitioning is too fine (Partition Cardinality).
- 5Settled
One row per sort key remains, in the engine's chosen collapse semantics.
guarantees Only that this state is eventually reached — never that a given query observed it.
fails by Being assumed by a consumer's query, which is the characteristic correctness bug of this engine (Deduplication).
Read the guarantees column: visibility is immediate and settling is eventual, and there is no stage that promises a query saw the settled state. That gap is where the read path has to take responsibility.
data/store/table=events/
all_202608_1_1_0/ <- one insert: one part, sorted by ORDER BY
tenant_id.bin event_type.bin event_time.bin payload.bin
primary.idx <- one entry per granule, not per row
all_202608_2_2_0/ <- next insert: another part
all_202608_1_2_1/ <- background merge of the two, still sorted
all_202607_1_9_2/ <- a previous month: separate partition,
never merged together with 202608
granule = a fixed number of consecutive rows
primary.idx holds the sort-key values at each granule boundary,
so a predicate on a prefix of the sort key binary-searches to a
granule range and the engine reads only those granules.Engine names in the merge-tree family, the default granule size, part-count thresholds, the settings that govern merge scheduling, and which table engines exist at all are configuration and product surface that change between releases. This lesson deliberately states no thresholds or defaults; verify current documentation for the version you are running.
The sort order is the schema decision
There is no secondary index doing the real work here. The sparse primary index is built over the sort key, and pruning happens by binary-searching that index to a granule range. So a predicate helps if and only if it constrains a prefix of the sort key — the same left-to-right rule as a composite index in any database, with the difference that here there is nothing else to fall back on (Composite Indexes and the Leftmost-Prefix Rule).
That makes ordering the columns a real design exercise. The column almost every query fixes goes first, even — especially — if its cardinality is low, because fixing it collapses the granule range enormously for everything after it. A high-cardinality identifier at the front is the classic mistake: it makes the index look selective and destroys prefix pruning for every other predicate, because no query that omits it can narrow anything.
Sorting has a second effect that is easy to forget. Rows adjacent in sort order have similar values, which is exactly the condition under which run-length and delta encodings do something. Put a randomly-varying column first and the columns after it are effectively shuffled, so their encodings have far less structure to exploit — the compression benefit is a function of adjacency, and adjacency is what the sort key controls (Dictionary, Run-Length, Delta and Bit Packing, Why Analytical Data Compresses).
- granules where tenant_id < acmemost of the table · 1 file · skipped
- granules where tenant_id = acme, event_type < checkouta slice of this tenant · 1 file · skipped
- granules where tenant_id = acme, event_type = checkout, older than the windowthis tenant's history · 1 file · skipped
- granules where tenant_id = acme, event_type = checkout, within the windowthe answer, plus granule rounding · 1 file · read
- granules where tenant_id > acmemost of the table · 1 file · skipped
- the same table sorted by event_time insteadevery granule in the time range · 1 file · read
A sparse index cannot address a row, only a granule. That is the deliberate trade: the index stays memory-resident for very large tables, and the smallest unit anyone can read is a granule.
Sort by arrival time, because that is the order data comes in and it makes time-range queries fast. Every dashboard query then reads every granule in its time range, filtering out the tenants it does not want.
Sort by the column every query fixes, then the next most common filter, then time. A dashboard for one tenant binary-searches to that tenant's granules and reads a small contiguous slice of them, and a time range inside that slice narrows further.
Pruning works on a prefix of the sort key and there is no other index to compensate. With time first, tenant_id = ? cannot eliminate a single granule, because every granule spans every tenant — the query is correct and reads the whole range. With tenant first, the same predicate collapses the granule range before any column data is read, and the columns after it also compress better because rows for one tenant are now adjacent.
Merge-time semantics: what the engine actually promises
The specialised engines are the most useful and most misunderstood part of the system. A replacing engine keeps the latest row per sort key; a summing engine adds numeric columns for rows sharing a sort key; an aggregating engine keeps partial aggregate states. All of them perform that transformation during a merge — which means a query issued between an insert and the merge that collapses it sees the uncollapsed rows (Deduplication).
This is not a bug and it is not a race to be tuned away. It is the trade that makes insert throughput so high: nothing has to be read, checked or updated at write time. The consequence is simply that the read path is responsible for correctness, and a query that assumes the collapsed state is a query that will be intermittently wrong in a way that is very hard to reproduce.
There are three honest ways to handle it, and the choice belongs to the consumer, not to the table. Ask for the final state explicitly and accept that it does more work per query. Write the aggregation so it produces the settled answer from unsettled rows — take the latest version per key in the query itself. Or make the pipeline naturally idempotent, so duplicates are impossible upstream and the engine's collapsing is a safety net rather than a dependency (Idempotent Data Pipelines).
| Check | Expresses | Catches | Still misses |
|---|---|---|---|
| Insert a known duplicate and immediately run the consumer-facing query; assert the correct value. | The read path is correct without depending on a merge having run. | Every query written as if collapsing were synchronous — the most common correctness bug on this engine. | A deduplication key that is wrong. If the sort key does not actually identify the entity, rows will never collapse and the query will never notice. |
| Active part count and merge backlog per table, alerted before the rejection threshold. | Merges are keeping up with inserts. | A producer that stopped batching, a partition scheme too fine to merge, a merge budget consumed by one heavy table. | A table that merges fine and prunes nothing, because the sort key does not match the workload — good operational health, bad query cost. |
| Granules read versus granules total, per dashboard query. | The sort key is eliminating data for the predicates people actually write. | A sort key chosen from arrival order rather than query shape; a dashboard that dropped its leading filter in a refactor. | Correctness entirely, and it says nothing about whether the rows read were the right rows (Missing Rows). |
| Row count of the raw event log for a closed interval versus the table's count for the same interval. | Completeness — everything the log carried arrived here. | A dropped ingest batch, a producer outage, a rejected insert that nobody retried. | Any period not yet closed, and duplicates that happen to offset losses — the same blind spot every reconciliation has (Reconciliation). |
The first row is the one specific to this engine. The other three are the ordinary portfolio, and this engine needs them exactly as much as a batch warehouse does (Data Quality).
1CREATE TABLE order_state2(3 tenant_id String,4 order_id String,5 updated_at DateTime,6 status String,7 amount_minor Int648)9ENGINE = ReplacingMergeTree(updated_at)10PARTITION BY toYYYYMM(updated_at) -- coarse: a retention unit11ORDER BY (tenant_id, order_id); -- the collapse key AND the index12 13-- 1. Assumes the merge has happened. Intermittently double-counts14-- every order that was updated recently. Correct most of the time,15-- which is the worst possible property for a bug to have.16SELECT sum(amount_minor) FROM order_state WHERE tenant_id = 'acme';17 18-- 2. Asks the engine to collapse at read time. Correct always,19-- and it does the deduplication work on every query.20SELECT sum(amount_minor) FROM order_state FINAL WHERE tenant_id = 'acme';21 22-- 3. Produces the settled answer from unsettled rows: pick the newest23-- version per key in the query, then aggregate. Correct always,24-- and the cost is explicit and visible in the SQL.25SELECT sum(amount_minor)26FROM (27 SELECT order_id, argMax(amount_minor, updated_at) AS amount_minor28 FROM order_state29 WHERE tenant_id = 'acme'30 GROUP BY order_id31);Queries 2 and 3 are correct at any moment; query 1 is correct only after a merge nobody scheduled. The engine's collapse rule is a storage optimisation, and treating it as a data guarantee is the characteristic mistake here.
Whether a given deduplication or aggregation engine exists, what its exact collapse semantics are, how a final-state read is expressed and optimised, and how insert-level deduplication windows behave are all version-dependent and have changed materially over releases. Test the semantics against the version you run rather than against documentation you remember.
How to build it
Most important first.
- Choose the sort key from the predicates your queries actually carry, ordered from lowest cardinality to highest: the tenant or dimension that almost every query fixes, then the next most common filter, then time. This one decision does more for query cost than everything else combined (Clustering and Sort Order). Keep the sort key short. Every additional column narrows granule boundaries a little and costs compression and merge work; three or four columns is a normal answer and ten is a smell.
- Insert in batches. One large insert per interval creates one part; a thousand single-row inserts create a thousand parts and hand the merge machinery a problem it cannot win (Batch Ingestion).
- Partition coarsely — a month is a common answer — and treat partitioning as the unit of retention and deletion rather than as a query optimisation (Data Retention).
- Model wide and denormalized. This engine is built for large flat event tables with dimensions folded in or served from in-memory dictionaries, not for a normalised star schema joined at query time (Denormalization on Purpose, Star Schema).
- Treat merge-time engines as an eventual transformation. If a consumer cannot tolerate seeing pre-collapse rows, write the query so it aggregates correctly regardless — deduplicate in the query, or read through a view that does — rather than assuming the merge has happened (Idempotent Data Pipelines).
- Give the ingestion path a buffer that batches on your behalf if producers cannot. A small aggregating stage in front is far cheaper than tuning a server drowning in parts (Streaming Ingestion).
What this actually promises
Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.
- An insert of a batch is atomic at the part level: the part becomes visible in full or not at all, so readers never see half a batch (Atomic Publish).
- There are no multi-statement transactions across tables in the sense an OLTP database means it. A pipeline that needs several tables to change together has to design for that itself (Transactions and ACID).
- Merge-time engines guarantee that duplicates or aggregates will eventually be collapsed. They do not guarantee when, and there is no point at which a query is promised to see the collapsed state unless it asks for it explicitly (Deduplication).
- Ordering within a part is guaranteed by the sort key. Ordering of *results* is guaranteed only by an explicit
ORDER BYin the query — the storage order is a physical property, not a result contract (Event Time). - On a cluster, replication is asynchronous by default, so a read served by a replica may not include the most recent insert. That is a freshness property to design around, not a defect (Replication and Read Scaling).
- Completeness and correctness are guaranteed by nothing here. The engine aggregates whatever it holds, very quickly (Data Quality).
Can I trust it?
A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.
- The check that matters most here is a duplicate-tolerance test on the read path: insert a known duplicate, query immediately, and assert the consumer-facing query returns the correct value *before* any merge has happened. It verifies that the query, not the storage engine, is responsible for correctness.
- It misses the slow version of the same failure — a deduplication key that is subtly wrong, so rows that should collapse never do and the table grows quietly with near-duplicates that no merge will ever remove (Duplicate Rows).
- Pair it with a part-count and merge-backlog check, because a table whose merges are falling behind will eventually reject inserts, and that failure arrives as an ingestion outage rather than as a data-quality symptom (Pipeline Metrics).
- This is the architecture's strongest axis: rows become queryable as soon as the part is written, so the gap between arrival and visibility is small and is under your control through batch size (Streaming Ingestion).
- The trade is explicit and worth stating: smaller batches mean fresher data and more parts; larger batches mean fewer parts and a longer wait before rows appear. Batch size is the freshness dial and the part-count dial at the same time (Cost vs Freshness).
- Merge-time semantics add a second, subtler freshness axis: data is *visible* immediately and *settled* only after merging. A consumer that needs the settled state must either wait, query with an explicit final-state modifier, or write a query that produces the settled answer from unsettled rows.
- Adding a column is cheap: existing parts simply lack it and reads supply a default. This is the same immutability benefit every columnar store gets (Schema Evolution).
- Changing the sort key is effectively creating a new table and rewriting into it. Treat the sort order as the least reversible decision in the schema and choose it against the query workload, not against the data model (Breaking Schema Changes).
- Changing a table's engine — from a plain merge tree to a replacing one, say — changes what a query means, because the same rows now collapse. That is a semantic change dressed as a DDL statement, and downstream consumers should be told (Semantic Changes).
- A materialized view here is an insert trigger that writes into another table, not a cached query. Changing the source table's schema does not retroactively change what the view already wrote, so the view's history and its future can disagree (The Metrics Layer).
- Partition-level operations are the natural repair unit: detach or drop a partition, rewrite it, attach it. Coarse partitioning is what makes this possible, which is the strongest argument for keeping partitions coarse but present (Planning a Backfill).
- Re-running an ingest is safe only if the read path tolerates duplicates or the table's engine collapses them on a key you control. Merge-time collapsing is a convenience, not an idempotency guarantee, because collapse is eventual (Idempotent Data Pipelines).
- A backfill of a large historical range should write into a separate table and be swapped in by an atomic exchange, rather than inserted into a table consumers are reading (Atomic Publish).
- The durable recovery position is, as always, upstream: the event log or the raw landing zone, replayed. This engine is a serving layer and should not be the only copy of anything (Replay from the Log, Keeping Raw History: The Recovery Position and the Liability).
What can go wrong
- Too many parts: single-row or high-frequency inserts outrunning background merges until the server refuses writes.
- A sort key that matches the data's arrival order rather than the query workload, so pruning does nothing for the predicate every dashboard carries.
- Duplicates visible to consumers between insert and merge, reported as a data bug rather than as the documented behaviour of a merge-time engine.
- Over-fine partitioning that prevents parts from merging and produces the too-many-parts failure from a different direction (Partition Cardinality).
- A large join written by someone applying warehouse habits, with a memory profile the design never promised (When the Join Strategy Is the Bottleneck).
- The mitigation failing too: a batching buffer in front of ingestion that hides part-count pressure until it fails, at which point the backlog is much larger than it would have been (Backpressure).
- "
ReplacingMergeTreededuplicates my data." It collapses rows with the same sort key when the parts holding them are merged, which is eventual and unscheduled. Until then, both rows are visible to every query that does not explicitly ask for the collapsed state (Deduplication). - "
PARTITION BYis how you make queries fast, like in a warehouse." Here the sort key does that work. Partitioning is for dropping and managing data, and fine-grained partitioning actively harms merging (Partitioning). - "Put the highest-cardinality column first so the index is selective." The opposite. Prefix pruning works left to right, so a unique-ish leading column means no other predicate can prune, and compression on every following column suffers because similar values are no longer adjacent (Dictionary, Run-Length, Delta and Bit Packing).
- "It is a drop-in replacement for the warehouse." It is a different set of trade-offs: excellent at large scans and high query concurrency over flat event tables, and deliberately narrow on joins, updates and multi-table transactions (Comparing Analytical Warehouses).
- "Inserts are cheap, so insert as they arrive." Each insert is a part, and parts are the currency of the merge machinery. Insert throughput is a batching problem, and the failure is an ingestion outage rather than slow queries (Batch Ingestion).
Operating it
- Active part count per table and merge backlog. This is the single most predictive operational signal the system has, and it degrades gradually before it fails suddenly (Pipeline Metrics).
- Granules read versus granules in the table, per query — the direct measure of whether the sort key is earning its place (Scan Cost).
- Rows read versus rows returned. A large ratio on a filtered query means the predicate is not on a sort-key prefix (Query Optimization: Finding the Actual Bottleneck).
- Insert batch size distribution per producer, which is the upstream cause of most part-count problems (Volume Anomalies).
- Replication lag per replica, because a replica serving reads is a freshness surface (Replication Lag: Reads That Are Correct and Stale).
- At 10x events, insert batching and part count decide whether the system is comfortable. Query cost changes far less, because pruning was already doing the work (File Size and the Small-Files Problem).
- At 100x, a single node stops being enough and sharding introduces a placement decision — the sharding key — with all the skew consequences of any distribution key (Data Skew).
- Consumer count is where the low fixed cost per query pays off: many concurrent small queries are the workload this architecture is built for, which is exactly the opposite of a batch warehouse's comfort zone (Concurrency Limits: An Unbounded Server Is a Slower Server).
- Bytes read from disk, decided by how well the sort key prunes granules and by how well sorted data compresses (Why Analytical Data Compresses).
- Merge work, which is proportional to how many parts inserts create and therefore to insert batch size rather than to data volume (File Compaction).
- Memory during aggregation and joins, which is the resource this engine spends most freely and the one most likely to end a query (Reading Memory: RSS, Heap, Working Set and the Number on Your Dashboard).
- Retained bytes, reduced by partition-level retention rather than by row-level deletes, which are expensive on immutable parts (Storage Lifecycle).
- Repeated work: many dashboards computing the same rollup from the raw event table, which materialized views exist to remove (Data Marts).
- A sparse index over sorted immutable parts buys very cheap large scans and gives up point-lookup efficiency: resolving a single row means reading its whole granule (OLTP Workloads).
- Merge-time collapsing buys enormous insert throughput and gives up read-time certainty, pushing correctness into the query. That is a good trade when the query author knows it and a silent bug when they do not.
- Denormalized wide tables buy join-free queries and cost you the ability to correct a dimension in one place — a changed customer name has to be rewritten across history or accepted as of-the-time (Slowly Changing Dimensions).
Dataset review questions
This lesson uses the shared review exercise.
Where this applies
Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.
- WAREHOUSE-SPECIFICSparse primary indexes over sorted immutable parts, merge-time collapsing and partitioning as a data-management rather than query concept are specific to this engine family; applying warehouse habits — fine partitions, normalised joins, row-level updates — produces the opposite of the intended behaviour here.
- GENERALThe underlying ideas — sorted immutable runs merged in the background, an index whose density trades memory for lookup precision, and compression that improves when similar values are adjacent — are shared with LSM storage engines and with every columnar format, and transfer far beyond this product.
- SCALE-SPECIFICThe design pays off when scans are large and query concurrency is high; below a few million rows any engine answers the query and the operational attention this one asks for — part counts, merge backlogs, sort-key choice — is overhead you are buying nothing with.
Where the depth lives
This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.
- — Distributed Systems owns what a sharded, asynchronously replicated store promises a reader, and why a replica that has not caught up is a correct system rather than a broken one.
- — DevOps / Production Engineering owns running this as a service — cluster topology, upgrades, and the alerting on merge backlog that turns a gradual degradation into a ticket rather than an outage.