skip to content

When does a ClickHouse ReplacingMergeTree table actually remove a duplicate row?

level: middleimportance: must knowfreq 78%

answer

  1. nothing happens when the row is written
  2. the background does it later, if at all
  3. identity comes from the sorting key
  4. merges never look across partition boundaries
  5. FINAL or argMax to be correct right now

basics

~20 s

Only when a background merge brings the duplicate rows together, and merges never cross partitions. Until then both rows are returned. ReplacingMergeTree gives eventual deduplication, not a uniqueness constraint, so use FINAL or argMax for correct reads now.

solid answer

~50 s

`ReplacingMergeTree` deduplicates **during background merges only**, never at insert time. Rows are duplicates when their full `ORDER BY` key matches; with a version parameter, `ReplacingMergeTree(ver)`, the merge keeps the row with the highest `ver`, otherwise the most recently inserted one wins. Two consequences bite people in production. First, merges are asynchronous and size-bounded, so a plain `SELECT` right after loading returns **both** rows — there is no uniqueness constraint anywhere in ClickHouse. Second, merges only ever combine parts **within one partition**, so two versions of the same key that land in different partitions never collapse at all. To read correct results before merges catch up you either use the `FINAL` modifier, which merges matching rows on the fly at query cost, or resolve it yourself with `GROUP BY key` plus `argMax(col, ver)`. Recent versions also support an `is_deleted` column so a tombstone row can retire a key.

code

sql · 23 lines
sql
CREATE TABLE users
(
    user_id  UInt64,
    name     String,
    updated  DateTime
)
ENGINE = ReplacingMergeTree(updated)
ORDER BY user_id;

INSERT INTO users VALUES (7, 'old name', '2026-01-01 00:00:00');
INSERT INTO users VALUES (7, 'new name', '2026-02-01 00:00:00');

-- Two separate parts: this returns BOTH rows until a merge runs
SELECT * FROM users WHERE user_id = 7;

-- Correct now, at query cost
SELECT * FROM users FINAL WHERE user_id = 7;

-- Correct now, using ordinary aggregation
SELECT user_id, argMax(name, updated) AS name
FROM users
WHERE user_id = 7
GROUP BY user_id;

go deeper

for a junior

Recall that duplicates are removed by a background process, not by the insert, and that a plain SELECT can return both rows. Know that FINAL exists as the simple way to read the deduplicated view.

for a middle

Explain the mechanics: identity is the full sorting key, the version parameter picks the survivor, merges are asynchronous and confined to one partition. Be able to write the argMax alternative to FINAL.

for a senior

Show the production judgment: there is no guarantee of eventual cleanup, so any correctness-critical query resolves at read time. Be ready to diagnose a table whose partitioning makes deduplication impossible.

for a principal

Own the contract you expose. Decide whether consumers query the raw table, a resolving view, or a downstream rollup, and justify that choice against read latency, cost and the risk of someone silently reading duplicates.

