skip to content

How do you reconcile two tables with a FULL OUTER JOIN and label rows missing on either side or mismatched?

level: seniorimportance: should knowfreq 42%

answer

  1. one pass that drops nothing from either side
  2. the two raw key columns tell you provenance
  3. order the CASE branches: missing before differing
  4. equality with NULL is unknown, not false
  5. filter to the interesting statuses

basics

~20 s

Full outer join the two sets on the business key, COALESCE the keys into one column, then use CASE: a NULL key on one side means the row is missing there, and otherwise compare the value columns NULL-safely to flag mismatches.

solid answer

~50 s

Join on the business key with `FULL OUTER JOIN` so nothing is dropped, then classify each output row. Because the key survives as two columns, `COALESCE(s.order_id, t.order_id)` rebuilds the identifier, and the two raw key columns become the test for provenance: `t.order_id IS NULL` means the row is missing in the target, `s.order_id IS NULL` means it is missing in the source, and if both are present the row exists on both sides and you compare the payload columns. Compare those NULL-safely — `s.amount = t.amount` is unknown, not false, when either is NULL, so a genuine difference between a value and a NULL would be silently reported as a match; `IS DISTINCT FROM` where available, or an explicit expansion, gives the right answer. Add a `WHERE` clause with the same three conditions when you want only the discrepancies.

code

sql · 14 lines
sql
SELECT COALESCE(s.order_id, t.order_id) AS order_id,
       s.amount AS source_amount,
       t.amount AS target_amount,
       CASE
         WHEN t.order_id IS NULL THEN 'missing_in_target'
         WHEN s.order_id IS NULL THEN 'missing_in_source'
         WHEN s.amount IS DISTINCT FROM t.amount THEN 'amount_differs'
         ELSE 'match'
       END AS status
FROM source_orders s
FULL OUTER JOIN target_orders t ON s.order_id = t.order_id
WHERE s.order_id IS NULL
   OR t.order_id IS NULL
   OR s.amount IS DISTINCT FROM t.amount;

go deeper

for a junior

Recall that a full outer join keeps unmatched rows from both tables, which is what lets one query show rows missing on either side. Be able to read the CASE that labels them.

for a middle

Explain how the two raw key columns classify each row, why COALESCE rebuilds the identifier, and why the missing-side branches must precede the value-comparison branch in the CASE.

for a senior

Demonstrate production judgement: verify key uniqueness and grain first, compare values NULL-safely, filter down to discrepancies, and summarise by status so the query becomes an operational signal rather than a dump.

for a principal

Own the wider call — which systems get reconciled, at what cadence and grain, what thresholds trigger action, and when the comparison should move out of SQL into a dedicated pipeline step.

