Skip to content
About
Main content

Set and dedup operations: aggregation with an empty accumulator

How do UNION, INTERSECT, EXCEPT and DISTINCT follow from what we already know, and which shortcut is dangerous?

Deduplication is aggregation with an empty accumulator

  • Hash dedup is the hash from Aggregation algorithms with nothing in the value slot.
  • Sort dedup is the sort-based aggregation from Aggregation algorithms with nothing in the value slot.
  • INTERSECT and EXCEPT are the and from the earlier articles, with every column as the key. (A semi-join keeps rows of one side that have a match on the other; an anti-join keeps rows that have none.)

Almost the entire article has already been derived. Noticing that is worth more than the article itself: the operator catalogue has real structure instead of being a list, and four mechanisms cover eleven surface features. What is left is one thing, and it is the most dangerous item here.


The state parameter, and the one inversion from aggregation

Dedup's state is O(). Its output is NDV rows. Its two-phase arithmetic is the same as in Aggregation algorithms: local dedup, by key, final dedup, with the N/(P·G), where G is the distinct count. Everything transfers.

One thing inverts, and exactly halfway. In Aggregation algorithms, the state of is a cache, not a requirement: flush it whenever you like, the final merge fixes it. For dedup this holds for the local stage and fails for the final stage.

  • A local dedup that flushes early emits some duplicates, which the final stage removes.
  • A final dedup that forgets a key emits it twice, and nothing downstream corrects it.

So partial dedup is safely flushable, and final dedup is not. The final stage must fit in memory, , or be replaced by a sort.

This is why sort-based dedup is more attractive here than in aggregation. Sort dedup uses O(1) memory (compare each key to the previous one, emit when it changes) and cannot fail on memory at any input size. If the input is already sorted, which it often is after a merge , an LSM read or a clustered scan, dedup is free and fully .

There is also a latency asymmetry that does not match the earlier articles. Hash dedup emits a row the moment it is first seen. It is state-blocking (it must keep all keys) but not latency-blocking (it does not make you wait). Sort dedup is both. In aggregation, both variants were latency-blocking, so hash had no latency advantage. In dedup it does. The hash/sort duality is real, but its balance point moves with the operator. This is the third time that has come up.


Wide keys: hash the digest, and use 128 bits

DISTINCT on a full row, INTERSECT and EXCEPT all use every column as the key, so the key is wide. Two consequences follow from the sorting article.

  1. pay off here more than anywhere, because a multi-column typed comparison is exactly what they remove.
  2. The reduction ratio N/(P·G) tends toward 1 when the key is the whole row. Nearly unique keys mean nearly no reduction. So partial dedup usually does not pay for set operations on wide rows, unlike grouped aggregation, where it almost always pays.

The standard implementation hashes each row to a fixed-width digest (a short fingerprint) and dedups on the digest. The width matters more than people think. With a b-bit digest and n rows, the probability that at least two rows collide is roughly 1 − exp(−n² / 2^(b+1)) (the "birthday" formula).

