A ClickHouse CDC pipeline can be completely caught up and still return the wrong answer.
That sounds contradictory until you separate two different facts: the change event may have arrived at the destination, while the old physical row is still present in the table. If the query does not apply the right replacement semantics, a count can include both versions. A deleted row can still appear. A dashboard can look stale even though Kafka offsets and connector checkpoints are healthy.
This is the production trap behind many reports of βClickHouse duplicates.β The problem is not always a broken connector, and it is not always slow merging. Sometimes the source identity is wrong. Sometimes versions tie. Sometimes a delete event cannot reproduce the same key as the original row. Sometimes the data is correct for an append-only history table but incorrect for a current-state dashboard.
This guide builds a practical model for finding the difference. It focuses on PostgreSQL or MySQL CDC flowing through tools such as Debezium and Kafka into ClickHouse, but the reasoning applies to other CDC systems as well.
Key Takeaways
Click any topic to expand or collapse
ReplacingMergeTree is not an immediate uniqueness constraint.
Duplicate physical rows can remain until background merges run.
Deduplication identity is the complete ORDER BY tuple.
It is defined by the full tuple, not necessarily the declared PRIMARY KEY.
Requirements for CDC implementation:
A stable row identity and a source-ordered version are more important than simply βturning onβ a replacing engine.
Understanding FINAL and OPTIMIZE TABLE:
FINAL provides query-time replacement for the rows being read. OPTIMIZE TABLE ... FINAL is a storage-maintenance operation and is not a routine lag fix.
Handling deletions safely:
Deletes are usually represented as versioned tombstones. Removing those tombstones too early can allow an old event to resurrect a row.
The safest production workflow:
Prove delivery, prove identity and ordering, inspect queues and parts, then choose the correctness boundary.
What does ReplacingMergeTree actually replace?
ReplacingMergeTree stores inserted rows in parts and uses background merges to consolidate those parts. During a merge, rows with the same values for every column in the tableβs ORDER BY expression are treated as candidates for replacement. If a version column is configured, the row with the greatest version wins.

That definition contains the first important distinction:
The table can accept a newer version immediately without physically removing the older version immediately.
The ClickHouse ReplacingMergeTree engine reference explicitly describes replacement as a merge-time behavior and warns that background merges happen asynchronously. The detailed ReplacingMergeTree guide makes the operational consequence clear: a table may contain duplicate versions at any moment.
So there are two states to keep separate:
- Physical state: the parts currently stored on disk, which may contain several versions of a logical row.
- Logical state: the current row a query should return after applying version and delete rules.
A plain SELECT does not automatically promise the second state just because the table uses ReplacingMergeTree.
The identity is ORDER BY, not simply PRIMARY KEY
A common design mistake is to assume that the ClickHouse PRIMARY KEY defines the replacement identity. In MergeTree-family tables, the primary key controls the sparse index. It may be a prefix of ORDER BY. Replacement uses the complete sorting-key tuple.
Imagine a source table with a stable business key called customer_id. This design can deduplicate correctly for that identity:
SQL β illustrative schema; validate against your ClickHouse release:
CREATE TABLE analytics.customer_state
(
tenant_id UInt64,
customer_id UInt64,
email String,
status LowCardinality(String),
version UInt64,
is_deleted UInt8
)
ENGINE = ReplacingMergeTree(version, is_deleted)
ORDER BY (tenant_id, customer_id);
ReplacingMergeTree(version, is_deleted)with stable ORDER BYBut if an update changes a value that was accidentally included in ORDER BY, ClickHouse sees a new identity. It does not understand that the application intended to update the old row. The result is two logical keys instead of one updated key.
This is why the ClickHouse CDC deduplication documentation emphasizes an immutable ordering key. Put stable identity columns in ORDER BY; keep mutable attributes out of the identity unless you deliberately want a new logical row.
Before tuning merges, write down the exact ORDER BY tuple. If the update and delete events cannot reproduce that tuple, no merge setting can repair the model.
Why CDC deduplication lag happens
βDeduplication lagβ is not one metric. In production, it is usually a combination of four freshness dimensions:
A healthy connector proves only the first dimension. It does not prove that a plain query has one row per source key.
The event timeline
The following timeline is a useful mental model when investigating a duplicate report:
| Stage | What happens | What can look wrong |
|---|---|---|
| 1. Source commit | The source database records an insert, update, or delete. | A source timestamp may not provide a total order. |
| 2. CDC delivery | The connector emits an event and ClickHouse accepts a row. | Retries or replay can create repeated physical inserts. |
| 3. Part creation | The inserted rows become one or more data parts. | Old and new versions coexist. |
| 4. Background merge | ClickHouse may merge relevant parts and apply replacement. | Large parts, write pressure, or scheduling can delay consolidation. |
| 5. Query | The reader chooses plain reads, FINAL, or a current-state layer. | The same physical data can produce different answers. |
The contrarian point is worth stating plainly: a merge backlog is not automatically a data-integrity incident. If the application reads historical events, duplicates may be intentional. If it needs one current row per key, the same backlog becomes a semantic correctness problem. The right response depends on the serving contract.
Version columns: the difference between βlatestβ and βlast insertedβ
Without a version column, the winning row depends on insertion and part order. That can be acceptable for a tightly controlled append stream, but it is a weak foundation for a CDC pipeline that can retry, replay, reorder, or fan in events.