## The shape of a reconciliation query Reconciliation asks three questions about two data sets that are supposed to agree: what is here but not there, what is there but not here, and what is in both but different. A `FULL OUTER JOIN` answers all three in a single pass, because it is the only join type that drops nothing. ```sql SELECT COALESCE(s.order_id, t.order_id) AS order_id, s.amount AS source_amount, t.amount AS target_amount, CASE WHEN t.order_id IS NULL THEN 'missing_in_target' WHEN s.order_id IS NULL THEN 'missing_in_source' WHEN s.amount IS DISTINCT FROM t.amount THEN 'amount_differs' ELSE 'match' END AS status FROM source_orders s FULL OUTER JOIN target_orders t ON s.order_id = t.order_id; ``` Three design choices carry the query. ## Choice 1 — join on a stable business key The `ON` predicate defines what "the same row" means. A surrogate key generated independently in each system is useless here; the key must be something both sides derive from the same fact — an order number, an external reference, a natural composite. If the key is not unique on either side, the matched bucket multiplies and your "differences" report inflates, so establish uniqueness first (or aggregate to a level where it holds). Getting the grain right is the part that goes wrong in practice, not the join syntax. ## Choice 2 — classify with the raw key columns, not the coalesced one `COALESCE` deliberately erases which side a value came from, so it is right for the output identifier and wrong for the classification. The `CASE` therefore tests `s.order_id` and `t.order_id` individually. Order matters: the two "missing" branches must come before the comparison branch, because for an unmatched row the payload columns are NULL-extended and comparing them would produce a meaningless verdict. ## Choice 3 — compare values NULL-safely This is where reconciliation queries quietly lie. If `s.amount` is 100 and `t.amount` is NULL, then `s.amount <> t.amount` evaluates to unknown, not true, so a `WHEN s.amount <> t.amount` branch does not fire and the row is reported as a match. The standard's NULL-safe inequality is `IS DISTINCT FROM`, which returns true here and false when both sides are NULL. Not every engine implements it; the portable expansion is: ```sql (s.amount <> t.amount) OR (s.amount IS NULL AND t.amount IS NOT NULL) OR (s.amount IS NOT NULL AND t.amount IS NULL) ``` Repeat that per compared column, or `COALESCE` both sides to a sentinel value that cannot occur in the data — a technique that works but is fragile, since choosing a sentinel that the data can produce reintroduces the bug. ## Reporting only the discrepancies A reconciliation that returns every row is fine for a small data set and useless for a large one. Filter to the three interesting cases: ```sql WHERE s.order_id IS NULL OR t.order_id IS NULL OR s.amount IS DISTINCT FROM t.amount ``` Or wrap the query and filter on the computed status — often clearer, and it guarantees the filter and the label can never disagree: ```sql WITH diff AS ( /* the query above */ ) SELECT * FROM diff WHERE status <> 'match'; ``` A summary count by status is usually the first thing an operator wants: group the wrapped query by `status` and count, which turns the reconciliation into a one-line health signal. ## Comparing many columns With a wide row, a per-column `CASE` chain becomes unmanageable. Two portable options: build a status list of the columns that differ using conditional expressions, or compare a digest of the concatenated payload — with the caveat that concatenation must handle NULLs and separators carefully, or two different rows can produce the same string. When the interviewer pushes on this, the honest answer is that column-level detail is what makes a reconciliation actionable, so a mechanical per-column comparison generated from the schema usually beats a single opaque digest. ## Portability note If the engine has no `FULL OUTER JOIN`, the same reconciliation is expressible as a `LEFT JOIN` branch plus an anti-joined branch combined with `UNION ALL`, with the `CASE` logic repeated in both branches — or, since the three status buckets are already disjoint, as three `UNION ALL` branches each emitting a constant status literal. ## What good looks like A solid answer names the join type and says why (nothing is dropped), rebuilds the key with `COALESCE`, classifies from the raw key columns in the right branch order, and — the detail that separates candidates — insists on NULL-safe value comparison, because that is the failure that makes a reconciliation report clean while the data is not.

  • Why is a plain <> comparison unsafe when flagging value differences?
    If either side is NULL, `s.amount <> t.amount` evaluates to unknown, so the branch does not fire and the row is labelled a match even though one side has a value and the other has none. `IS DISTINCT FROM` treats NULL as a comparable value and returns true, which is what a difference report needs.
  • What could go wrong if the reconciliation key is not unique on both sides?
    The matched bucket multiplies: three source rows and two target rows sharing a key produce six output rows, so the discrepancy report inflates and the counts stop meaning anything. Verify uniqueness on both sides first, or aggregate each side to a grain at which the key is unique before joining.
  • How would you turn this query into a monitoring signal rather than a row dump?
    Wrap it in a CTE and group by the computed status, counting rows per bucket. That yields a handful of numbers — missing in target, missing in source, differing, matching — that an alert can threshold, while the detailed rows stay available behind the same query for investigation.
  • Why not use LEFT JOIN and just run it twice, once per direction?
    You can, and it is the fallback on engines without `FULL OUTER JOIN`, but it reads both tables twice and gives you two result sets to stitch together. One full outer join produces all three buckets in a single labelled result, which is easier to filter, count and hand to a consumer.

saying these in an interview costs you the question

  • Uses LEFT JOIN and misses rows that exist only in the target
  • Compares values with <> and misses NULL-versus-value differences
  • Classifies rows from the coalesced key column
  • Puts the value-comparison CASE branch before the missing-side branches
  • Joins on a surrogate key each system generates independently

context