rows hashed
bits in the digest
64-bit digest96-bit digest
Data table
Chance of at least one collision, by rows hashed
Rows nseriesCollision probability
1M64-bit digest0%
1.19M64-bit digest0%
1.41M64-bit digest0%
1.68M64-bit digest0%
2M64-bit digest0%
2.37M64-bit digest0%
2.82M64-bit digest0%
3.35M64-bit digest0%
3.98M64-bit digest0%
4.73M64-bit digest0%
5.62M64-bit digest0%
6.68M64-bit digest0%
7.94M64-bit digest0%
9.44M64-bit digest0%
11.2M64-bit digest0%
13.3M64-bit digest0%
15.8M64-bit digest0.001%
18.8M64-bit digest0.001%
22.4M64-bit digest0.001%
26.6M64-bit digest0.002%
31.6M64-bit digest0.003%
37.6M64-bit digest0.004%
44.7M64-bit digest0.005%
53.1M64-bit digest0.008%
63.1M64-bit digest0.011%
75M64-bit digest0.015%
89.1M64-bit digest0.022%
106M64-bit digest0.03%
126M64-bit digest0.043%
150M64-bit digest0.061%
178M64-bit digest0.086%
211M64-bit digest0.121%
251M64-bit digest0.171%
299M64-bit digest0.241%
355M64-bit digest0.341%
422M64-bit digest0.481%
501M64-bit digest0.679%
596M64-bit digest0.957%
708M64-bit digest1.35%
841M64-bit digest1.9%
1B64-bit digest2.67%
1.19B64-bit digest3.76%
1.41B64-bit digest5.26%
1.68B64-bit digest7.35%
2B64-bit digest10.2%
2.37B64-bit digest14.1%
2.82B64-bit digest19.4%
3.35B64-bit digest26.2%
3.98B64-bit digest34.9%
4.73B64-bit digest45.5%
5.62B64-bit digest57.6%
6.68B64-bit digest70.2%
7.94B64-bit digest81.9%
9.44B64-bit digest91.1%
11.2B64-bit digest96.7%
13.3B64-bit digest99.2%
15.8B64-bit digest99.9%
18.8B64-bit digest100%
22.4B64-bit digest100%
26.6B64-bit digest100%
31.6B64-bit digest100%
37.6B64-bit digest100%
44.7B64-bit digest100%
53.1B64-bit digest100%
63.1B64-bit digest100%
75B64-bit digest100%
89.1B64-bit digest100%
106B64-bit digest100%
126B64-bit digest100%
150B64-bit digest100%
178B64-bit digest100%
211B64-bit digest100%
251B64-bit digest100%
299B64-bit digest100%
355B64-bit digest100%
422B64-bit digest100%
501B64-bit digest100%
596B64-bit digest100%
708B64-bit digest100%
841B64-bit digest100%
1,000B64-bit digest100%
3.35M96-bit digest0%
3.98M96-bit digest0%
4.73M96-bit digest0%
5.62M96-bit digest0%
6.68M96-bit digest0%
7.94M96-bit digest0%
9.44M96-bit digest0%
11.2M96-bit digest0%
13.3M96-bit digest0%
15.8M96-bit digest0%
18.8M96-bit digest0%
22.4M96-bit digest0%
26.6M96-bit digest0%
31.6M96-bit digest0%
37.6M96-bit digest0%
44.7M96-bit digest0%
53.1M96-bit digest0%
63.1M96-bit digest0%
75M96-bit digest0%
89.1M96-bit digest0%
106M96-bit digest0%
126M96-bit digest0%
150M96-bit digest0%
178M96-bit digest0%
211M96-bit digest0%
251M96-bit digest0%
299M96-bit digest0%
355M96-bit digest0%
422M96-bit digest0%
501M96-bit digest0%
596M96-bit digest0%
708M96-bit digest0%
841M96-bit digest0%
1B96-bit digest0%
1.19B96-bit digest0%
1.41B96-bit digest0%
1.68B96-bit digest0%
2B96-bit digest0%
2.37B96-bit digest0%
2.82B96-bit digest0%
3.35B96-bit digest0%
3.98B96-bit digest0%
4.73B96-bit digest0%
5.62B96-bit digest0%
6.68B96-bit digest0%
7.94B96-bit digest0%
9.44B96-bit digest0%
11.2B96-bit digest0%
13.3B96-bit digest0%
15.8B96-bit digest0%
18.8B96-bit digest0%
22.4B96-bit digest0%
26.6B96-bit digest0%
31.6B96-bit digest0%
37.6B96-bit digest0%
44.7B96-bit digest0%
53.1B96-bit digest0%
63.1B96-bit digest0%
75B96-bit digest0%
89.1B96-bit digest0%
106B96-bit digest0%
126B96-bit digest0%
150B96-bit digest0%
178B96-bit digest0%
211B96-bit digest0%
251B96-bit digest0%
299B96-bit digest0%
355B96-bit digest0%
422B96-bit digest0%
501B96-bit digest0%
596B96-bit digest0%
708B96-bit digest0%
841B96-bit digest0%
1,000B96-bit digest0.001%

At ten billion rows a collision is not a risk. It is the expected result. And in dedup, a digest collision means a row is thrown away as a "duplicate" when it was not one.


Approximate dedup: the same filter, the opposite consequence

The phrase "sketch-based approximate dedup" mixes up two different problems. Naming the mix-up is the correction.

Approximate distinct counting ( and its relatives) answers "how many distinct values are there?" It belongs to the aggregation article, because it is an aggregation. It has no membership query at all: HLL cannot tell you whether a particular row has been seen. It only knows how many distinct items it absorbed.

Approximate duplicate removal needs a membership structure, such as a or a cuckoo filter. These answer "have I seen this key?" with one-sided error: no false negatives, some false positives (they may say "yes, seen" when the answer is no, but never say "no" when the answer is yes). Here is the whole point.

  • In the join article, a Bloom filter's false positive meant a probe row passed the filter, reached the exact join, and was correctly rejected there. The error cost work.
  • In dedup, a false positive means the filter says "seen before" about a row that was not seen. The row is dropped. No exact test downstream catches it. The error costs data, silently and permanently.

Same structure, same mathematics, same library call, opposite consequence. This refines the earlier principle "a conservative pre-filter keeps the answer exact":

The repair is to invert the test. Use the filter as a :

  • "Definitely not present" means certainly new: emit at once, no lookup.
  • "Possibly present" means check the exact structure.

Now a false positive costs a lookup instead of a row, and the exact structure sees only a small share of the traffic. This is how an LSM-tree point lookup uses a Bloom filter to skip files: the same pattern, arriving here again. A of the pattern:

def dedup_stream(rows, bloom, exact_store):
    for key, row in rows:
        if not bloom.might_contain(key):   # definitely new
            bloom.add(key); exact_store.add(key)
            yield row
        elif key not in exact_store:       # false positive: filter said "maybe"
            bloom.add(key); exact_store.add(key)
            yield row
        # else: truly seen before, drop

(The exact store still needs memory or disk proportional to the number of distinct keys; the filter only saves lookups.)

Where approximate dedup is acceptable:

  • best-effort telemetry, where volume control matters more than completeness;
  • pipelines whose consumer is itself idempotent (repeating or missing a suppression can be repaired);
  • bounded-window dedup, where the filter is reset periodically, so the error is bounded in time instead of accumulating forever.

Where it is not acceptable: anything that produces a reported figure. It is deployed there anyway, routinely, because a 0.1% silent row loss is invisible to every monitor anyone builds. Who pays when the assumption breaks? The downstream consumer, in numbers that are quietly and permanently a little low.

This is also the one place here where a point on the exact-versus-approximate scale is chosen wrongly by default, because the technique is imported from a setting where its error was harmless. (the system keeps running with worse results) with no matching fail-fast signal (nothing tells anyone). It degrades without telling anybody, which is the worst position on that scale.