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.
Optimize ClickHouse at Scale
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)
What this proves: ClickHouse created completely immutable directories (parts) on the disk for every single insert. When the background thread triggered a merge, it combined 1_1_0 and 2_2_0 into a new part 1_2_1. The old parts were marked as active = 0 (inactive) and will be deleted permanently after a short safety timeout.
Need Expert Help Optimizing Your ClickHouse Cluster?
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.
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.
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.
Anil Kushwaha, Technology Head at Ksolves, is an expert in Big Data. With over 11 years at Ksolves, he has been pivotal in driving innovative, high-volume data solutions with technologies like Nifi, Cassandra, Spark, Hadoop, etc. Passionate about advancing tech, he ensures smooth data warehousing for client success through tailored, cutting-edge strategies.
Fill out the form below to gain instant access to our exclusive webinar. Learn from industry experts, discover the latest trends, and gain actionable insights—all at your convenience.
AUTHOR
Big Data
Anil Kushwaha, Technology Head at Ksolves, is an expert in Big Data. With over 11 years at Ksolves, he has been pivotal in driving innovative, high-volume data solutions with technologies like Nifi, Cassandra, Spark, Hadoop, etc. Passionate about advancing tech, he ensures smooth data warehousing for client success through tailored, cutting-edge strategies.
Share with