ETL and Data Pipeline Testing

ETL stands for extract, transform, load: a job reads data from source systems, reshapes it by business rules and writes it into a target such as a data warehouse. ETL testing checks that every row that should arrive does arrive, exactly once, with every value transformed as the rules say. Example: each night a job copies yesterday’s orders from a shop’s order database into a sales warehouse, converts amounts to SAR, maps status codes to names and skips test stores; the finance dashboard reads only the warehouse.

The count, sum, orphan and duplicate queries from SQL for testers become the job’s checks, and the before-and-after comparison you used for migrations now runs on every load. Teams that load first and transform inside the target (ELT) use the same checks.

Why it matters

Pipeline defects are quiet. A job that drops a few rows, loads a batch twice or rounds amounts down still ends with status "success", and no user sees an error. The damage surfaces later as wrong numbers in reports and invoices, after decisions were made on them.

There is often no user interface: the output is thousands of rows, and the expected result comes from a mapping document and arithmetic. Proving that two data sets agree, automatically, is a skill you will reuse for integrations, migrations and analytics.

Extract, transform, load: what to check at each stage

Check each stage at its boundary. The test basis is usually a source-to-target mapping document: for each target column it names the source columns, the transformation rule and what happens to invalid values. If none exists, writing one with the developer is often the most useful test activity.

  • Extract: all rows of the agreed window arrive (for example yesterday from 00:00 Riyadh time up to, but not including, 00:00 today; a filter that ends at 23:59:59 misses a row stamped 23:59:59.5), the count matches what the source reports, and Arabic or Hindi text keeps its encoding. In CSV files, RFC 4180 says a field containing a comma, a double quote or a line break should be enclosed in double quotes, with each quote inside it doubled; a reader that just splits on commas shifts the columns after it.
  • Transform: every mapping rule gives the expected value, also for NULLs, unknown codes and boundary values; rows that break a rule go to a reject table with a reason, and rows a rule filters out are counted.
  • Load: rows land in the right table, business keys (the natural key from the source, such as order_id) stay unique, and loading the same batch twice leaves the target unchanged: the load is idempotent.

Try it: CSV Dataset Generator

Reconciliation: account for every row

Reconciliation compares source and target and explains every difference. A pipeline may load fewer rows than it read, because rules filter some and validation rejects others, but each row must end up in exactly one place: source rows = loaded + rejected + filtered. If that does not balance, rows were lost or duplicated.

One pipeline run: rows that fail validation go to a reject table with a reason, rows a rule filters out are counted, and the run publishes only when source rows = loaded + rejected + filtered.
  1. 1ExtractEvery row of the agreed window; count, columns and encoding as the source reports

    Branch: Schema or file check fails → stop the runFail

  2. 2Validate rowsRequired fields, types, allowed values

    Branch: Bad rows → reject table, with a reason codeFail

  3. 3TransformMapping rules: lookups, conversions, time zones, defaults, duplicates

    Branch: Rows a rule filters out are counted, not lost

  4. 4LoadBusiness keys stay unique; loading the same batch again changes nothing
  5. 5Reconcile and check qualityCounts, control totals and keys agree; quality checks passsource = loaded + rejected + filteredGate

Throughout steps 1–5 Audit table: the counts of every stage, for every run

Then:Publish to reports only when the run balances

Illustrative order: some pipelines validate inside the transform step, or load first and transform in the target (ELT).

Check at three levels. Counts per run, day and status show that totals agree. Control totals, such as the sum of amounts, show that values were not truncated or rounded away. Key-level comparison, the LEFT JOIN … IS NULL query from SQL for testers run in both directions, names the exact missing or extra ids, which is what a developer needs. Across two systems, export the keys from both sides and compare them in one place.

-- Balance one run (src_orders = the extracted rows, as staged)
SELECT
  (SELECT COUNT(*) FROM src_orders
    WHERE order_date = '2026-09-30')    AS source_rows,
  (SELECT COUNT(*) FROM fact_sales
    WHERE order_date = '2026-09-30')    AS loaded,
  (SELECT COUNT(*) FROM reject_orders
    WHERE order_date = '2026-09-30')    AS rejected,
  (SELECT COUNT(*) FROM src_orders
    WHERE order_date = '2026-09-30'
      AND store_type = 'TEST')          AS filtered;

