Data Quality / Reconciliation

Full-Fledged Reconciliation of Very Large Datasets

A ready reckoner for reconciling two large tables end-to-end — structure, volume, existence, value, and drill-down — without ever brute-force comparing every row. Cost scales with suspicion, not with table size.

Coming soon Interactive tooling built around this exact methodology is on the way — stay tuned.
Each phase only touches what the phase above it flagged. A 500M-row table is fully scanned only at the cheap, aggregate phases (0–2); row-level comparison (4–5) only ever runs against the narrow slice already under suspicion.
0
Contract

Define the Contract

Business key · comparison scope · tolerance rules · snapshot watermark · partition boundaries

scope: agreement, before any SQL

1
Structural

Structural Reconciliation

Compare INFORMATION_SCHEMA metadata — column names, types, nullability — source vs target

scope: full table (metadata only)

2
Volumetric

Volumetric Reconciliation

Row counts + control totals, per partition — flags which partitions need deeper checks

scope: full table, aggregated per partition

3
Existence

Key-Based Existence Check

Anti-join for missing/extra rows + duplicate-key check, scoped to flagged partitions

scope: flagged partitions only

4
Value-Level

Value-Level Reconciliation

Hash comparison on matched keys, NULL-neutralised — catches silent value drift

scope: matched keys, flagged partitions

5
Drill-Down

Column-Level Drill-Down

Targeted column diff — only for rows Phase 4 already flagged

scope: rows flagged by Phase 4

6
Normalise

Normalisation Rules

NULL-safety, numeric tolerance, timezone/precision, case & whitespace — cross-cutting, applied in every phase above

scope: cross-cutting

7–8
Scale & Report

Scale Strategy & Reporting

Partition pruning, parameterised & version-controlled, wired into CI/CD, structured pass/fail report per run

scope: framework, runs every release

▲ full table scanned, aggregated only  ·  narrowing = fewer rows, more precision  ·  ▼ handful of rows, full detail

Phase Detail

expand for SQL + rationale
01 Structural reconciliation

Catches schema drift — a column silently added, dropped, or retyped between source and target — before it corrupts every check downstream.

-- metadata comparison, not data comparison
SELECT COALESCE(a.column_name, b.column_name) AS column_name,
       a.data_type AS source_type, b.data_type AS target_type
FROM source_db.information_schema.columns a
FULL OUTER JOIN target_db.information_schema.columns b
  ON a.table_name = b.table_name AND a.column_name = b.column_name
WHERE a.table_name = 'ORDERS'
  AND (a.data_type <> b.data_type OR a.column_name IS NULL OR b.column_name IS NULL);
Gate, not a suggestion: if structure doesn't match, don't run value-level checks yet — a column-count mismatch will silently corrupt any SELECT *-based hash.
02 Volumetric reconciliation

Row counts and control totals, computed per partition — the short-list generator for every phase after it.

SELECT COALESCE(s.load_date, t.load_date) AS load_date,
       s.row_count AS source_count, t.row_count AS target_count,
       s.row_count - t.row_count AS variance
FROM (SELECT load_date, COUNT(*) AS row_count FROM source_orders GROUP BY load_date) s
FULL OUTER JOIN (SELECT load_date, COUNT(*) AS row_count FROM target_orders GROUP BY load_date) t
  ON s.load_date = t.load_date
WHERE s.row_count <> t.row_count OR s.row_count IS NULL OR t.row_count IS NULL;
This is what makes the whole approach scale: from here on, only partitions with a variance get touched by anything more expensive.
03 Key-based existence check

Anti-join for missing/extra rows, plus an independent duplicate check — because duplicates can mask missing rows inside a matching total.

-- in source, missing from target
SELECT s.id FROM source_orders s
LEFT JOIN target_orders t ON s.id = t.id
WHERE t.id IS NULL AND s.load_date = :flagged_date;

-- duplicate keys within one side
SELECT id, COUNT(*) FROM source_orders
GROUP BY id HAVING COUNT(*) > 1;
Classic trap: 5 missing + 5 duplicated cancels out in Phase 2's total. This phase is what actually catches it.
04 Value-level reconciliation

Hash every in-scope column per row and compare — one fingerprint instead of an N-column OR chain.

SELECT s.id
FROM source_orders s
JOIN target_orders t ON s.id = t.id
WHERE s.load_date = :flagged_date
  AND HASH(COALESCE(s.col1,'~'), COALESCE(s.col2,'~'))
   <> HASH(COALESCE(t.col1,'~'), COALESCE(t.col2,'~'));
COALESCE before hashing neutralises unverified NULL behaviour in hash functions — the same NULL-safety principle applied everywhere in Phase 6.
05 Column-level drill-down

Side-by-side column diff — but only for the small set of IDs Phase 4 already flagged, so it's cheap even on huge tables.

SELECT s.id, s.col1 AS src_col1, t.col1 AS tgt_col1
FROM source_orders s
JOIN target_orders t ON s.id = t.id
WHERE s.id IN (:ids_flagged_in_phase4);
Recovers the diagnostic detail (which column, not just which row) that Phase 4 deliberately sacrifices for speed.
06 Normalisation rules (cross-cutting)

Applied inside every comparison above, not as a separate pass:

-- NULL-safety
COALESCE(col, '~NULL~')
-- numeric tolerance, never exact equality on decimals
ABS(a - b) > 0.01
-- timezone / precision normalisation
DATE_TRUNC('second', ts)
-- case & whitespace
TRIM(UPPER(col))
The single biggest source of false-positive "differences" in real reconciliation work is a normalisation mismatch, not a genuine defect.
07–08 Scale strategy & reporting

What turns the method from theoretically correct into actually runnable in a nightly job:

Partition pruning on every query · row-level phases scoped only to flagged partitions/keys · fully parameterised (table, key, tolerance, excluded columns as config) · version-controlled · wired into CI/CD · structured report per run (partitions checked, variance type, sample flagged rows, pass/fail).

This is what makes it a reusable, maintainable regression safety net — not a one-time exercise repeated manually every release.

Coverage Matrix

Every failure scenario, which phase catches it, and why nothing earlier would have.

Failure scenarioCaught atWhy not caught earlier
Column added / removed / retypedPhase 1A data-level check would error out or silently mis-hash instead of flagging cleanly
Whole load missing or failedPhase 2Row-level checks are far too slow to serve as a first-pass smoke test
Row missing on one side onlyPhase 3Counts can still match if masked by an equal number of duplicates
Duplicate rowsPhase 3Counts alone can't distinguish "5 missing" from "5 duplicated"
Value silently changed on a matched rowPhase 4Counts and existence checks are both blind to value-level drift
Need to know exactly what changedPhase 5Hash comparison sacrifices this detail deliberately, for speed
NULL vs NULL false mismatchPhase 6Raw <> and unverified hash-NULL behaviour both fail silently
Rounding / floating-point noisePhase 6Exact equality on decimals is almost never the correct test
Timezone / precision mismatchPhase 6Otherwise floods results with false positives, burying real defects
Dataset too large to brute-force comparePhase 7The reason the funnel structure exists at all — without it, 4–5 alone don't scale
Not repeatable across releasesPhase 8Without automation, every run is manual effort with no regression protection