With ReplacingMergeTree(version), the greatest version wins among rows that share the same ORDER BY identity. The version must represent a meaningful order for every operation, including deletes. A timestamp is not automatically safe: two events can share the same timestamp, clocks can move, and connector retries can deliver an older event after a newer one.
A database log position, transaction sequence, or connector sequence can be a better candidate when its ordering semantics are known. In its PostgreSQL CDC material, ClickHouse uses PostgreSQL logical-decoding source.lsn as an example of a version. That example should not be treated as a universal contract for every connector.
Equal versions are another subtle failure mode. If two rows have the same maximum version, the physical insertion order becomes relevant. In a distributed or replay-heavy pipeline, that tie-breaker may not match business intent.
A practical version checklist
Before trusting a version column, answer these questions:
- Is it ordered per logical source key, or only globally?
- Can two events for the same key share the value?
- Does the delete event receive a version greater than the row it deletes?
- Can a retry deliver an older version after a newer one?
- Is the value preserved across replay and backfill?
- Do all replicas and consumers interpret it the same way?
If the answer to any of these is unclear, label the design as unresolved rather than calling it exactly-once deduplication.
Deletes are tombstones, not disappearing rows
CDC deletes are often represented by inserting a newer row with a delete flag. With the is_deleted form of ReplacingMergeTree, the highest-version tombstone wins and is retained by ordinary merges.
That retention is intentional. If the tombstone disappeared immediately, a delayed older version could arrive later and make the deleted entity visible again. The tombstone acts as memory: it tells future merges that older states are no longer current.

The usual read pattern therefore filters the winning deleted state:
SQL β current-state read pattern:
SELECT
tenant_id,
customer_id,
email,
status
FROM analytics.customer_state FINAL
WHERE is_deleted = 0;
FINAL plus is_deleted = 0The delete event must contain the same identity columns used in ORDER BY. This is easy to miss when the source connector emits a delete payload with only a subset of the old row. If the tombstone has a different identity, it cannot replace the original row.
For PostgreSQL, source-side replica identity matters when a delete needs key columns that are not the primary key. The exact setting and connector behavior must be checked against the source and connector versions; do not assume that every CDC tool reconstructs missing identity fields automatically.
Why cleanup is dangerous
OPTIMIZE ... FINAL CLEANUP is not an ordinary βremove old duplicatesβ command. The official cleanup guidance requires confidence that old versions will not arrive later and that replicas are synchronized. Otherwise, a late old version can be retained as if it were current because the tombstone that protected the row has already been removed.
The ClickHouse settings reference also makes cleanup availability version-sensitive. Treat cleanup as a controlled maintenance procedure with a documented gate, not as an automated reaction to every duplicate count.
Warning: Never turn tombstone cleanup into a blind cron job. First prove the source retention window, connector replay behavior, replica state, and absence of late events. If those conditions cannot be proved, keep tombstones and filter them at the serving boundary.
FINAL vs OPTIMIZE FINAL: similar words, different jobs
These two commands are often conflated because both contain βFINAL.β Their operational roles are different.
| Mechanism | Where replacement happens | Best use | Main risk |
|---|---|---|---|
SELECT ... FINAL | At query time for the data read. | Current-state correctness when storage merges are not complete. | Additional read and deduplication cost, especially for broad scans. |
OPTIMIZE TABLE ... FINAL | In storage by forcing a merge. | Controlled maintenance after capacity and replica checks. | High I/O, memory, disk/object-storage writes, and possible resource pressure. |
OPTIMIZE ... FINAL CLEANUP | Storage merge plus tombstone cleanup when enabled. | Rare, explicitly gated cleanup windows. | Late old events can resurrect deleted state after protection is removed. |