-- Source orders that reached neither the target nor the reject table
SELECT s.order_id
FROM src_orders s
LEFT JOIN fact_sales f    ON f.order_id = s.order_id
LEFT JOIN reject_orders r ON r.order_id = s.order_id
WHERE s.order_date = '2026-09-30'
  AND COALESCE(s.store_type, '') <> 'TEST'   -- NULL store_type must not hide a row
  AND f.order_id IS NULL
  AND r.order_id IS NULL;

-- The other direction: target rows with no source order
SELECT f.order_id
FROM fact_sales f
LEFT JOIN src_orders s ON s.order_id = f.order_id
WHERE f.order_date = '2026-09-30'
  AND s.order_id IS NULL;

Try it: CSV to SQL Try it: SQL Query Validator Try it: Text Diff

Testing transformation rules

Each row of the mapping is a test condition. Design input rows with partitions, boundary values and decision tables, put them in a small source data set where you know every expected value, run the job and compare the target row by row. A large, production-like run adds volume and surprising values; you need both.

  • Lookups: status P → Paid; also test an unknown code and a lowercase p.
  • Conversions: amount × the exchange rate of the order date, rounded to 2 decimals. Test a result exactly halfway, such as 10.125: half-up rounding gives 10.13, half-to-even gives 10.12. Test a missing rate too.
  • Time zones: converting UTC to Riyadh time (UTC+3) moves orders from 21:00 UTC onwards to the next day; test 20:59:59 and 21:00:00 UTC.
  • Duplicates and NULLs: when the source sends an order twice, the rule decides which copy wins, for example the latest updated_at; a NULL discount may become 0.

Try it: Decimal Dataset Generator Try it: Date Test Data Generator

Data quality checks and backend validation

Reconciliation proves that nothing was lost; data quality checks prove that what arrived is usable. Teams name the dimensions differently, but most check those below, each as a query whose empty result is a pass.

Backend data validation means the pipeline runs these checks itself, on every run: a schema check on arrival, row rules that send bad rows to a reject table with a reason code, thresholds agreed with the data owner that stop the run before it publishes, and an audit table with the counts of every run. Test that machinery too: feed a row that breaks each rule, and push the reject rate over the threshold to confirm that the run stops and someone is alerted.

  • Completeness: every sale has a store and an amount.
  • Uniqueness: one row per business key, such as order_id.
  • Validity: known status codes, amount > 0, no future dates.
  • Consistency: every sale points to an existing store, and line totals add up to the order total.
  • Timeliness: the load finished on time, and the newest row is from the expected day.
  • Accuracy: a sample of target rows matches the source or a trusted report, field by field.

Worked example: a nightly load that ran twice

The run for 30 September reads 10,000 orders. The mapping filters 120 test-store orders and validation rejects 15 orders without a store id, so the target should gain 9,865 rows. The audit table shows a network timeout at 02:10, an automatic restart, and status "success" at 02:40.

The balance query counts 10,365 loaded rows, more than the source sent: 10,000 ≠ 10,365 + 15 + 120. The missing-order query returns nothing. The duplicate query returns 500 order ids, each loaded twice, which explains the whole surplus of 500 rows, and the SAR control total is too high by exactly their amounts. The job loads chunks of 500 rows and records each finished chunk in a progress table. One chunk was committed but its progress record was not, because the two writes were not in one transaction, so the restart inserted it again.

  • Bug report: run date, balance numbers, the 500 order ids, the restart in the audit log, and the expected behaviour: rerunning a batch leaves the target unchanged.
  • New checks: run every batch twice in testing, kill the job between a write and its progress record, and let the order_id uniqueness check stop publication.

Common mistakes

  • Trusting the job status: "success" means the job ended, not that the data is complete or correct.
  • Comparing only total row counts: a lost row and a duplicated row cancel out; add control totals and key-level comparison.
  • Not counting rejected and filtered rows, so no run can be balanced and lost data looks like filtering.
  • Testing rules only on a large production copy, where nobody knows what each value should be.
  • Running each batch once: reruns, restarts and late-arriving records are where loads duplicate or skip data.
  • Copying production personal data into a test environment without approved anonymisation; Saudi Arabia’s PDPL applies there too.

