skip to content

To reconcile two datasets you compute the difference in both directions — rows in A not in B, and rows in B not in A. What properties of set-difference semantics can make that result misleading, and how do you make the comparison trustworthy?

level: seniorimportance: should knowfreq 42%

answer

  1. A − B ≠ B − A: need both directions
  2. dedup hides multiplicity → EXCEPT ALL or count check
  3. whole-row, positional → column order/type/whitespace drift
  4. diff size == input size ⇒ shape problem, not data problem
  5. ship a key-based full outer join with per-column report

basics

~20 s

Set difference deduplicates, so count mismatches vanish; it compares whole rows positionally, so a column-order or type-coercion difference makes everything look different; and it says which rows differ, not which columns. Compare on a key with a value-level check instead.

solid answer

~60 s

Three properties bite: 1. **Difference is not symmetric**, so you genuinely need both directions — one direction alone hides rows that exist only on the other side. That part is fine; people just forget the second pass. 2. **The default form eliminates duplicates.** If A holds a row three times and B holds it once, `A EXCEPT B` returns nothing and the reconciliation reports "identical" while the counts differ. Use the `ALL` variant, or reconcile counts separately. 3. **Matching is whole-row and positional.** One extra column, a swapped column order with compatible types, a trailing space, a numeric scale difference, or a timestamp precision difference makes every row appear on both sides of the diff — a "total mismatch" that is really a formatting artifact. And nulls match under not-distinct-from, unlike `=`. The robust pattern is a **key-based full outer join**: match on the business key, then classify rows as left-only, right-only, or present-both-with-differing-columns, and report *which* columns differ. Difference tells you a row is different; it never tells you why.

code

sql · 15 lines
sql
-- naive: dedupes, compares whole rows positionally, no explanation
(SELECT * FROM a EXCEPT SELECT * FROM b)
UNION ALL
(SELECT * FROM b EXCEPT SELECT * FROM a);

-- robust: key-based, null-safe, classifies the difference
SELECT COALESCE(a.id, b.id) AS id,
       CASE WHEN a.id IS NULL THEN 'RIGHT_ONLY'
            WHEN b.id IS NULL THEN 'LEFT_ONLY'
            ELSE 'VALUE_DIFF' END AS kind
FROM a FULL OUTER JOIN b ON a.id = b.id
WHERE a.id IS NULL
   OR b.id IS NULL
   OR a.amount IS DISTINCT FROM b.amount
   OR a.status IS DISTINCT FROM b.status;

go deeper

for a junior

Know that difference is asymmetric so both directions are needed, and that the default form removes duplicates.

for a middle

Add the whole-row positional matching hazard — column order, types, whitespace, timestamp precision — and the EXCEPT ALL fix for multiplicity.

for a senior

Design the real thing: assert key uniqueness, use a key-based full outer join with null-safe column comparison, report which columns drifted, and chunk by partition to bound memory.

for a principal

Treat reconciliation as a data contract: canonicalise types and text at the boundary, add independent count and aggregate checks per partition, and make the report actionable and restartable rather than a single opaque row dump.

