ETL Validation SQL

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.

What you will take away
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

Every row read is written or rejected
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

Count by the same change window on both sides
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

Sum of a money column
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.

Missing, extra, and mismatched rows in one pass
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.

Keep hash outputs free of PHI
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.

Independent re-implementation of the mapping rule
-- 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

Duplicate business keys in the fact table
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

Unknown-member and orphan counts
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.

Overlaps and duplicate current rows
-- 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.

Updates missed by the watermark
-- 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

Truncation and column definitions
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.

Fingerprint before and after a rerun
-- 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.

ETL-3318  |  Encounter fact misses 2.4% of source rows updated after initial loadHigh
EnvironmentWarehouse load v2.7, nightly incremental, window Sept 1 to Sept 30
Reported byQuality analytics
ExpectedEvery encounter changed in the source appears in the fact table with the latest values.
Actual1,031 of 42,960 changed encounters (2.4%) carry stale values. All are discharge-time updates entered after the first load of the encounter.
EvidenceCheck 2 gap by day, Check 9 anti-join (1,031 keys), Check 4 hash mismatches match the same keys. Keys only; no values in this ticket.
Root causeWatermark filters on the encounter creation date. Later updates keep the original creation date and are never picked up.
FixFilter on the source update timestamp and add a three-day overlap with an idempotent upsert.
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.

Illustrative figures
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: doubled revenue
-- 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

After: older row closed, guard query added
-- 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.

Store every check result with 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
The mistake I made once
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.

Download the ETL Validation SQL Query Pack and Source-to-Target Mapping Template (PDF)
Eleven checks, the failure decision tree, mapping template, and load sign-off checklist on two printable pages.

Download the PDF

Sources and Further Reading

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.

Scroll to Top