OPTIMIZE FINAL β physical rows per key
OPTIMIZE FINAL β physical rows per keyThe ClickHouse best-practice guidance on avoiding OPTIMIZE FINAL warns that forced final merges can create substantial decompression, recompression, memory, and storage work. That does not make the command unusable. It means the command belongs in a maintenance plan, not in the default response to a dashboard showing duplicates.
A good production design often accepts query-time FINAL for a narrow current-state workload, while using a separately refreshed serving table or view for high-volume dashboards. The correct choice depends on scan width, update rate, freshness target, and capacity. Do not copy a benchmark from another workload and call it a guarantee.
A safe production diagnosis workflow
When someone reports duplicate rows, resist the urge to run a forced merge first. Use evidence to locate the layer that is failing.
Step 1: Prove that the event arrived
Compare the source checkpoint, connector offsets, delivery timestamps, and the maximum version visible in ClickHouse. If the new event never arrived, this is a delivery or connector problem, not a merge problem.
For Kafka-based ingestion, inspect the connectorβs offset and retry metrics alongside the ClickHouse Kafka table or ingestion logs. The Kafka table engine documentation describes the relevant ingestion and offset concepts, but connector-specific retry and ordering behavior still needs to be checked in the connector documentation.
Step 2: Prove the identity and version
Pick one affected source key and retrieve every physical version. Look for:
- Missing identity columns.
- An update that changed an
ORDER BYcolumn. - A delete with a different key shape.
- A version that moved backward.
- Equal maximum versions.
- A repeated event that is identical versus a repeated event with conflicting payloads.
A small, targeted query is more useful than a broad OPTIMIZE because it tells you whether the stored records can logically converge at all.
SQL β targeted inspection; replace columns and key values:
SELECT
tenant_id,
customer_id,
version,
is_deleted,
_part,
_part_uuid
FROM analytics.customer_state
WHERE tenant_id = 42
AND customer_id = 781
ORDER BY version, _part;
The system columns available and the exact diagnostic fields should be checked against your ClickHouse version. The example is a documentation-guided investigation pattern, not a locally executed benchmark.
Read-only diagnostic query pack
The following examples are intentionally narrow. Replace the database, table, key columns, and version column, then validate the available system-table columns against your ClickHouse build before putting them into monitoring.
SQL β find logical keys with more than one physical row:
SELECT
tenant_id,
customer_id,
count() AS physical_rows,
min(version) AS oldest_version,
max(version) AS newest_version,
countIf(is_deleted = 1) AS tombstones
FROM analytics.customer_state
GROUP BY tenant_id, customer_id
HAVING physical_rows > 1
ORDER BY physical_rows DESC
LIMIT 100;
SQL β compare a plain read with a current-state read:
SELECT 'plain' AS read_mode, count()
FROM analytics.customer_state
WHERE tenant_id = 42
UNION ALL
SELECT 'final' AS read_mode, count()
FROM analytics.customer_state FINAL
WHERE tenant_id = 42
AND is_deleted = 0;
FINAL=4SQL β inspect active merges:
SELECT
database,
table,
elapsed,
progress,
num_parts,
result_part_name,
total_size_bytes_compressed
FROM system.merges
ORDER BY elapsed DESC;
system.merges: active background merge workSQL β inspect active parts by partition:
SELECT
database,
table,
partition,
count() AS active_parts,
sum(rows) AS rows,
sum(bytes_on_disk) AS bytes_on_disk,
max(level) AS max_level
FROM system.parts
WHERE active
AND database = 'analytics'
AND table = 'customer_state'
GROUP BY database, table, partition
ORDER BY active_parts DESC;
system.parts: active parts by partitionThese queries answer different questions. The duplicate-group query tests logical convergence. The plain-versus-FINAL comparison tests semantic freshness. system.merges and system.parts test storage pressure. Add system.replicas and system.replication_queue checks when the table is replicated. A single query cannot identify every layer.
Step 3: Inspect merges, parts, and replication queues
Use ClickHouse system tables to determine whether the problem is active work, accumulated parts, replication delay, or an error state. Useful areas include:
system.mergesfor active merge work.system.partsfor active parts, levels, sizes, and partition distribution.system.replicasfor replica delay and queue state.system.replication_queuefor failed or pending replicated operations.system.query_logfor expensive or failing FINAL queries.
The exact columns differ by release and deployment mode, so validate diagnostic SQL against the system tables documentation. Do not build an alert on a column copied from an old blog without checking its current reference.
Step 4: Check the partition boundary
Background merging is partition-local. If versions of the same logical key can land in different partitions, ordinary merges cannot bring them together. A partition key that changes with mutable business data is therefore a correctness risk, not just a performance choice.
The partition key should normally be stable for a logical row. If the workload permits the same key to span partitions, query-time FINAL and partition-related settings require extra care. Current ClickHouse releases also have version-sensitive behavior around partition pruning during FINAL, so review the setting reference before changing defaults during an upgrade.
Step 5: Choose the correctness boundary
There are three defensible patterns:
- Query-time correctness: use
FINALfor current-state queries where the workload can afford it. - Refreshed serving layer: periodically materialize a current-state table or view designed for the dashboard workload.
- Upstream stateful deduplication: use a stream processor when the product needs a strict, continuously maintained current state before ClickHouse.
None is universally best. The decision depends on whether the table is an event history, a current-state store, or both. A useful architecture often keeps the raw CDC history and exposes a separate current-state serving path rather than forcing every query to solve deduplication independently.
CDC schema design that survives retries and replays
A production CDC table should be designed around the source rowβs identity, not around the shape of one sample event.

