Deduplicating Inserts with Insert Deduplication
ClickHouse ships a block-level insert deduplication mechanism that makes a retried INSERT a no-op instead of a double-write: every inserted block carries a hash (or an explicit token), and the server drops any block whose identifier it has already committed. This guide shows how automatic block dedup works on replicated tables through Keeper, how insert_deduplication_token extends the same guarantee to precise application-defined boundaries, how to size the dedup window on non-replicated tables, and how to prove idempotency by reading system.parts and system.query_log — the safety net that makes the retry loops in batch insert optimization correct rather than merely fast.
Prerequisites
What “block-level” deduplication actually means
Deduplication in ClickHouse is not row-level and not based on primary key. When you insert, the server splits your payload into blocks and computes a hash of each block’s contents (or uses the token you supply). On a ReplicatedMergeTree, that identifier is recorded in Keeper alongside the committed part; on a plain MergeTree, it is kept in an in-memory ring buffer sized by non_replicated_deduplication_window. If the same identifier arrives again — because a client timed out mid-write and retried — the block is recognized and silently skipped. The critical consequence: two attempts must produce byte-identical blocks (or share a token) for dedup to fire. Reorder the rows, resize the batch, or let a fresh token slip in, and the safety net has a hole.
Step 1 — Create a replicated table so dedup is automatic
On a ReplicatedMergeTree, insert deduplication is on by default (replicated_deduplication_window = 100 parts). No token is required for the common case: identical blocks are caught automatically because their content hash is stored in Keeper.
CREATE TABLE analytics.events_repl
(
`event_id` UUID,
`event_ts` DateTime64(3),
`user_id` UInt64,
`event_type` LowCardinality(String),
`payload` String
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events_repl', '{replica}')
PARTITION BY toYYYYMMDD(event_ts)
ORDER BY (event_type, user_id, event_ts)
SETTINGS
replicated_deduplication_window = 100, -- last 100 block hashes tracked in Keeper
replicated_deduplication_window_seconds = 604800; -- 7-day retention of those hashes
The window is a sliding count of recent blocks, not an all-time set. If more than 100 distinct blocks (or 7 days) pass between the original insert and a retry, the hash has aged out and the duplicate will be admitted — size the window to comfortably exceed your maximum retry latency.
Step 2 — Attach an explicit insert_deduplication_token per logical batch
Content hashing breaks the moment two attempts differ by a byte — a re-serialized JSON payload, a reordered batch, a regenerated UUID. Bind an explicit insert_deduplication_token derived from the source identity (a Kafka topic-partition-offset range, a file name plus byte range, a batch sequence number) so the identifier is stable regardless of block contents.
import clickhouse_connect
client = clickhouse_connect.get_client(host="clickhouse", database="analytics")
def insert_batch(rows, batch_key: str, attempt: int = 0):
# The token is derived from the SOURCE, so every retry of this logical
# batch carries the same value even if the bytes differ slightly.
token = f"events-{batch_key}" # e.g. "events-topicA-3-100..200"
try:
client.insert(
"events_repl",
rows,
column_names=["event_id", "event_ts", "user_id", "event_type", "payload"],
settings={"insert_deduplication_token": token},
)
except clickhouse_connect.driver.exceptions.OperationalError:
if attempt < 3:
return insert_batch(rows, batch_key, attempt + 1) # same token → safe
raise
Because the retry reuses token verbatim, the second insert is recognized and skipped even though the network failure left the client unsure whether the first one committed. This is the crux of making an at-least-once delivery source effectively-once at the ClickHouse boundary.
Step 3 — Size the window on non-replicated tables
Plain MergeTree tables also deduplicate when a token is supplied, but the identifiers live in an in-memory ring buffer rather than Keeper. Its size is non_replicated_deduplication_window, which defaults to 0 (disabled) — you must enable it.
ALTER TABLE analytics.events_local
MODIFY SETTING non_replicated_deduplication_window = 1000;
With the window set, an insert_deduplication_token on a non-replicated table is honored for the last 1000 blocks. Because the buffer is in memory, it does not survive a server restart — non-replicated dedup is best-effort across process lifetime, whereas replicated dedup is durable in Keeper. For anything that must survive a restart, use a replicated table.
Step 4 — Keep batch boundaries stable across retries
Dedup is per block, so a retry must reconstruct the same blocks. If attempt 1 sent 500k rows as one block and the retry re-chunks into two 250k blocks, neither new block matches the stored identifier and both are admitted as duplicates. Freeze the batching before the first send and replay the identical partitioning on retry.
def stable_chunks(rows, chunk_rows=500_000):
# Deterministic slicing: the same input always yields the same chunks,
# so a retry regenerates byte-identical blocks (and identical tokens).
for i, start in enumerate(range(0, len(rows), chunk_rows)):
yield i, rows[start:start + chunk_rows]
for idx, chunk in stable_chunks(all_rows):
insert_batch(chunk, batch_key=f"{source_file}-{idx}")
Pairing a deterministic chunker with a per-chunk token gives every block a stable identity, which is what the deduplication window is comparing against.
Verification
First, prove that a deliberate duplicate insert did not create a second part. Insert a batch, insert it again with the same token, then count parts:
SELECT partition, count() AS active_parts, sum(rows) AS rows
FROM system.parts
WHERE database = 'analytics' AND table = 'events_repl' AND active
GROUP BY partition
ORDER BY partition;
The row count must be unchanged after the duplicate attempt. Next, confirm the server actually skipped the block rather than writing zero rows by accident — a deduplicated insert appears in system.query_log with written_rows = 0 even though the query succeeded (flush logs first with SYSTEM FLUSH LOGS):
SELECT event_time, written_rows, result_rows, query_duration_ms,
Settings['insert_deduplication_token'] AS token
FROM system.query_log
WHERE type = 'QueryFinish' AND query_kind = 'Insert'
AND has(tables, 'analytics.events_repl')
AND event_time > now() - INTERVAL 15 MINUTE
ORDER BY event_time DESC
LIMIT 10;
The original insert shows written_rows equal to the batch size; the deduplicated retry shows written_rows = 0 with the same token. That pair — full write then zero-write with a matching token — is the signature of dedup doing its job.
Gotchas and edge cases
- A fresh token per attempt defeats the whole mechanism. Generating a UUID token inside the retry loop means every attempt looks new and every retry double-writes. Derive the token from the immutable source coordinates, compute it once, and reuse it for all attempts of that batch.
- Dedup is per block, not per row. If one row inside a block changed but the rest repeat, the block hash differs and the entire block — including the repeated rows — is admitted. Deduplication cannot merge partially overlapping batches; that is a job for a ReplacingMergeTree keyed on a version column.
- The window ages out under high insert rates.
replicated_deduplication_window = 100means only the last 100 blocks are remembered; a firehose can push a batch’s hash out of the window in seconds, so a slow retry sneaks a duplicate through. Widen the window (count and seconds) to exceed your worst-case retry delay. - Materialized views deduplicate independently. With
deduplicate_blocks_in_dependent_materialized_viewsat its default, a view fed by a deduplicated insert may still process or skip differently than its source. If a downstream aggregate double-counts on retry, enable that setting so the dedup decision propagates to attached views built with the materialized view creation patterns.
Related
- Batch Insert Optimization — the parent pattern whose retry loops this makes idempotent.
- Bulk Inserting with clickhouse-connect — where the per-insert token setting is attached in Python.
- Deduplicating Rows with ReplacingMergeTree — row-level dedup for overlaps that block dedup cannot catch.
- Using Python Asyncio for Concurrent ClickHouse Inserts — concurrent writers whose failed batches you retry safely with tokens.
- Recovering from ClickHouse Keeper Quorum Loss — what happens to the dedup registry when Keeper is unavailable.