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.
0–2); row-level comparison (4–5) only ever runs against the narrow slice already under suspicion.
Define the Contract
Business key · comparison scope · tolerance rules · snapshot watermark · partition boundaries
scope: agreement, before any SQL
Structural Reconciliation
Compare INFORMATION_SCHEMA metadata — column names, types, nullability — source vs target
scope: full table (metadata only)
Volumetric Reconciliation
Row counts + control totals, per partition — flags which partitions need deeper checks
scope: full table, aggregated per partition
Key-Based Existence Check
Anti-join for missing/extra rows + duplicate-key check, scoped to flagged partitions
scope: flagged partitions only
Value-Level Reconciliation
Hash comparison on matched keys, NULL-neutralised — catches silent value drift
scope: matched keys, flagged partitions
Column-Level Drill-Down
Targeted column diff — only for rows Phase 4 already flagged
scope: rows flagged by Phase 4
Normalisation Rules
NULL-safety, numeric tolerance, timezone/precision, case & whitespace — cross-cutting, applied in every phase above
scope: cross-cutting
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
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);
SELECT *-based hash.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;
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;
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.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);
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))
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).
Every failure scenario, which phase catches it, and why nothing earlier would have.
| Failure scenario | Caught at | Why not caught earlier |
|---|---|---|
| Column added / removed / retyped | Phase 1 | A data-level check would error out or silently mis-hash instead of flagging cleanly |
| Whole load missing or failed | Phase 2 | Row-level checks are far too slow to serve as a first-pass smoke test |
| Row missing on one side only | Phase 3 | Counts can still match if masked by an equal number of duplicates |
| Duplicate rows | Phase 3 | Counts alone can't distinguish "5 missing" from "5 duplicated" |
| Value silently changed on a matched row | Phase 4 | Counts and existence checks are both blind to value-level drift |
| Need to know exactly what changed | Phase 5 | Hash comparison sacrifices this detail deliberately, for speed |
| NULL vs NULL false mismatch | Phase 6 | Raw <> and unverified hash-NULL behaviour both fail silently |
| Rounding / floating-point noise | Phase 6 | Exact equality on decimals is almost never the correct test |
| Timezone / precision mismatch | Phase 6 | Otherwise floods results with false positives, burying real defects |
| Dataset too large to brute-force compare | Phase 7 | The reason the funnel structure exists at all — without it, 4–5 alone don't scale |
| Not repeatable across releases | Phase 8 | Without automation, every run is manual effort with no regression protection |