Stable identity
Include every column needed to identify the logical row. In a multi-tenant system that may mean (tenant_id, source_primary_key). The tuple must remain stable across updates, and delete messages must carry enough information to reproduce it.
A meaningful version
Use a source or connector sequence whose ordering semantics are understood. For PostgreSQL, an LSN may be appropriate in a specific architecture. For MySQL, binlog file and position, GTID, or a connector-provided sequence may be relevant, but the exact mapping must be verified with the connector documentation. Never assume that a wall-clock updated_at field is a total order.
Explicit deletes
Represent a delete with the same identity and a higher version. Keep the tombstone until the system can prove that old events cannot return. Filter it at the current-state read boundary.
Idempotent replay expectations
CDC systems often retry. A retry is harmless only if the repeated event has a stable identity and deterministic version semantics. If a retry receives a new version every time, the pipeline may create an endless stream of logically equivalent βnewβ rows. That is not the same as idempotent ingestion.
Materialized views are not merge observers
A materialized view reacts to inserted blocks. It does not automatically wake up later because a background merge replaced two old rows. If a design assumes that an MV can compare the final logical row after every merge, it is mixing insert-time behavior with merge-time behavior.
This distinction is one of the most useful production corrections to make early. The pipeline may need an explicit current-state refresh rather than an MV that assumes storage merges are application events.
Symptom-to-action decision matrix
Use this matrix to choose the first investigation, not as a substitute for checking the deployed version and connector.
| Observed symptom | Likely layer | First check | Do not do first |
|---|---|---|---|
| The new row is missing everywhere | Source or delivery | Connector offset, retry state, and event payload | OPTIMIZE FINAL |
| Two versions are visible in a plain read | Merge or query semantics | Compare the same key with and without FINAL | Assume corruption or force a cluster-wide merge |
| A delete does not hide the row | Identity, version, or tombstone handling | Check delete identity and version against the prior row | Remove tombstones immediately |
| A deleted row reappears after replay | Cleanup or ordering | Reconstruct the version timeline and cleanup window | Run cleanup again |
| Current-state queries become slow | Broad FINAL scans or storage pressure | Query log, key filters, parts, and workload shape | Assume forced merges are free |
Common mistakes that create duplicate-looking data

