ETL Validation SQL: Source-to-Target Testing Queries for Data Warehouses
ETL validation SQL proves that the data a pipeline loaded matches the data it was supposed to load. Row counts that agree tell you almost nothing. A load can match on count and still truncate a column, drop late updates, or duplicate a dimension row. This article gives you the queries that catch those failures.
I have tested warehouse loads for hospital reporting and finance, including nightly extracts from Epic Clarity into a reporting warehouse. Most of the defects I found passed a count check. The checks below come from those projects. Table names are generic, so map them to your environment.
A taxonomy of ETL tests, a validation pipeline with six checkpoints, eleven SQL checks with code, two defect scenarios with tickets and before/after queries, a decision tree for failing checks, a source-to-target mapping template, role checklists, and a downloadable PDF query pack.
What ETL Validation Covers
ETL stands for extract, transform, load. Each stage fails in its own way, and each failure needs a different test. A complete validation plan covers six data quality dimensions.
| Dimension | Question | Typical SQL check | Typical failure |
|---|---|---|---|
| Completeness | Did every source row arrive? | Row counts, anti-join on keys | Dropped rows, missed late updates |
| Accuracy | Do loaded values equal the source after transformation? | Hash comparison, control totals | Wrong mapping, rounding, truncation |
| Uniqueness | Does each business key appear once at the right grain? | GROUP BY key HAVING COUNT(*) > 1 | Duplicate dimension or fact rows |
| Consistency | Do related tables agree? | Foreign key anti-joins, sub-totals vs totals | Orphan facts, unknown members |
| Validity | Do values follow format and domain rules? | Range, pattern, and list checks | Impossible dates, unmapped codes |
| Timeliness | Did data arrive by the agreed time? | Compare load time to source change time | Stale data, missed windows |
These map directly to the validation query families in the nine validation query families article. ETL testing applies those families to a pipeline instead of an application, and adds checks for slowly changing dimensions and incremental loads.
Where ETL testing sits in the test pyramid
| Test level | Scope | Owner | Run when |
|---|---|---|---|
| Unit | One transformation rule on controlled input rows | ETL developer | Every code change |
| Component | One mapping or job against a sample extract | ETL developer and QA | Each build |
| Integration | Full load source to warehouse in a test environment | QA | Each release candidate |
| Reconciliation | Production load totals against source totals | QA and data stewards | Every production load |
| Regression | Pre/post comparison after any change | QA | Each release |
Most teams skip reconciliation because it feels like operations work. It is the only level that watches real data. A pipeline can pass every test environment check and still fail on production volumes, so build the reconciliation checks into the load itself.
A Reference Model for ETL Validation Queries
The examples use a simple warehouse pattern with a source, a staging layer, a fact table, a slowly changing dimension, and an audit table. Healthcare projects use exactly this shape when Clarity data feeds a reporting warehouse such as Caboodle.
| Object | Layer | Role |
|---|---|---|
src.PAT_ENC_HSP |
Source | Hospital encounters in the operational reporting database |
stg.encounter |
Staging | Raw copy of changed source rows with load metadata |
dw.fact_encounter |
Warehouse | One row per hospital encounter with dimension keys and measures |
dw.dim_department |
Warehouse | Department dimension, slowly changing (type 2) |
etl.load_log |
Audit | One row per load: batch ID, start, end, rows read, rows written, rows rejected |
etl.reject_row |
Audit | Rows that failed a rule, with the reason |
Grain matters before any test runs. In this model the fact table holds one row per encounter, keyed by the contact serial number. The PAT_ENC table guide covers why that key is the right join and uniqueness test for encounter data.
Source
Clarity tables
Staging
Raw changed rows
Transform
Rules, mapping
Warehouse
Facts, dimensions
Reports
BI, extracts
V1
V2
V3
V4
V5
V6
V1 extract counts | V2 staging fidelity | V3 transformation rules | V4 load integrity and SCD
V5 reject review | V6 report reconciliation to source totals
Each checkpoint answers one question and has one owner. If V2 fails, the problem sits between the source and staging, so you do not waste time inspecting transformation logic. Run the checkpoints in order, and stop at the first failure.
Eleven ETL Validation SQL Checks
All checks use SQL Server syntax and the reference model. Each returns zero violating rows when the load is clean, and each should also report how many rows it examined.
Check 1: Row count reconciliation with the load log
SELECT l.batch_id, l.rows_read, l.rows_written, l.rows_rejected,
l.rows_read - (l.rows_written + l.rows_rejected) AS unaccounted
FROM etl.load_log l
WHERE l.load_date = CAST(GETDATE() AS date)
AND l.rows_read <> l.rows_written + l.rows_rejected;
This check trusts the pipeline’s own counts, so it catches only internal leaks. Pair it with an independent count taken directly from the source, as in Check 2. A pipeline that undercounts its own input passes Check 1 and fails Check 2.
Check 2: Independent source-to-target count by change window
SELECT s.src_rows, t.tgt_rows, s.src_rows - t.tgt_rows AS gap
FROM (SELECT COUNT(*) AS src_rows
FROM src.PAT_ENC_HSP
WHERE UPDATE_DATE >= @window_start AND UPDATE_DATE < @window_end) s
CROSS JOIN
(SELECT COUNT(*) AS tgt_rows
FROM dw.fact_encounter
WHERE source_update_date >= @window_start AND source_update_date < @window_end) t;
Use the same filter column on both sides. Many warehouse tables carry the source’s change timestamp for this reason. If yours does not, add it. Without it, you cannot test incremental loads.
Check 3: Control totals on measures
SELECT s.total_charges, t.total_charges AS dw_total, s.total_charges - t.total_charges AS diff
FROM (SELECT SUM(TOTAL_CHARGES) AS total_charges FROM src.HSP_ACCOUNT
WHERE DISCH_DATE >= '2026-09-01' AND DISCH_DATE < '2026-10-01') s
CROSS JOIN
(SELECT SUM(total_charges) AS total_charges FROM dw.fact_encounter
WHERE discharge_date >= '2026-09-01' AND discharge_date < '2026-10-01') t;
Compare sums, minimums, and maximums for every numeric measure. A matching sum can hide a sign flip offset by another error, so add the minimum and maximum. Money columns must use fixed decimals on both sides.
Check 4: Row-level comparison with hashes
Totals prove aggregates. They do not prove that each row is right. Hash a canonical string of the compared columns on each side, then compare keys and hashes. The Microsoft HASHBYTES documentation lists supported algorithms and the input-size limits to respect.
WITH s AS (
SELECT PAT_ENC_CSN_ID AS k,
HASHBYTES('SHA2_256', CONCAT_WS('|', ISNULL(CONVERT(varchar(30), HOSP_ADMSN_TIME, 121), '~'),
ISNULL(CONVERT(varchar(30), HOSP_DISCH_TIME, 121), '~'),
ISNULL(CAST(DEPARTMENT_ID AS varchar(20)), '~'))) AS h
FROM src.PAT_ENC_HSP WHERE UPDATE_DATE >= @window_start AND UPDATE_DATE < @window_end),
t AS (
SELECT encounter_csn AS k,
HASHBYTES('SHA2_256', CONCAT_WS('|', ISNULL(CONVERT(varchar(30), admit_time, 121), '~'),
ISNULL(CONVERT(varchar(30), discharge_time, 121), '~'),
ISNULL(CAST(department_id AS varchar(20)), '~'))) AS h
FROM dw.fact_encounter WHERE source_update_date >= @window_start AND source_update_date < @window_end)
SELECT COALESCE(s.k, t.k) AS key_value,
CASE WHEN s.k IS NULL THEN 'extra in target'
WHEN t.k IS NULL THEN 'missing in target'
ELSE 'value mismatch' END AS problem
FROM s FULL OUTER JOIN t ON t.k = s.k
WHERE s.k IS NULL OR t.k IS NULL OR s.h <> t.h;
Replace nulls with a marker before hashing, or the concatenation function drops them and different rows can produce the same string. Convert datetimes to a fixed text format on both sides. A hash mismatch from a format difference wastes an afternoon.
Report only key values and problem types in shared evidence, never the compared column values. Keys such as encounter IDs still identify patients to anyone with source access, so keep the results inside the secured environment.
Check 5: Transformation rule tests
Every mapping rule deserves a test with known inputs. Write the rule as a CASE expression in the test, run it against the source, and compare the result to the warehouse.
-- Rule: patient class code 1 -> 'Inpatient', 2 -> 'Observation', 3 -> 'Emergency', else 'Other'
SELECT f.encounter_csn, f.patient_class AS dw_value,
CASE s.ADT_PAT_CLASS_C WHEN 1 THEN 'Inpatient' WHEN 2 THEN 'Observation'
WHEN 3 THEN 'Emergency' ELSE 'Other' END AS expected_value
FROM dw.fact_encounter f
JOIN src.PAT_ENC_HSP s ON s.PAT_ENC_CSN_ID = f.encounter_csn
WHERE f.patient_class <> CASE s.ADT_PAT_CLASS_C WHEN 1 THEN 'Inpatient' WHEN 2 THEN 'Observation'
WHEN 3 THEN 'Emergency' ELSE 'Other' END;
An independent re-implementation catches errors the developer repeated in both code and test. The code values above are illustrative. Take real mappings from the signed-off mapping document.
Check 6: Uniqueness at the declared grain
SELECT encounter_csn, COUNT(*) AS copies
FROM dw.fact_encounter
GROUP BY encounter_csn
HAVING COUNT(*) > 1;
Run this on every fact and dimension table, using the key that defines the grain. Declared primary keys help, but many warehouses skip enforcement for load speed, so the database will not stop duplicates for you.
Check 7: Foreign keys and unknown members
SELECT COUNT(*) AS facts_with_unknown_dept,
SUM(CASE WHEN d.department_key IS NULL THEN 1 ELSE 0 END) AS orphan_facts
FROM dw.fact_encounter f
LEFT JOIN dw.dim_department d ON d.department_key = f.department_key
WHERE f.department_key = -1 OR d.department_key IS NULL;
Dimensional models use a placeholder row, often with key -1, for facts whose dimension value is missing. A few unknown members are normal. A jump after a release is a defect, so track the count over time against a threshold.
Check 8: Slowly changing dimension integrity
Type 2 dimensions keep history by adding a row each time an attribute changes, with effective dates and a current flag. Kimball dimensional modeling techniques describe the pattern. Three things go wrong: overlapping date ranges, gaps, and more than one current row.
-- Overlapping effective ranges for the same department
SELECT a.department_id, a.department_key AS key_a, b.department_key AS key_b,
a.effective_from AS a_from, a.effective_to AS a_to, b.effective_from AS b_from, b.effective_to AS b_to
FROM dw.dim_department a
JOIN dw.dim_department b
ON a.department_id = b.department_id
AND a.department_key < b.department_key
AND a.effective_from < ISNULL(b.effective_to, '9999-12-31')
AND b.effective_from < ISNULL(a.effective_to, '9999-12-31');
-- More than one current row
SELECT department_id, COUNT(*) AS current_rows
FROM dw.dim_department WHERE is_current = 1
GROUP BY department_id HAVING COUNT(*) > 1;
Overlaps matter because a fact table joins to the dimension on a date range. Two matching rows duplicate the fact, and every measure doubles for that department. Scenario two below shows the damage.
Check 9: Incremental load and late-arriving data
Incremental loads use a watermark, usually the highest change timestamp loaded so far. The watermark fails in two cases. The source updates a row after you passed its timestamp, or the timestamp column is not the one the application maintains.
-- Source rows changed inside the window that never reached the target
SELECT s.PAT_ENC_CSN_ID, s.UPDATE_DATE
FROM src.PAT_ENC_HSP s
WHERE s.UPDATE_DATE >= DATEADD(day, -3, @run_date)
AND NOT EXISTS (SELECT 1 FROM dw.fact_encounter f
WHERE f.encounter_csn = s.PAT_ENC_CSN_ID
AND f.source_update_date >= s.UPDATE_DATE);
Reading three days back each run, with idempotent upserts, costs little and prevents most late-arrival losses. Choose the overlap from the longest delay you have seen, and test it by replaying a known late update.
Check 10: Data type, precision, and truncation
SELECT s.PAT_ENC_CSN_ID, LEN(s.DISCH_COMMENT) AS src_len, LEN(f.discharge_comment) AS dw_len
FROM src.PAT_ENC_HSP s
JOIN dw.fact_encounter f ON f.encounter_csn = s.PAT_ENC_CSN_ID
WHERE LEN(s.DISCH_COMMENT) > LEN(f.discharge_comment); -- truncated text
SELECT COLUMN_NAME, DATA_TYPE, NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'dw' AND TABLE_NAME = 'fact_encounter';
Compare source and target column definitions before the first load. A varchar(50) target for a varchar(200) source silently cuts values if the loader does not raise an error. Decimal scale mismatches cause rounding that surfaces months later in a tie-out.
Check 11: Idempotency, or what happens on a rerun
A well-built load produces the same result when run twice on the same input. Test it. Run the job, take a hash of the target, run it again, and compare.
-- Snapshot-style fingerprint of the target for a window
SELECT COUNT(*) AS row_count,
CHECKSUM_AGG(CHECKSUM(encounter_csn, admit_time, discharge_time, department_key)) AS fingerprint
FROM dw.fact_encounter
WHERE source_update_date >= @window_start AND source_update_date < @window_end;
Treat CHECKSUM_AGG as a quick fingerprint, not a proof. It can collide, and it ignores row order. Use Check 4 hashes when the result must be exact. If a rerun changes the fingerprint, the job appends instead of upserting, and a retry after any failure will duplicate data.
Scenario: Encounter Fact Table Missing 2.4% of Updated Rows
A health system loaded Clarity encounter data into a reporting warehouse every night. Quality analysts noticed that discharge times for some recent stays showed as blank in the warehouse while Hyperspace showed them correctly. The nightly row counts matched, and the load log reported no errors.
I was the QA lead asked to find out why before a length-of-stay dashboard went to the board. Check 1 passed, as the load log predicted. Check 2 with a change-window filter showed the gap.
| Check | Result before fix | Result after fix |
|---|---|---|
| 1. Load log: rows read vs written | Pass (0 unaccounted) | Pass |
| 2. Source vs target by change window | Fail (gap 1,031) | Pass (gap 0) |
| 4. Hash comparison | 1,031 value mismatches | 0 mismatches |
| 9. Missed late updates | 1,031 keys | 0 keys |
| 11. Rerun fingerprint | Stable | Stable |
The load log passed because the pipeline only counted the rows it chose to read. This is the most common ETL blind spot: the pipeline measures itself against its own selection logic. An independent count at the source exposes it. Teams that compare against a source query written by someone other than the pipeline developer catch these defects earliest.
The health system, ticket number, and counts are a composite from warehouse validation projects. The failure pattern, a watermark on the wrong column, is common.
Scenario: A Department Dimension That Doubled Revenue for One Clinic
A finance analyst reported that a surgical clinic’s revenue in the warehouse was exactly double the general ledger for the quarter. Other clinics tied out. The fact table passed uniqueness and count checks, so the cause had to sit in a join.
I ran the overlap query from Check 8 on the department dimension. The clinic had two type 2 rows with overlapping effective dates. A department rename and a cost center change had loaded on the same night.
| department_key | department_id | effective_from | effective_to | is_current |
|---|---|---|---|---|
| 5102 | 88410 | 2025-01-01 | NULL | 1 |
| 5877 | 88410 | 2026-06-14 | NULL | 1 |
Two open-ended rows for the same department. Every fact dated after June 14 matched both rows and doubled. The fact table itself had no duplicates because the duplication happened at query time, in the join from fact to dimension. This is why uniqueness checks on the fact table alone are not enough.
The before query
-- BEFORE: date-range join to the dimension returns two rows after Jun 14
SELECT d.department_name, SUM(f.net_revenue) AS revenue
FROM dw.fact_revenue f
JOIN dw.dim_department d
ON d.department_id = f.department_id
AND f.service_date >= d.effective_from
AND f.service_date < ISNULL(d.effective_to, '9999-12-31')
GROUP BY d.department_name;
The after query and the fix
-- Fix 1: close the older row
UPDATE dw.dim_department
SET effective_to = '2026-06-14', is_current = 0
WHERE department_key = 5102;
-- Guard: detect the problem before it reaches reports
SELECT department_id FROM dw.dim_department
GROUP BY department_id
HAVING SUM(CASE WHEN is_current = 1 THEN 1 ELSE 0 END) > 1;
The loader bug was a merge that inserted a new version without closing the previous one when two attributes changed in one batch. The developer fixed the merge logic, and the team added the overlap and current-row checks to the post-load script. Reports for the quarter then tied to the ledger.
A related guard belongs in the fact load: join on the dimension key captured at load time, not on a date range at query time. That removes the join-time multiplication risk, at the cost of a more complex load.
Decision Tree: A Check Failed. Where Do You Look?
Use the failing check to choose the stage to inspect. Fix the earliest failing checkpoint first, because later failures often follow from it.
Which check failed first?
Start at the earliest checkpoint
Counts or totals differ
Checks 1, 2, 3, 9
Values differ, counts match
Checks 4, 5, 10
Duplicates or fan-out
Checks 6, 7, 8
Different on every rerun
Check 11
Look at
Extract filter and watermark
Rejected rows (Check 1)
Source change column
Compare the pipeline to an
independent source query
Look at
Mapping and CASE logic
Column types, precision
Time zone and formats
Hash a canonical string,
replace nulls first
Look at
Grain and business keys
SCD overlaps, current flags
Join type in the load
Test the join, not only
the fact table
Look at
Insert vs upsert logic
Truncate-and-load steps
Non-deterministic functions
Rerun twice, compare
hashes
Many defects trigger more than one branch. The watermark scenario failed Check 2, 4, and 9 at once, and all pointed to the same cause. Resolve the earliest branch, rerun, and see which checks still fail.
Template: Source-to-Target Mapping for Testable ETL
The mapping document defines what each target column should contain. A mapping without a test is a wish. Add a test column to every row, and reviewers can see untested rules at a glance.
| Target column | Source | Rule | Null handling | Test | Check |
|---|---|---|---|---|---|
encounter_csn |
PAT_ENC_HSP.PAT_ENC_CSN_ID |
Direct copy | Not null | Unique, equals source | 6, 4 |
admit_time |
HOSP_ADMSN_TIME |
Direct copy, local time | Allow null | Hash equal | 4 |
patient_class |
ADT_PAT_CLASS_C |
Lookup to name; unmapped = Other | Null = Other | Independent CASE | 5 |
department_key |
DEPARTMENT_ID |
Dimension lookup at load date | Missing = -1 | Orphan anti-join | 7, 8 |
los_days |
Computed | Midnights between admit and discharge dates | Null if open stay | Boundary cases | 5 |
source_update_date |
UPDATE_DATE |
Direct copy | Not null | Max equals source max | 2, 9 |
For length-of-stay logic, the definition choices in the Clarity LOS article apply to the warehouse too. Write the counting rule into the mapping row, and test the same four boundary cases there.
Load sign-off checklist
| Item | Evidence | Owner |
|---|---|---|
| Counts reconcile to an independent source query | Check 2 output attached | QA |
| Control totals match for each measure | Check 3 output attached | QA |
| Row-level hashes match for the sample window | Check 4 output, keys only | QA |
| No duplicate keys; no overlapping SCD ranges | Checks 6 and 8 output | ETL developer |
| Unknown members within threshold | Check 7 trend | Data steward |
| Rerun produces identical fingerprint | Check 11 output | ETL developer |
| Rejects reviewed and categorized | Reject summary | Data steward |
Automating ETL Validation After Every Load
Validation that runs only when someone remembers will stop running. Build the checks into the load as a final step, write results to a table, and fail the job when a check breaches its threshold.
CREATE TABLE etl.validation_result (
batch_id INT NOT NULL,
check_name VARCHAR(60) NOT NULL,
rows_checked BIGINT NOT NULL,
violations BIGINT NOT NULL,
threshold BIGINT NOT NULL DEFAULT 0,
status AS (CASE WHEN violations <= threshold THEN 'PASS' ELSE 'FAIL' END),
run_at DATETIME2 NOT NULL DEFAULT SYSDATETIME()
);
-- Example: record Check 6 (duplicates) for the current batch
INSERT INTO etl.validation_result (batch_id, check_name, rows_checked, violations)
SELECT @batch_id, 'fact_encounter_unique_key',
(SELECT COUNT(*) FROM dw.fact_encounter),
(SELECT COUNT(*) FROM (SELECT encounter_csn FROM dw.fact_encounter
GROUP BY encounter_csn HAVING COUNT(*) > 1) d);
The rows_checked column protects you from the empty-table false pass. A check that reports zero violations against zero rows is a failure, so add a rule that fails any check with no rows examined.
Set thresholds by risk, not by habit. Duplicate keys and overlapping dimension ranges get zero tolerance. Unknown-member rates may allow a small percentage with a named owner. Write each threshold into the mapping document and get the data steward’s signature.
Send failures to the people who can act. A failed Check 8 goes to the ETL developer who owns the merge. A failed Check 7 trend goes to the data steward. A message to a shared inbox no one reads is the same as no alert.
How to Triage a Failed ETL Validation Check
A failed check tells you where to look, not what broke. Work from the cheapest explanation to the most expensive one. Most failures come from the first three causes below, and each takes minutes to confirm.
| Order | Question to answer | Quick test | If yes |
|---|---|---|---|
| 1 | Did the load finish? | Check etl.load_log status and end time |
Rerun after the job completes, then retest |
| 2 | Did the source change after extract? | Compare source update timestamps to the extract time | Widen the window or freeze the source snapshot |
| 3 | Is the test itself wrong? | Run the check against a hand-picked key | Fix the query and log the correction |
| 4 | Does one key explain the gap? | Trace one missing key through staging | Find the stage that dropped it |
| 5 | Is the pattern systematic? | Group failures by date, department, or status | Look for a rule or mapping gap |
Step three deserves a habit. A check that fails on day one is as likely to be a bad test as a bad load. Trace one key by hand before you raise a defect, because a false defect costs the developer a day and costs QA its credibility.
Group the failing keys before you read them. Counting failures by source date, department, and encounter status shows a pattern in seconds. Twelve thousand misses that all share one status code point to a mapping rule, not to random loss.
Designing Test Data for ETL Validation
Production-sized data proves volume. Small, designed data proves logic. Build a seed set where you know the expected result for every row, and keep it in version control beside the checks.
| Seed row type | What it proves | Expected result |
|---|---|---|
| Normal encounter, all fields filled | Happy path mapping | One target row, exact values |
| Encounter with null discharge time | Null handling rule | Target holds the documented default or null |
| Encounter updated after first load | Incremental pickup | One target row with the new values, not two |
| Encounter deleted at source | Delete policy | Row flagged or removed as the mapping states |
| Department renamed mid-month | SCD type 2 behavior | Old row closed, new row open, no overlap |
| Text at maximum source length | Truncation guard | Full value in target |
| Code value missing from the mapping | Unmapped code handling | Row lands on the unknown member or in rejects |
Synthetic rows protect patients and make results repeatable. If you must test on masked production data, confirm with the privacy office that the masking method fits the project. Never copy production identifiers into a test environment without that approval.
Keeping ETL Validation Queries Fast
Full-table comparisons on a large fact table can run for hours and compete with the load. Three habits keep them practical.
First, filter by the same change window on both sides, as in Check 2. Second, compare by partition or date slice, and run the slices in parallel when the platform allows. Third, run the cheap checks first. Counts and totals take seconds and often end the investigation before any hash runs.
Indexes on the key and the change-timestamp columns make most checks several times faster. Ask the DBA for read-only access to a replica or a reporting copy, so validation never slows the production load. Record each check’s runtime in the results table, and treat a sudden jump as a signal that data volume or the plan has changed.
Role Checklists for ETL Validation
Business Analyst
- Write the mapping rule and null handling for every target column.
- Define the grain and the business key for each table in the requirement.
- Agree thresholds with the data steward and record the signer.
- List the reports that depend on each table, so impact analysis is fast.
QA Analyst
- Write source queries independently of the pipeline developer.
- Run Checks 2, 4, 8, and 9 on every release, not only Check 1.
- Test a late update, a deleted row, and a rerun in every cycle.
- Attach keys and counts to defects, never patient values.
ETL Developer
- Capture the source change timestamp in the target.
- Use upserts with a lookback window, so reruns do not duplicate.
- Close old SCD rows in the same transaction that opens new ones.
- Write rejects to a table with a reason, never discard them.
Common Mistakes in ETL Validation
| Mistake | Effect | Fix |
|---|---|---|
| Trusting the pipeline’s own row counts | Selection-logic errors pass | Count independently at the source |
| Checking totals only | Offsetting errors hide | Add min, max, and row-level hashes |
| Hashing without null markers | Different rows hash the same | Replace nulls and fix formats first |
| Testing the fact table for duplicates only | Join-time multiplication slips through | Test dimension ranges and the join |
| Validating a sample of five | Rare patterns missed | Full-volume counts and hashes, sample for detail |
| No rerun test | Retries duplicate data | Run twice and compare fingerprints |
| Zero violations on zero rows | False pass | Fail checks that examined nothing |
| Alerting a shared inbox | Nobody acts | Route to the owner of the failing stage |
I signed off a warehouse load after matching totals for the month. The totals matched because two errors offset: one table double-counted a refund, and another dropped a charge of the same amount. A row-level hash comparison found both within minutes. I now treat a matching total as a prompt to check rows.
When I Would Use SQL Checks and When I Would Use Other Tools
| Need | Best approach | Why |
|---|---|---|
| Source-to-target reconciliation on a known pipeline | Hand-written SQL checks in version control | Transparent, reviewable, no license cost |
| Continuous tests on a modeled warehouse | dbt tests or similar declarative framework | Tests live beside the models and run in CI |
| Statistical profiling and expectations | Great Expectations or similar library | Rich profiling and documentation output |
| Large heterogeneous estates | Commercial ETL testing platforms | Connectors, scheduling, and reporting across many sources |
| Ad hoc defect triage | SQL client | Fastest path from symptom to cause |
I use SQL checks first because they teach the team what correct looks like. I move to a framework once the check list is stable and the team wants scheduling and reporting around it. Commercial tools earn their cost on large, mixed estates and add little value on a single warehouse with a dozen tables. For more query practice, see the SQL for IT Analysts hub and the Epic Clarity SQL guide.
Edge Cases That Break Clean ETL Checks
| Edge case | Why it breaks a clean check | How to handle it |
|---|---|---|
| Soft deletes and hard deletes in the source | A deleted source row still sits in the target | Compare key sets in both directions; decide the delete policy |
| Time zones and daylight saving | Same instant stored in different bases | Convert to one basis before comparing |
| Character encoding and trailing spaces | Equal-looking text fails hash comparison | Trim and normalize in the canonical string |
| Null versus empty string | Source uses one, target uses the other | Define the rule in the mapping and test both |
| Floating point in the source | Rounding differs after conversion | Compare with a tolerance and document it |
| Backfills and restatements | History changes by design | Version the load and compare within a batch |
| Very large tables | Full-row hashing is slow | Hash by partition or date range; sample within it |
| Merged patients in healthcare sources | Keys point to a different person after a merge | Test on encounter keys; document merge handling |
Validation output can itself carry patient identifiers. Keep results inside the secured environment, report keys and counts instead of values, and follow your privacy office’s guidance for evidence storage.
Questions Analysts Ask About ETL Validation SQL
Is matching row counts enough?
No. Counts prove volume only. Add control totals for completeness and row-level hashes for accuracy. Counts also fail to catch truncation, mapping errors, and duplicated dimension rows.
How do I test an incremental load?
Compare source and target by the source change timestamp, then replay a known late update and a known deletion. Confirm the overlap window picks up changes made after the first load.
How much of the data should I compare at row level?
Compare as much as the platform allows. Hash by partition when tables are large. Use a full comparison for the most recent window and for tables that feed financial or regulatory reports.
Who owns data quality thresholds?
The data steward owns them, with input from the report owners. Developers implement them, and QA verifies them against the signed mapping document.
Run This Before Your Next Load Sign-Off
Pick one fact table in production. Write an independent source query for yesterday’s changes, count both sides by the source update timestamp, and run the late-update anti-join from Check 9. If either shows a gap, your watermark is losing rows. Thirty minutes of checking now beats a quarter of reports built on stale data.
Sources and Further Reading
- Microsoft Learn: HASHBYTES (Transact-SQL). Supported hash algorithms and input limits.
- Kimball Group: Dimensional Modeling Techniques. Reference for fact grain, dimensions, and slowly changing dimension types.
- Your warehouse platform documentation and the signed source-to-target mapping for your pipeline.
Object names are generic and need mapping to your environment. Column names for Epic source tables follow standard Clarity and vary by version. Run every query in a non-production environment first.