The QA perspective

Start with the mapping document: review it like a requirement and ask, before anything is built, what happens to NULLs, unknown codes, duplicates, late data and reruns. Agree with the data owner which checks stop publication, which only warn, and what reject rate is acceptable.

For each run you test, record the run id, date window, balance numbers, control totals and queries, so anyone can repeat the check. Automate the checks that found defects, and report failures with keys and business impact, such as "500 orders counted twice in the 30 September sales report", not "data mismatch".

Practice exercise

Balance a customer load and find the defects

Your task

A job loads customers from a CRM extract. Mapping: country is looked up (SA → Saudi Arabia, IN → India, any other code is rejected with BAD_COUNTRY); email is trimmed and lowercased; a row without customer_id is rejected with NO_ID; rows with is_test = Y are filtered out.

Balance the run below, then list every defect with its key, expected result and actual result.

Material
Extract (6 rows)
customer_id  email                 country  is_test
101          "Sara@Example.com"    SA       N
102          " ravi@example.com"   IN       N
103          "test@example.com"    SA       Y
(none)       "noid@example.com"    SA       N
105          "omar@example.com"    XX       N
106          "lina@example.com"    sa       N

Target dim_customer (3 rows)
customer_id  email                 country
101          "sara@example.com"    Saudi Arabia
102          " ravi@example.com"   India
106          "lina@example.com"    Unknown

Reject table (1 row)
customer_id  reason
(none)       NO_ID
Steps
  1. Work out which rows should be loaded, rejected and filtered.
  2. Check whether source = loaded + rejected + filtered holds.
  3. Compare each loaded row with the rules, field by field.
  4. Write each defect with its key, expected value and actual value.
Show the model answer
Model answer

Expected: 103 filtered; the row without an id rejected with NO_ID; 105 rejected with BAD_COUNTRY; 101 and 102 loaded. 106 has sa: as written, that is another code and must be rejected, unless lookups ignore case.

Balance: 6 source rows, but 3 loaded + 1 rejected + 1 filtered = 5. Customer 105 is in neither the target nor the reject table: data loss, the most serious defect, because the run looks successful.

Defect: 102 kept a leading space in email; expected "ravi@example.com". Lowercasing works (101), so trimming is missing.

Defect: 106 has country Unknown, which the mapping never produces; expected a reject with BAD_COUNTRY, or Saudi Arabia if lookups ignore case. Ask the mapping owner, and ask where filtered rows are counted: without that number no run can be balanced.

Frequently asked questions

What is the difference between ETL and ELT testing?

In ETL the data is transformed before it is loaded; in ELT it is loaded raw and transformed inside the target, usually with SQL. The checks are the same (reconciliation, transformation rules, data quality), but in ELT most of them run as SQL inside one system, and you also check the raw layer.

Do I need to compare every row, or is a sample enough?

Counts, control totals and key-level comparison should cover every row: they are cheap queries, and they find lost or duplicated rows that a sample misses. Field-by-field comparison of values can use a sample plus your small known data set, chosen so that every rule and every risky value is hit.

Is ETL testing the same as database testing?

It uses the same skills (SQL, constraints, reconciliation) on a different object. Database testing checks how one application stores and changes its data; ETL testing checks data moving between systems in batches, where the main risks are rows lost, duplicated or transformed wrongly across runs.

References

Practise with Mutqan tools

Generate CSV files with custom columns, data types, row counts and delimiters.

Test Data

Convert CSV data into CREATE TABLE and INSERT statements with inferred column types.

Database

Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.

Database

Compare two texts line by line or word by word and highlight differences.

Formatting

Generate decimal / floating-point datasets with fixed precision, currency-style amounts and rounding edge cases.

Test Data

Generate date datasets: random dates in a range, leap days, month ends, DST edges and multiple formats.

Test Data

Go deeper

Your progress on this topic

Part of the QA Roadmap