Mistake 1: Treating an acknowledged insert as an update
ClickHouse accepted a new version, but the old physical version can remain. Fix the read contract or wait for controlled merges; do not infer correctness from insert acknowledgement.
Mistake 2: Using mutable columns in ORDER BY
If a customer changes region and region is part of the sorting key, the event may become a new logical identity. Keep mutable attributes out of the identity.
Mistake 3: Using timestamps without testing ties and regressions
Timestamps are easy to produce and hard to reason about under concurrency. Test same-timestamp events, clock skew, replay, and delete ordering before relying on them.
Mistake 4: Sending incomplete delete payloads
A tombstone that lacks one identity column cannot replace the old state. Configure source-side identity exposure and connector mapping deliberately.
Mistake 5: Running OPTIMIZE FINAL as an alert handler
A forced merge may reduce visible duplicates while creating a larger resource incident. Investigate parts, queues, and workload pressure first.
Mistake 6: Cleaning tombstones before the replay window closes
This removes the protection that prevents an old update from resurrecting a deleted row. Cleanup needs an explicit source-retention and replica-synchronization policy.
Mistake 7: Applying FINAL after aggregation
The placement matters. For a current-state count, apply replacement to the table before aggregation:
SQL β current-state aggregation:
SELECT
status,
count()
FROM analytics.customer_state FINAL
WHERE is_deleted = 0
GROUP BY status;
FINAL before GROUP BYThe query still assumes that the identity, version, and tombstone model are correct. FINAL cannot repair a bad key or an ambiguous version stream.
Before and after: changing the operational model
Before
A team sees duplicate customers in a dashboard. It runs OPTIMIZE TABLE ... FINAL, watches the count drop, and closes the incident. A week later, the same query becomes slow, the source connector replays an older event, and a deleted customer reappears.
The team treated physical compaction as the correctness guarantee.
After
The team defines the serving contract first. It validates the source identity and version, checks whether the delete contains the full key, separates connector freshness from merge freshness, and uses FINAL or a refreshed current-state layer for queries that require one row per key. Cleanup is gated by replay and replica conditions.
The team no longer asks βHow quickly does ClickHouse merge?β as its only question. It asks βWhich freshness boundary does this product promise, and where is that promise enforced?β
That is the durable improvement. Faster merges can help, but they are not a substitute for a correctness boundary.
When not to use ReplacingMergeTree
ReplacingMergeTree is a strong fit for append-oriented change streams and current-state queries that can define their replacement semantics. It is not a universal substitute for a transactional uniqueness constraint or a fully managed state store.

Consider another design, or add a separate serving layer, when:
- The application needs immediate uniqueness enforcement at write time.
- Late, out-of-order events cannot be bounded or represented with a reliable version.
- The workload requires frequent broad scans that cannot afford query-time replacement.
- The data is a high-value current state and there is no capacity for reconciliation or refresh.
- Updates can change the logical identity and the source cannot emit a tombstone for the old identity.
- The table is being used simultaneously as an immutable event history and as a low-latency current-state API without an explicit contract for either view.
The alternative is not automatically βdeduplicate upstream.β Stateful stream processing introduces its own state, checkpoint, replay, and recovery requirements. The useful decision is to choose where correctness is enforced and make that boundary observable.
Production observability checklist
Track the following as separate signals rather than collapsing them into one βCDC lagβ number:
- Source commit-to-event time.
- Connector checkpoint and retry age.
- Maximum source version versus maximum ClickHouse version.
- Replication queue size and oldest queue item age.
- Active merge count and merge duration.
- Number, size, and level of active parts by partition.
- Query latency and memory for
FINALreads. - Count of visible duplicate identities in a sampled audit query.
- Count and age of winning tombstones.
- Reconciliation results for a known set of source keys.
A useful audit does not need to scan the entire table on every interval. Sample keys, partition ranges, or business-critical entities, then run a deeper reconciliation during controlled windows.
The most meaningful alert is often not βparts are high.β It is βcurrent-state reconciliation has exceeded the productβs freshness objective.β That connects infrastructure behavior to user-visible correctness.
Documented examples versus company case studies
Public ClickHouse documentation provides strong implementation examples, including PostgreSQL logical decoding, Debezium/Kafka transformations, versioned rows, and delete markers. These are useful documented patterns, but they are not independently measured customer case studies.