## Why bidirectional difference is the natural first instinct Set difference is **not commutative**: `A − B` is the rows of A absent from B, and `B − A` is the reverse. Neither alone answers "are these two datasets the same?", so computing both — sometimes combined as the symmetric difference `(A − B) ∪ (B − A)` — is the obvious reconciliation. If both directions come back empty, the two relations are equal *as sets*. That last qualifier is where all the trouble lives. ## Pitfall 1: duplicate collapse hides multiplicity differences The non-ALL forms of the set operators deduplicate. So if A contains a row three times and B contains it once, `A EXCEPT B` yields the empty relation. Both directions come back clean and you report "reconciled" while one side has two extra rows — precisely the kind of error a reconciliation exists to catch. Fixes: - Use `EXCEPT ALL` in both directions where the engine supports it: it returns `max(m − n, 0)` copies and so surfaces the multiplicity gap directly. - Or reconcile a **count** alongside the set diff — a simple `COUNT(*)` per side, and a per-key count comparison. - Or, better, establish that the key is unique on both sides first, which makes multiplicity a non-issue and is usually the real requirement anyway. ## Pitfall 2: whole-row, positional matching turns cosmetic differences into total mismatches Set operators compare **entire rows**, matched by column position. Any of the following makes every row differ: - **Column order** differing between the two branches (with compatible types, no error is raised). - **An extra or missing column** on one side — this one at least errors on degree mismatch. - **Type or scale differences**: `10.0` vs `10.00` under some type rules, or an implicit coercion applied to one side. - **Whitespace and case**: trailing spaces, differing case, or a differing collation. - **Timestamp precision or time zone**: microseconds truncated on one side, or one side stored in local time. The signature symptom is a diff whose output size equals the input size in both directions. When you see that, stop looking for data problems and look for a shape or normalisation problem. Mitigation: project an explicit, identically-ordered column list on both branches (never `SELECT *`), cast both sides to a canonical type, and normalise text (trim, fold case, force a collation) before comparing. ## Pitfall 3: nulls match under distinctness, and that surprises people both ways Set operators compare rows using "is not distinct from", so two rows null in the same position count as the same row. That is usually what you want for reconciliation — but it differs from a join predicate written with `=`, where null comparisons yield unknown and the rows fail to match. If you migrate a reconciliation from `EXCEPT` to a join, you must add explicit null-safe comparisons or the results will change. ## Pitfall 4: the diff says *that*, not *why* Even a perfectly correct symmetric difference returns rows. It cannot tell you that 12,000 rows appear on both sides because one column drifted, versus 12,000 genuinely missing records. On a wide table this is the difference between a five-minute fix and a day of manual comparison. ## The robust pattern: key-based comparison Replace the whole-row diff with a **full outer join on the business key**, then classify: - key present only on the left → missing from the right, - key present only on the right → extra on the right, - key present on both, some non-key column differs → a value drift, and you can report exactly which columns. This gives an actionable report, is immune to column-order accidents (you name the columns you compare), and separates "row missing" from "row changed". Practical additions: - Verify key uniqueness on both sides first; a duplicated key silently fans out the join and inflates every count. - Compare with null-safe equality per column so that null-vs-null counts as equal and null-vs-value counts as different. - For very wide tables, compare a normalised **checksum/hash of the row** per key as a fast first pass, then drill into columns only for the keys that differ. Normalise inputs before hashing — the hash inherits every whitespace and precision sensitivity described above. - Reconcile **counts and aggregate totals** per partition as a cheap independent signal; matching row counts with a mismatching sum immediately localises the problem to a value drift. ## Cost note All deduplicating set operators sort or hash both inputs, so on large datasets the reconciliation is memory-hungry and may spill. Chunking by key range or partition makes it bounded and restartable, and gives you a per-partition report rather than one giant result you cannot triage. ## The answer to give Lead with the three semantic properties — asymmetry (so you need both directions), duplicate collapse (so counts can differ while the diff is empty), and whole-row positional matching (so cosmetic drift reads as total mismatch) — mention null distinctness, then pivot to the key-based full-outer-join pattern with per-column reporting as the design you would actually ship.

  • Your bidirectional diff returns every row on both sides. What do you check first?
    Shape and normalisation, not data. Confirm the two branches project the same explicit column list in the same order, then check for type or scale coercions, trailing whitespace, case and collation differences, and timestamp precision or time-zone handling. A diff whose output size equals the input size in both directions is almost always a formatting artifact rather than a genuine full mismatch.
  • Both directions of EXCEPT come back empty but the two tables have different row counts. How is that possible?
    Because the default set operators eliminate duplicates. If one side holds a row multiple times and the other holds it once, the difference is empty in both directions while the counts differ. Use EXCEPT ALL, which returns max(m − n, 0) copies, or reconcile per-key counts alongside the diff — and ideally assert key uniqueness on both sides first.
  • Why prefer a full outer join on the key over a symmetric difference for a production reconciliation?
    Because the join classifies the discrepancy: key on one side only means a missing or extra record, key on both sides with a differing column means value drift, and you can report exactly which columns drifted. A symmetric difference only returns whole rows and leaves a human to work out why. The join is also immune to accidental column reordering, since you name the columns you compare explicitly.

Comparing two ledgers by set difference is like checking two shopping lists by asking only "is this exact line on the other list?" — a duplicated item, a different spelling, or a differently ordered line makes everything look wrong, and you never learn which detail changed.

saying these in an interview costs you the question

  • Running only one direction of the difference and calling the datasets equal.
  • Not knowing that the default EXCEPT deduplicates and can hide count mismatches.
  • Assuming set operators match columns by name rather than position.
  • Expecting a whole-row diff to identify which column changed.
  • Comparing wide tables with SELECT * on both branches and trusting the result.

context