skip to content

In a sort-merge join, what has to happen when the join key has duplicate values on both sides, and why does that force the algorithm to re-read part of one input?

level: seniorimportance: should knowfreq 32%

answer

  1. mark the inner group, restore per outer duplicate
  2. output = product of group sizes
  3. inner must be re-readable, else materialise
  4. unique side means zero rewinds
  5. one hot key = quadratic blowup

basics

~20 s

Equal keys on both sides form groups whose full cross product must be emitted. The algorithm marks the start of the inner group and rewinds to that mark for every row of the outer group, so inner rows are read once per outer duplicate. Output for that key is the product of the two group sizes.

solid answer

~50 s

The pure two-cursor sweep only works when at least one side has unique keys. With duplicates on both sides, every row of the left group matches every row of the right group, so the operator marks the first row of the right group and, after finishing a left row, restores the cursor to that mark for the next left row. Only when the left group is exhausted does it advance past the right group. Consequences: the inner group is scanned once per outer duplicate rather than once overall, so it is no longer strictly one pass; the engine may buffer or materialise the group if the source cannot cheaply rewind; and a heavily skewed key becomes a quadratic blowup for that value. If one side is unique on the join key — a primary-key or foreign-key join — no rewind ever happens, which is a large part of why merge joins are efficient on such joins.

go deeper

for a junior

Know that duplicates on both sides mean every left row pairs with every right row for that key.

for a middle

Describe mark and restore concretely and note that the inner group gets re-read once per outer duplicate.

for a senior

Add the operational angle: re-readability, materialisation and spill of a large group, skew as the failure mode, and how uniqueness removes rewinds.

for a principal

Discuss designing keys and constraints so many-to-many merges do not arise, and how to bound blast radius when one value dominates the distribution.

## Why duplicates break the simple sweep The textbook merge is: compare current keys, advance the smaller, emit on equal. That is correct only if equal keys never repeat on both sides at once. Relational joins operate on multisets: if key 7 appears three times on the left and four times on the right, the join must produce all twelve pairs. A cursor that advanced both sides on a match would produce three or four pairs, not twelve. ## Mark and restore The standard fix is a mark/restore protocol on the inner input. On reaching a matching key, the operator marks the position of the first inner row of that key group. It pairs the current outer row with every inner row of the group. When the group ends it takes the next outer row; if that row has the same key, it restores the inner cursor to the mark and replays the group. Only when the outer key changes does it release the mark and let the inner cursor move past the group. ## Cost implications For a key with L left rows and R right rows, the operator produces L times R output rows and reads the inner group L times. Summed over keys, the work is proportional to the output size, which is the honest framing: a many-to-many join is expensive because the answer is large, not because the algorithm is bad. What is genuinely lost is the clean one-pass property — the inner side must be re-readable. ## Re-readability and buffering Not every input can be rewound cheaply. An index scan can be repositioned; the output of an arbitrary pipeline generally cannot. Engines handle this by materialising or buffering the inner group, or inserting a spool operator between the child and the merge join. If a single key group is huge, that buffer can exceed memory and spill, so a skewed join key hurts merge join here just as it hurts hash join in the build phase. ## Skew is the practical failure mode One popular value — a default tenant, an unknown-customer sentinel row — with tens of thousands of rows on both sides produces hundreds of millions of pairs from a single key. Plans look fine on average statistics and then one value dominates runtime. The signal is a plan whose estimated rows are modest but whose actual output count is orders of magnitude higher, concentrated in one key. ## When rewinds disappear If the inner side is unique on the join key — joining to a dimension by primary key, or any foreign-key join into the parent — each group has exactly one inner row, so a match consumes it and the sweep stays one pass. Optimizers learn the uniqueness from constraints and index metadata and cost the join accordingly, which is one reason declaring uniqueness improves plan quality rather than being mere documentation. ## Related subtlety: NULLs Under standard equality semantics NULL keys match nothing, so rows with NULL join keys sort together at one end and are skipped by the merge without producing pairs. They still cost the sort, which is a reason to filter them earlier when they are numerous.

  • How does a unique constraint or unique index on the join key change how the optimizer costs a merge join?
    Uniqueness guarantees at most one inner row per key, so no mark/restore is needed and the join output cannot exceed the outer input size. The optimizer uses that to estimate cardinality tightly and to drop any materialisation the rewind would otherwise require, which usually makes the merge join look cheaper and more predictable.
  • You see a merge join whose actual row count is a thousand times its estimate. What do you suspect?
    Almost always key skew combined with many-to-many matching: one or a few values have large groups on both sides and their cross products dominate the output. Check the frequency distribution of the join key, whether a sentinel value acts as a catch-all, and whether a predicate that was supposed to make one side unique is missing.

Comparing two sorted stacks of forms by ID: when both stacks contain several copies of ID 7, you must hold your place in the second stack and flip back through its 7s for each 7 in the first.

saying these in an interview costs you the question

  • Saying the merge join simply advances both cursors on a match — that loses rows when both sides have duplicates
  • Claiming merge join is always exactly one pass over each input regardless of duplicates
  • Believing skew only hurts hash joins
  • Assuming duplicates are deduplicated by the join — a join emits the full cross product per key group

context