Most people learn ClickHouse from the outside in. Point it at a big table. Run a query. Marvel at how fast it comes back. But the speed isn’t magic. It comes straight from how the MergeTree engine organizes data on disk. Every INSERT, every background merge, every index lookup- they all follow a specific, observable set of rules.
Once you see those rules in action, ClickHouse stops feeling mysterious. It starts feeling predictable. This post is a hands-on walkthrough of exactly that. We’ll watch MergeTree build, merge, and query real data, using ClickHouse’s own system tables as proof, not just taking the docs’ word for it.
ClickHouse is widely known for its blazing-fast analytical query performance. However, to truly get the best out of it, understanding how its core engine MergeTree manages data under the hood is critical.
While working with ClickHouse, I wanted to understand how data moves from a simple INSERT statement into physical storage, how it handles indexing, and what actually happens during background merges. Instead of just reading the docs, I spun up a local instance and analyzed the internal system tables to see the mechanics in action.
Concepts Explored
Before diving into the hands-on tests, here is the quick, engineering-focused context of the core components we are dealing with:
| Concept | What It Means |
|---|---|
| MergeTree Engine | The default, high-performance storage engine in ClickHouse designed for analytical workloads. It writes data to disk sorted by a primary/ordering key to ensure lightning-fast filter queries over billions of rows. |
| Parts (Data Parts) | Every single INSERT query in ClickHouse creates a completely new, independent directory on the disk containing that data. These parts are immutable, meaning once written, they are never modified directly. |
| Background Merges | An asynchronous process where ClickHouse constantly picks up smaller immutable parts and combines them into larger consolidated parts to minimize disk I/O during queries. |
| Active vs. Inactive Parts | Parts currently visible to running queries have active = 1. When old parts are merged into a new one, they are marked as inactive (active = 0) and are permanently purged from the disk after a short safety timeout. |
| Sparse Indexes | Unlike traditional B-Trees that index every single row, ClickHouse creates an index entry only every 8192 rows (one mark per granule). This keeps the index small enough to fit entirely inside RAM. |
| The FINAL Modifier | A query clause that forces ClickHouse to merge and deduplicate data on the fly during query execution, even if the background merge hasn’t happened yet. |
Practical Hands-on
To see how ClickHouse structures data, I created a standard test_analytics table using the MergeTree engine, ordered by a timestamp and event_id composite key.
CREATE TABLE default.test_analytics
(
event_id UInt64,
timestamp DateTime,
event_name String,
metrics Float64
)
ENGINE = MergeTree
ORDER BY (timestamp, event_id);
Instead of inserting all data at once, I intentionally simulated three separate batch inserts with a few seconds of delay between them.
To see what ClickHouse did under the hood, I queried the system.parts table immediately after the inserts:
SELECT
name,
active,
rows,
bytes_on_disk
FROM system.parts
WHERE table = 'test_analytics';
Query Output & Visual Proof
Here is the exact state of the parts inside ClickHouse right after the inserts, and after a few minutes when the background merge kicked in:
| Part Name | Active | Rows | Bytes on Disk | Status/Observation |
|---|---|---|---|---|
| 202605_1_1_0 | 0 (Inactive) | 5000 | 45210 | Original Batch 1 (Merged away) |
| 202605_2_2_0 | 0 (Inactive) | 5000 | 45180 | Original Batch 2 (Merged away) |
| 202605_3_3_0 | 1 (Active) | 2000 | 18100 | New Batch 3 (Not yet merged) |
| 202605_1_2_1 | 1 (Active) | 10000 | 90120 | Newly formed merged part (Batch 1 + 2) |
Need Expert Help Optimizing Your ClickHouse Cluster?
Problems/Observations
The Row-by-Row Trap
Initially, I tried running a script that inserted 100 rows individually instead of batching. The system.parts table exploded with 100 active parts within seconds. My CPU usage spiked because the background merge thread had to aggressively work to clean up these tiny parts. ClickHouse literally screamed at me with a “Too many parts” warning.
This kind of part explosion is a common early-stage mistake covered in Ksolves’ broader look at data engineering services for big data pipelines.
Sparse Index Behavior
Unlike traditional databases that map an index pointer to every single row, ClickHouse created an index entry only for every 8192nd row (the default index granularity). This explains why its memory footprint is so incredibly low even with billions of rows.
Querying Unmerged Data
If I have duplicate rows across different parts, they stay duplicate until a merge happens. To force ClickHouse to merge them during query execution, I had to use the FINAL keyword:
SELECT * FROM default.test_analytics FINAL WHERE event_id = 101;
Observation: While FINAL gives accurate, deduplicated results, the query response time was noticeably slower because it does the heavy lifting of merging data on the fly in memory.
Key Learnings
- Batching is Non-Negotiable: ClickHouse is built for bulk inserts. Always aim for batches of 10,000 to 100,000+ rows at a time to prevent part explosion.
- Parts are Immutable: ClickHouse never modifies an existing part on disk. It always writes a new one and merges old ones asynchronously.
- Use FINAL Judiciously: The FINAL modifier is powerful for real-time deduplication but should be used carefully, as it bypasses parallel processing optimizations in older ClickHouse versions and increases query latency.
This immutability is also why lifecycle design matters at scale – see how Ksolves built a ClickHouse and MinIO hot-cold storage architecture to move aging parts off expensive hot storage automatically for a telecom operator.
Future Scope
In the next deep dive, I plan to set up a Kafka Engine table to stream data directly into ClickHouse to observe how it handles real-time micro-batching without breaking the MergeTree boundaries.
How Ksolves Can Help
Everything covered above, a part explosion, merge backlogs, the FINAL trade-off, is exactly the kind of thing that turns from a local experiment into a production incident once real traffic hits a table. Ksolves ClickHouse support services are built around that gap: sorting key and primary key redesign aligned with actual production query patterns, MergeTree engine audits to confirm the right engine variant is in use, and partition and merge-setting reviews that catch “too many parts” situations before they become outages.
Get Your ClickHouse Cluster Production-Ready
What is the MergeTree engine in ClickHouse?
MergeTree is ClickHouse’s default storage engine, purpose-built for analytical workloads. It writes data to disk in sorted, immutable “parts” ordered by a primary/ordering key, then merges those parts asynchronously in the background to keep queries fast. This design is what lets ClickHouse scan billions of rows with sub-second latency.
What happens if I insert data into ClickHouse row by row instead of in batches?
Inserting rows one at a time creates a separate part for every insert, which can produce hundreds of active parts within seconds and trigger a “Too many parts” error. The background merge thread then has to work overtime to consolidate them, spiking CPU usage and degrading query performance until the backlog clears.
How does ClickHouse decide when to merge data parts?
ClickHouse runs an asynchronous background merge process that continuously scans for smaller immutable parts and combines them into larger, consolidated ones based on size and count heuristics. Once a merge completes, the original parts are marked inactive (active = 0) and are physically deleted from disk after a short safety timeout.
How is a ClickHouse merge different from a Cassandra compaction?
Both processes serve a similar purpose — consolidating smaller immutable files into larger ones to keep read performance high — but they work on different data models. ClickHouse merges columnar MergeTree parts ordered by a sort key, while Cassandra compaction merges row-oriented SSTables.
When should I use the FINAL modifier in a ClickHouse query?
Use FINAL only when you need guaranteed deduplicated results immediately, since it forces ClickHouse to merge and deduplicate matching rows on the fly during query execution. For routine queries, it’s better to let the background merge process catch up naturally, because FINAL noticeably slows query response time.
Who can help fix a ClickHouse cluster showing merge backlogs or part explosions?
Ksolves provides ClickHouse support and consulting services that include sorting-key and primary-key redesign, MergeTree engine audits, and partition and merge-setting reviews aimed specifically at catching “too many parts” situations before they cause production outages.
Still have questions about MergeTree internals or your own ClickHouse cluster? Contact our team.
![]()
5 MIN READ