## What ReplacingMergeTree is for `ReplacingMergeTree` is the standard ClickHouse answer to "my source system sends updates and I want the latest state per key". It has exactly the same storage as `MergeTree` — immutable sorted parts, a sparse index, partitions, TTL — and differs in one respect: when a background merge encounters several rows with the same sorting key, it emits only one of them. ```sql CREATE TABLE users ( user_id UInt64, name String, updated DateTime ) ENGINE = ReplacingMergeTree(updated) ORDER BY user_id; ``` ## The deduplication key is the ORDER BY key This is the single most common misunderstanding. The identity of a row for deduplication purposes is its **full `ORDER BY` (sorting key)**, not a primary key you declared separately and not "all columns". If you write `ORDER BY (user_id, updated)`, then two rows for the same user with different `updated` values have *different* sorting keys and will never be deduplicated — the table simply grows. The sorting key must be exactly the business key you want one row per. ## When the duplicate actually disappears Deduplication happens **only inside a merge**, and merges have three properties that shape everything: 1. **They are asynchronous.** Nothing happens at insert time. Immediately after loading, both versions of the row are in separate parts and both are returned by a plain `SELECT`. 2. **They are size-bounded.** The background scheduler will not merge parts beyond a configured maximum resulting size (`max_bytes_to_merge_at_max_space_in_pool`), so a very large part may sit unmerged beside a small newer one indefinitely. There is no guarantee — and the documentation is explicit about this — that a duplicate is ever removed. 3. **They never cross partitions.** If the table is partitioned by month and an update for a key arrives with a timestamp in a different month, the old and new rows live in different partitions and can never be merged together. Partition by something stable relative to the key, or accept permanent duplicates. ## The version parameter With `ReplacingMergeTree(ver)` the merge keeps the row with the **maximum** `ver` for each sorting key; `ver` is typically a `DateTime` update timestamp or a monotonically increasing sequence number from the source. Without the parameter, the last row in the merge selection — effectively the most recently inserted among the parts being merged — survives, which is fine for genuinely idempotent replays and dangerous for out-of-order change data capture. Modern versions also accept a second parameter, `ReplacingMergeTree(ver, is_deleted)`, where a `UInt8` column marks a row as a tombstone so a key can be retired rather than just superseded; physically dropping those rows requires an explicit cleanup merge. Treat this as version-sensitive and check the behaviour on the version you actually run. ## Reading correct results before the merge Three practical options: - **`SELECT ... FINAL`** asks ClickHouse to merge the matching rows on the fly during the query. It is correct and simple, and it costs — the engine must read and collapse overlapping key ranges rather than stream parts independently. Modern versions parallelise it far better than old ones did, and it is a legitimate production choice on tables whose parts are reasonably merged. - **Explicit resolution:** `SELECT user_id, argMax(name, updated) FROM users GROUP BY user_id`. This is deterministic, uses ordinary aggregation, and lets you resolve only the columns you need. - **Filtering on the version** when your access pattern allows, for example reading only the newest partition. A common shape is to expose one of the latter two through a view so that consumers cannot accidentally query the raw table. ## What it is not ReplacingMergeTree is not a uniqueness constraint, not a primary-key enforcement mechanism, and not an upsert that happens at write time. ClickHouse has no constraint that rejects a duplicate insert. (A separate, unrelated feature — insert deduplication on replicated tables — discards a re-sent *identical block* by hash, which protects retries but has nothing to do with row-level replacement.) ## How to answer Lead with "only during background merges, eventually, and only within a partition", then name the version parameter and the read-time remedies. Candidates who say "it deduplicates on insert" have not run it in production, and interviewers ask this question precisely to find out.

  • Why does adding the update timestamp to the ORDER BY key break ReplacingMergeTree?
    Because the sorting key *is* the deduplication key. With `ORDER BY (user_id, updated)` two versions of the same user have different keys, so a merge sees no duplicates and keeps both rows forever. The version column belongs in the engine parameter, not in `ORDER BY`. The correct shape is `ORDER BY user_id` with `ReplacingMergeTree(updated)`.
  • What is the cost of using FINAL on every query, and when is that acceptable?
    `FINAL` forces the engine to read overlapping key ranges together and collapse them, so it cannot stream parts fully independently and does more work per row than a plain scan. On a well-merged table with selective filters the overhead is often acceptable, and modern versions parallelise it well. It becomes painful on tables with many unmerged parts or on full-table scans, where `GROUP BY` with `argMax` is usually cheaper.
  • Your ReplacingMergeTree table is partitioned by month on the event date and duplicates never disappear. Why?
    Merges only combine parts within the same partition. If an updated row for a key carries a date that maps to a different month, the two versions sit in different partitions and can never be merged together, so deduplication is impossible by construction. Partition by something aligned with the key's lifetime — or do not partition that table at all — and resolve at read time.
  • Does ClickHouse ever guarantee that duplicates in a ReplacingMergeTree table are eventually gone?
    No. Merges are best-effort and size-bounded: once a part reaches the maximum merge size the scheduler will not keep rewriting it, so a stale row can persist indefinitely. Any query that must be correct has to deduplicate at read time with `FINAL` or `argMax`, or read through a view that does. Treating the raw table as unique is the classic production bug.

saying these in an interview costs you the question

  • Says ReplacingMergeTree deduplicates rows at insert time
  • Treats the sorting key as a uniqueness constraint
  • Puts the version column into ORDER BY alongside the key
  • Assumes duplicates are always eventually removed
  • Forgets that merges never cross partitions

context