Data validation and reconciliation in SQL
Prove that your numbers are right before anyone else finds out they are wrong — profiling, integrity checks and reconciliation to independent totals.
This lesson builds on: Joins — combining tables without double counting.
- Profile a table: row counts, keys, NULLs, ranges and distributions
- Detect duplicates, orphan rows and impossible values
- Reconcile totals between two sources and explain differences
- Describe where validation belongs in ETL/ELT pipelines
Why validate?
An analysis can be logically perfect and still wrong because the data was incomplete, duplicated or loaded twice. Validation is the analyst's insurance. Senior analysts are trusted precisely because their numbers reconcile.
1. Profile the table
SELECT COUNT(*) AS rows,
COUNT(DISTINCT order_id) AS distinct_orders,
MIN(order_date) AS first_date,
MAX(order_date) AS last_date,
SUM(CASE WHEN customer_id IS NULL THEN 1 ELSE 0 END) AS null_customers,
SUM(CASE WHEN discount_pct < 0 OR discount_pct > 0.9 THEN 1 ELSE 0 END) AS suspicious_discounts
FROM orders;| rows | distinct_orders | first_date | last_date | null_customers | suspicious_discounts |
|---|---|---|---|---|---|
| 37651 | 37651 | 2023-01-01 | 2025-12-31 | 0 | 0 |
Rows = distinct orders (no duplicate keys), dates cover the expected range, no missing customers, no impossible discounts.
2. Duplicates
SELECT customer_id, COUNT(*) AS copies
FROM customers_raw
GROUP BY customer_id
HAVING COUNT(*) > 1
ORDER BY copies DESC
LIMIT 5;| customer_id | copies |
|---|---|
| 182 | 2 |
| 191 | 2 |
| 214 | 2 |
| 245 | 2 |
| 417 | 2 |
Distinguish exact duplicates (every column identical — usually a loading error) from entity duplicates (same customer, different spelling — needs matching rules).
3. Referential integrity (orphans)
SELECT
(SELECT COUNT(*) FROM order_items oi LEFT JOIN orders o ON o.order_id = oi.order_id WHERE o.order_id IS NULL) AS lines_without_order,
(SELECT COUNT(*) FROM order_items oi LEFT JOIN products p ON p.product_id = oi.product_id WHERE p.product_id IS NULL) AS lines_without_product,
(SELECT COUNT(*) FROM orders o LEFT JOIN order_items oi ON oi.order_id = o.order_id WHERE oi.order_id IS NULL) AS orders_without_lines;| lines_without_order | lines_without_product | orders_without_lines |
|---|---|---|
| 0 | 0 | 0 |
All zeros: every line has an order and product, and every order has at least one line.
4. Business-rule checks
Rules that must hold regardless of schema constraints: quantities positive, prices above cost (or flagged), close dates after open dates, a balance sheet that balances.
SELECT year_month,
ROUND(SUM(CASE WHEN section = 'Assets' THEN amount ELSE 0 END), 2) AS assets,
ROUND(SUM(CASE WHEN section IN ('Liabilities', 'Equity') THEN amount ELSE 0 END), 2) AS liabilities_and_equity,
ROUND(SUM(CASE WHEN section = 'Assets' THEN amount ELSE -amount END), 2) AS difference
FROM balance_sheet
GROUP BY year_month
ORDER BY ABS(difference) DESC
LIMIT 3;| year_month | assets | liabilities_and_equity | difference |
|---|---|---|---|
| 2025-11 | 7570922.86 | 7570922.87 | -0.01 |
| 2025-10 | 7247879.22 | 7247879.23 | -0.01 |
| 2025-09 | 7130257.08 | 7130257.07 | 0.01 |
The largest absolute difference across all months is effectively zero — the balance sheet balances.
5. Reconciliation
Reconciliation compares a figure computed two independent ways, or from two systems, and explains every difference. Example: revenue computed from order lines vs monthly plan file loaded by Finance — or here, order-line revenue split by channel must sum to the total:
WITH lines AS (
SELECT o.channel, oi.quantity * oi.unit_price * (1 - oi.discount_pct) AS rev
FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status = 'completed' AND o.order_date >= '2025-01-01'
)
SELECT ROUND((SELECT SUM(rev) FROM lines), 2) AS total,
ROUND((SELECT SUM(rev) FROM lines WHERE channel = 'store')
+ (SELECT SUM(rev) FROM lines WHERE channel = 'online'), 2) AS sum_of_channels;| total | sum_of_channels |
|---|---|
| 1144526.55 | 1144526.55 |
When two systems disagree (CRM vs ERP), build a reconciliation table: matched records, records only in A, only in B, and matched records with different amounts (FULL OUTER JOIN from the joins lesson), then explain each bucket — timing, status definitions, currency, cancellations.
In ETL (extract → transform → load) and ELT (extract → load → transform in the warehouse) pipelines, these checks should run automatically after each load, failing loudly when a rule breaks. Tools like dbt tests or simple scheduled SQL checks do this. An analyst who repeatedly runs the same manual checks should propose automating them (Module 14).
Practice
How many rows in customers_raw are duplicates — i.e. rows beyond the first for the same customer_id? One number.
CRM shows 12,713 completed 2025 orders; the finance extract shows 12,690. Which are plausible explanations to investigate? Select all that apply.
Summary
- Profile first: counts, keys, NULLs, ranges.
- Check duplicates, orphans and business rules explicitly.
- Reconcile to independent totals and explain every difference.
- Automate recurring checks in the ETL/ELT pipeline.