The public research reviewed for this article did not provide enough verified, comparable production metrics to claim that a named company reduced duplicate lag by a specific percentage or achieved a particular throughput. That evidence gap matters. A premium technical article should not manufacture a success story just to satisfy a case-study template.
The more honest production lesson is this: ClickHouseβs own CDC examples demonstrate the mechanics, while independent practitioner discussions repeatedly surface the operational questions, replay, ordering, FINAL cost, partition boundaries, and tombstone handling, that must be tested in the readerβs environment. Use the official PostgreSQL CDC series as a starting point, then validate connector-specific behavior with a replay test and a representative workload.
For broader architecture context, Vertex Frontierβs Postgres CDC in Production guide covers failover, retries, and duplicate-handling concerns before the data reaches ClickHouse. The Debezium vs. Estuary Flow vs. MaterializedPostgreSQL comparison is a natural companion when choosing the CDC path itself rather than diagnosing the ClickHouse table.
A practical test plan before production rollout
Run this as a repeatable validation exercise, not as a one-time happy-path demo.
- Insert one source row and confirm its identity and version in ClickHouse.
- Update a mutable attribute and confirm that
ORDER BYremains unchanged. - Deliver the same event twice and compare physical rows with the current-state result.
- Deliver an older event after a newer event and confirm the winner.
- Delete the row and confirm the tombstone has the same identity and a greater version.
- Replay an old pre-delete event and confirm it does not resurrect the row.
- Move events across connector restarts and partitions if the architecture allows it.
- Test a large partition under realistic write pressure and query concurrency.
- Compare plain reads,
FINALreads, and the intended serving layer. - Document the ClickHouse and connector versions, schema, settings, workload, and observed results.
If the test has no measured workload, do not publish a performance number. Publish the test method and the conditions that readers need to reproduce.
Final perspective
The reliable way to solve ClickHouse ReplacingMergeTree duplicates is not to chase the fastest merge. It is to define what βcorrectβ means for the reader, then trace that promise backward through the system.
First prove that the event arrived. Then prove that updates and deletes share one immutable identity. Prove that the version is ordered and that retries cannot create ambiguous winners. Inspect replication queues, parts, and merges. Finally, enforce current-state semantics with FINAL or a deliberately designed serving layer when the product cannot wait for background compaction.
For teams building the wider analytical platform, Vertex Frontierβs MySQL CDC and Apache Doris architecture article provides a useful adjacent comparison: CDC delivery and analytical serving are separate design decisions. Its Apache Iceberg guide is also relevant when deciding whether the system needs a table layer, a query engine, or both.
ReplacingMergeTree is powerful precisely because it separates ingestion from compaction. But that separation moves responsibility to the architecture. Once the team treats identity, ordering, delivery, merges, and query semantics as separate contracts, βduplicate lagβ stops being a mysterious ClickHouse failure and becomes a diagnosable production state.
The deduplication, late-arrival, and tombstone reasoning in this article is also a common interview topic β our Data Engineering Interview Questions & Answers guide covers the same trade-offs under the CDC and streaming sections.
Frequently asked questions
Why does ReplacingMergeTree still show duplicates after I insert the newest row?
FINAL or a current-state serving layer when the read must be correct before merges finish.Does ReplacingMergeTree deduplicate by PRIMARY KEY?
ORDER BY sorting-key tuple. The primary key may be a prefix used by the sparse index, so it should not be assumed to be the replacement identity.Should I use FINAL or OPTIMIZE FINAL to fix CDC duplicates?
FINAL is a query-time mechanism for applying replacement to the rows being read. OPTIMIZE TABLE ... FINAL forces a storage merge and can consume substantial resources. It may be appropriate for controlled maintenance, but it should not be the default response to every CDC duplicate alert.Can I use updated_at as the ReplacingMergeTree version?
Why are deleted rows still visible?
FINAL and is_deleted = 0.What should a CDC delete event contain?
ORDER BY expression, a version greater than the row it deletes, and the delete flag expected by the table model. Source replica-identity and connector settings determine whether those fields are available.Can a materialized view automatically remove duplicates after a merge?
Was this article helpful?










[…] If your sink is ClickHouse specifically, the duplicate story is different, see our guide to ReplacingMergeTree deduplication lag and tombstone handling. […]
[…] arrive late. In CDC systems, ordering and tombstones require careful design. Vertex Frontierβs ClickHouse ReplacingMergeTree guide explores why duplicates can persist even when a table appears to have a deduplication […]