Deduplicating Rows with ReplacingMergeTree
ReplacingMergeTree deduplicates rows that share an ORDER BY key by keeping only the highest-version row when a background merge combines them — but the collapse is asynchronous, so duplicates stay visible until a merge fires and only correct queries hide them in the meantime. This walkthrough builds a deduplicating table with a version column and an is_deleted tombstone, proves duplicates persist pre-merge with system.parts, and lays out the FINAL, do_not_merge_across_partitions_select_final, and OPTIMIZE … FINAL trade-offs so reads stay correct without paying merge cost on every query.
Prerequisites
How deduplication happens at merge time
An INSERT never deduplicates. Each statement writes a new part containing its rows exactly as given, so two writes of the same key produce two parts, each with that key. Deduplication is a side effect of the background merge that later combines those parts: as the merge streams rows in sorted ORDER BY order, a run of rows sharing a key is reduced to the single row with the maximum version, and if that survivor has is_deleted = 1, a FINAL merge drops it entirely.
Because a merge only ever runs within a partition and only when the scheduler decides two parts are worth combining, there is no guaranteed moment at which the table is fully deduplicated. The engine gives you eventual convergence, not immediate uniqueness — which is why the read path, not the insert path, is where correctness is enforced.
Step 1 — Create the table with a version and tombstone
Key the table on the entity identity and pass both a version column and an is_deleted column to the engine. Partition so that every version of a key lands in the same partition — for a pure latest-state table that usually means PARTITION BY tuple(), because partitioning by a mutable updated_at would scatter versions of one key across partitions where merges can never unite them.
CREATE TABLE analytics.account_state
(
`account_id` UInt64,
`status` LowCardinality(String),
`balance` Decimal(18, 2),
`updated_at` DateTime64(3),
`version` UInt64, -- highest wins on collapse
`is_deleted` UInt8 DEFAULT 0 -- 1 = tombstone, dropped on FINAL
)
ENGINE = ReplacingMergeTree(version, is_deleted)
PARTITION BY tuple()
ORDER BY account_id;
Expected: the table registers with engine = ReplacingMergeTree. The version argument makes the newest logical write win independent of insert order; the is_deleted argument turns a soft delete into a real removal at merge time.
Step 2 — Insert duplicates in separate statements
Separate INSERT statements guarantee separate parts, which is what lets you observe the pre-merge state. Write three versions of one account plus an unrelated account:
INSERT INTO analytics.account_state VALUES (100, 'active', 50.00, now64(3), 1, 0);
INSERT INTO analytics.account_state VALUES (100, 'active', 75.00, now64(3), 2, 0);
INSERT INTO analytics.account_state VALUES (100, 'closed', 0.00, now64(3), 3, 1);
INSERT INTO analytics.account_state VALUES (200, 'active', 10.00, now64(3), 1, 0);
Expected: four rows, three parts for account_id = 100 (one row each) and one for 200.
Step 3 — Confirm the duplicates persist before any merge
A plain count sees every part. This is the behavior that surprises engineers who expect insert-time deduplication:
SELECT count() FROM analytics.account_state; -- 4 rows still present
SELECT account_id, count() AS versions
FROM analytics.account_state
GROUP BY account_id; -- account 100 -> 3
Now confirm the parts really are separate on disk:
SELECT name, partition, rows, active
FROM system.parts
WHERE database = 'analytics' AND table = 'account_state' AND active
ORDER BY name;
Expected: four active parts, each with rows = 1. Nothing has collapsed because no merge has run.
Step 4 — Read correctly with FINAL
FINAL applies the collapse on the read path: it merges the relevant parts on the fly, keeps the max-version row per key, and drops tombstoned survivors. It is the simplest correct read.
SELECT account_id, status, balance, version
FROM analytics.account_state FINAL;
Expected: exactly one row — account_id = 200. Account 100 collapses to its version = 3 row, which is tombstoned (is_deleted = 1), so FINAL removes it entirely. To keep the tombstone logic explicit and avoid the merge cost, the argMax idiom reproduces the same result at query time:
SELECT account_id, argMax(status, version) AS status, argMax(balance, version) AS balance
FROM analytics.account_state
GROUP BY account_id
HAVING argMax(is_deleted, version) = 0; -- same one-row result, no FINAL merge
Step 5 — Force convergence with OPTIMIZE when you must
OPTIMIZE TABLE … FINAL forces a merge of all parts in a partition down to one, physically deduplicating the data. It is useful after a bulk backfill or before a full-table export, but it is an expensive, I/O-heavy operation that rewrites entire parts and competes with live merges — never put it in a query path or a per-batch loop.
OPTIMIZE TABLE analytics.account_state FINAL;
-- After it completes, the plain count matches the FINAL count.
SELECT count() FROM analytics.account_state; -- now 1
Expected: one active part per partition and a plain count that equals the FINAL count. Reach for OPTIMIZE … FINAL as an occasional maintenance action, and lean on the automatic min_age_to_force_merge_seconds setting for routine convergence instead.
Verification
Confirm the state converged by comparing the raw and FINAL counts and inspecting part consolidation in system.parts:
SELECT
(SELECT count() FROM analytics.account_state) AS raw_rows,
(SELECT count() FROM analytics.account_state FINAL) AS deduped_rows;
When these two numbers are equal, every part has collapsed. Track the part count over time — a persistently high count means merges are not keeping pace with writes:
SELECT partition, count() AS active_parts, sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS size
FROM system.parts
WHERE database = 'analytics' AND table = 'account_state' AND active
GROUP BY partition
ORDER BY partition;
To make FINAL cheaper on partitioned tables whose key never spans partitions, enable do_not_merge_across_partitions_select_final so FINAL only reconciles within each partition:
SELECT account_id, status
FROM analytics.account_state FINAL
SETTINGS do_not_merge_across_partitions_select_final = 1;
Gotchas and edge cases
- Duplicates across partitions never collapse. Merges are partition-scoped, so partitioning by a column that changes between versions of a key (like
updated_at) strands duplicates permanently. Keep the version history of a key inside one partition —PARTITION BY tuple()for latest-state tables. - No version argument means order-dependent results.
ReplacingMergeTree()with no version keeps a row from the most recently inserted part, so a late-arriving out-of-order write can overwrite a newer one. Always supply a monotonicversionunless writes are strictly ordered. FINALcost scales with part count, not result size. AFINALread merges parts on the fly, so a table with thousands of un-merged parts makes even a tiny lookup slow. Batch inserts and let background merges (ormin_age_to_force_merge_seconds) keep the part count low.is_deletedneedsFINALto actually delete. WithoutFINAL(orclean_deleted_rows), a tombstoned row is only hidden by theFINAL-aware read; the physical row and its storage remain until aFINALmerge removes it.OPTIMIZE … FINALis not idempotent-cheap. It rewrites whole parts every time and can build a part larger thanmax_bytes_to_merge_at_max_space_in_pool, after which the engine will never merge it again automatically — so a giant table can end with a permanent split that onlyFINALreads reconcile.
Related
- MergeTree engine variants — where ReplacingMergeTree sits in the family and how its collapse rule compares.
- ReplacingMergeTree vs AggregatingMergeTree vs SummingMergeTree — choosing dedup over aggregation.
- How MergeTree handles background merging — why collapse is deferred and how the scheduler decides to merge.