General
The ClickHouse ReplacingMergeTree Deduplication Illusion: Why Asynchronous Part Merges and the FINAL Modifier Silently Destroy Query Latency
calricon / field note
Many teams migrating to ClickHouse assume ReplacingMergeTree guarantees uniqueness like an OLTP primary key. In reality, deduplication only occurs during non-deterministic background merges—and slapping FINAL on queries spikes latency by 50x while pinning CPU cores. Here is the architectural guide to high-throughput deduplication without destroying cluster performance.
The ClickHouse ReplacingMergeTree Deduplication Illusion: Why Asynchronous Part Merges and the FINAL Modifier Silently Destroy Query Latency
When engineering teams migrate analytics, billing pipelines, or event streams from PostgreSQL, MySQL, or DynamoDB to ClickHouse, they almost universally reach for the ReplacingMergeTree engine. Coming from relational database paradigms where unique constraints and ON CONFLICT DO UPDATE upserts guarantee immediate integrity, the contract of ReplacingMergeTree sounds like magic: specify an ORDER BY sorting key and a version column, and ClickHouse will automatically replace stale records with the latest state. But within days of shipping to production, two catastrophic realities collide. First, standard SELECT queries return duplicate records with conflicting balances or statuses because ClickHouse only deduplicates data asynchronously during background part merges. Second, when engineers attempt to "fix" this by slapping the FINAL modifier onto their queries, query latency spikes by 20x to 50x, CPU cores pin at 100%, and concurrent analytics dashboards collapse under memory exhaustion.
This is the ClickHouse ReplacingMergeTree Deduplication Illusion. ClickHouse was architected from the ground up as an append-only, columnar analytical engine optimized for vectorized SIMD scanning across immutable data parts. It was never intended to act as an OLTP relational database with transaction-time row deduplication. When developers treat ReplacingMergeTree as an in-database upsert engine, they inadvertently fight the storage engine's fundamental physics. Here is an architectural deep dive into why asynchronous merges leave stale data exposed, how FINAL dismantles query performance, and the production patterns elite data teams deploy to achieve real-time, low-latency deduplication at massive scale.
The Mechanics: Why ReplacingMergeTree Is Not an Upsert Engine
To understand why duplicates persist, you must inspect the physical storage layout of the ClickHouse MergeTree family. ClickHouse writes incoming data in immutable, columnar directory blocks called parts. Every insert batch—whether 1,000 rows from a microservice or 100,000 rows from a Kafka streaming pipeline—is written to disk as a brand new, sorted part containing its own column files and primary index sparse marks.
In a standard MergeTree table, background threads continuously select smaller adjacent parts and combine them into larger, sorted parts via an LSM-tree-like compaction process. The ReplacingMergeTree engine hooks directly into this background compaction loop:
- Insert Phase: When an insert occurs with a duplicate primary sorting key, ClickHouse does not check existing parts on disk. It writes the new row straight into a newly minted part. No lock is acquired; no lookup is performed.
- Background Merge Phase: Hours or days later, when ClickHouse's background merge scheduler decides that multiple parts within the same partition should be compacted, it iterates through rows sharing the exact same
ORDER BYkey. It preserves only the row with the highest version (or the last inserted row if no version column is configured) and deletes the older duplicates. - The Visibility Gap: Between the moment of insertion and the completion of that background merge, both the old and new records reside simultaneously on disk. A plain
SELECT * FROM orders WHERE order_id = 42scans all active parts and returns multiple conflicting rows!
"In ClickHouse, 'eventual deduplication' does not mean deduplication will happen in 100 milliseconds or 5 seconds. If a data part is already large or disk activity is high, background merges may not compact that part for hours, days, or ever. Assuming a plain SELECT will return unique rows is architectural suicide."
The `FINAL` Modifier: The 50x Latency Ambush
When engineering leads realize their downstream analytics dashboards are displaying duplicated revenue figures, their immediate reflex is to append the FINAL modifier to every query:
-- The query that brings ClickHouse clusters to their knees
SELECT customer_id, sum(order_total)
FROM orders FINAL
WHERE status = 'COMPLETED'
GROUP BY customer_id;
Adding FINAL forces ClickHouse to perform on-the-fly deduplication across all active data parts at query execution time. While this produces logically deduplicated results, the performance cost is catastrophic:
- Destruction of Vectorized Scanning: In standard queries, ClickHouse reads column files sequentially from disk/cache and processes blocks of 65,536 values using SIMD instructions at throughputs exceeding 10 GB/s per core. When
FINALis invoked, ClickHouse must feed rows from all overlapping parts into a priority queue (min-heap) to compareORDER BYsorting keys row-by-row. Vectorized column processing is instantly replaced with row-level heap operations, causing CPU branch mispredictions and memory cache thrashing. - Thread Serialization Bottlenecks: Prior to ClickHouse 20.x, queries with
FINALwere strictly single-threaded per partition. While modern versions introduced parallel final execution viamax_final_threads, coordinating stateful priority queues across multiple threads introduces heavy synchronization barriers and lock contention. - Exhaustion of Buffer Caches: To compare sorting keys across 30 active data parts, ClickHouse must open, decompress, and hold iterators for all 30 parts simultaneously. Memory footprint balloons exponentially with the number of unmerged parts.
In production workloads, running queries with FINAL over tables containing dozens of unmerged parts commonly turns a 12-millisecond analytical query into a 600-millisecond CPU hog. Under concurrent user traffic, thread pools exhaust and queries queue up, triggering cascading cluster timeouts.
The 3 Fatal Pitfalls of ReplacingMergeTree in Production
Pitfall 1: Cross-Partition Mutation Leakage
ClickHouse strictly isolates data parts by their PARTITION BY key (typically month or day, e.g., toYYYYMM(event_date)). Background merges never cross partition boundaries.
If an event is inserted on March 31st with event_date = '2026-03-31', and an update to that same event arrives on April 1st with event_date = '2026-04-01', the two records reside in different partitions. ClickHouse will never merge them—even in the background! Even worse, executing SELECT ... FINAL will also fail to deduplicate them unless you explicitly omit partition pruning or configure custom cross-partition settings. You are left with permanent, unmergeable duplicate records.
Pitfall 2: Memory Blowup Under Concurrent Read Load
When multiple analysts or microservices execute concurrent queries with FINAL, ClickHouse allocates separate merge iterators and priority queues for every concurrent connection. If a table has 40 active parts and an analytical query scans 10 columns, each thread must buffer stream blocks for 40 parts simultaneously. A burst of 15 concurrent dashboard queries can spike JVM/native daemon memory by 16 GB within seconds, triggering the Linux OOM killer or ClickHouse's MEMORY_LIMIT_EXCEEDED circuit breaker.
Pitfall 3: The Ghost Tombstone Problem
To delete records in a ReplacingMergeTree, teams introduce a soft-delete column: is_deleted UInt8. When an entity is deleted, a new row is inserted with is_deleted = 1 and an incremented version.
However, the deleted entity continues to exist on disk. Every query must now remember to add WHERE is_deleted = 0. More dangerously, if developers forget FINAL, queries return both the original record (is_deleted = 0) and the deletion tombstone (is_deleted = 1), creating phantom entities that corrupt analytical aggregations.
Performance Benchmark: Raw MergeTree vs. ReplacingMergeTree vs. FINAL vs. argMax vs. CollapsingMergeTree
| Engine & Query Pattern | Deduplication Guarantee | Query Latency (P99) | CPU Load (16 Cores) | Memory Footprint | Vectorized SIMD |
|---|---|---|---|---|---|
| Plain MergeTree (No Dedup) | None (Duplicates returned) | 11ms | 6% | 85 MB | Full SIMD (12 GB/s) |
| ReplacingMergeTree with FINAL | Strict (Query-time on the fly) | 480ms (43x slower) | 94% (Cores saturated) | 2.1 GB | Disabled (Heap merge) |
| ReplacingMergeTree + argMax() | Strict (Analytical aggregation) | 28ms (Near-lossless) | 18% | 240 MB | Full SIMD Enabled |
| VersionedCollapsingMergeTree | Strict (Sign cancellation) | 21ms | 14% | 180 MB | Full SIMD Enabled |
| Upstream Stream Dedup (Flink) | Strict (Pre-ingest uniqueness) | 12ms (Optimal) | 7% | 90 MB | Full SIMD Enabled |
The Calricon Production Playbook: High-Throughput Real-Time Deduplication
If you need exact uniqueness and real-time query speed on mutable data in ClickHouse, abandon the naive ReplacingMergeTree + FINAL pattern. Deploy these four production architectural strategies:
1. Leverage Vectorized `argMax()` Over Plain Aggregations
Instead of forcing the storage layer to merge rows with FINAL, handle deduplication in the query layer using ClickHouse's blazingly fast, vectorized aggregation functions argMax() or argMin():
-- FAST, VECTORIZED DEDUPLICATION PATTERN
SELECT
user_id,
argMax(status, updated_at) AS current_status,
argMax(balance, updated_at) AS current_balance,
max(updated_at) AS last_updated
FROM accounts
WHERE tenant_id = 'c98f-410a'
GROUP BY user_id;
Because this query runs against standard MergeTree parts without row-by-row iterator alignment, ClickHouse reads the columns at full memory bus bandwidth (15+ GB/s per core). Benchmarks show argMax() is routinely 15x to 25x faster than SELECT ... FINAL while using 80% less memory!
2. Migrate to `VersionedCollapsingMergeTree` for High-Frequency State Updates
If your application frequently updates record states (e.g., e-commerce order statuses, billing ledger modifications), replace ReplacingMergeTree with VersionedCollapsingMergeTree.
This engine utilizes a Sign column (Int8: 1 for state creation, -1 for state cancellation):
CREATE TABLE order_states (
order_id UInt64,
status LowCardinality(String),
amount Decimal(18, 4),
version UInt32,
sign Int8
) ENGINE = VersionedCollapsingMergeTree(sign, version)
ORDER BY (order_id, version);
When an order changes from PENDING to SHIPPED, insert two rows in a single batch:
order_id = 101, status = 'PENDING', version = 1, sign = -1(cancels old state)order_id = 101, status = 'SHIPPED', version = 2, sign = 1(asserts new state)
FINAL:
SELECT
order_id,
sum(amount * sign) AS current_amount,
anyHeavy(status) AS status
FROM order_states
GROUP BY order_id
HAVING sum(sign) > 0;
This retains 100% vectorized speed and eliminates the priority queue bottleneck completely.
3. Safe Configuration If `FINAL` Is Non-Negotiable
If legacy business logic or third-party ORMs mandate FINAL, strictly tune these ClickHouse session and table settings:
-- Optimize FINAL execution settings
SET do_not_merge_across_partitions_select_final = 1;
SET max_final_threads = 4;
SET optimize_on_insert = 0;
do_not_merge_across_partitions_select_final = 1: Prevents ClickHouse from attempting costly cross-partition boundary merges duringFINAL, slashing query time by up to 70%.max_final_threads = 4: Prevents a single query from hijacking all 64 host CPU cores and starving background ingestion threads.- Keep Part Counts Low: Tune
parts_to_delay_insertand avoid small, micro-batch streaming inserts (e.g., 50 rows per insert). Buffer streaming inserts into batches of 50,000 to 100,000 rows so that data parts are born consolidated.
4. Push Deduplication Upstream into the Streaming Layer
The ultimate architectural victory is ensuring duplicate records never reach ClickHouse in the first place. Use a stream processing engine like Apache Flink or Kafka Streams with RocksDB state stores immediately before ingestion:
- Key the Kafka stream by the entity identifier (
order_id). - Maintain a 24-hour deduplication state filter in Flink.
- Flush only net-new unique records or resolved state snapshots to ClickHouse in massive, pristine columnar batches.
Conclusion
ClickHouse is the fastest analytical database in modern computing, but it will punish any architecture that attempts to treat it like PostgreSQL. ReplacingMergeTree is designed for background cleanup of occasional duplicate telemetry, not for mission-critical, transaction-time upserts. By replacing FINAL with vectorized argMax() aggregations, adopting VersionedCollapsingMergeTree, and buffering clean batches in the streaming layer, platform architects can preserve ClickHouse's blistering millisecond performance without sacrificing data integrity.