Data Lake Testing: The Checklist Most Teams Skip


Most data teams test their data lake — just not the right parts of it. They run row count checks on the final serving layer, wire up alerts on pipeline failures, and call it done. Then a downstream analyst notices that revenue figures for Q3 are off by 12%, and the investigation reveals that a schema change in the raw zone three weeks ago introduced a silent type mismatch that propagated through every transformation layer without triggering a single alert.
Data lake testing fails not because teams don't test, but because they test the wrong things at the wrong layer. The medallion architecture — raw, ingestion, cleansed, conformed, enriched, gold — creates six distinct testing contexts. Each layer has its own failure modes. A check that catches problems in the raw zone catches nothing in the gold zone, and vice versa.
This guide covers all six layers: what to test at each one, why those checks exist, and what specific query patterns to use on AWS S3, Azure ADLS, and Databricks. If you implement every check in this framework, bad data stops at the layer it entered — not at the dashboard where an executive spots it.
What this covers:
Layer 1: Raw Zone — file integrity, format conformance, arrival completeness
Layer 2: Ingestion Zone — deduplication, row count parity, latency SLAs
Layer 3: Cleansed Zone — null handling, type casting, encoding normalization
Layer 4: Conformed Zone — business rule enforcement, referential integrity
Layer 5: Enriched Zone — join accuracy, derived field validation
Layer 6: Gold/Serving Zone — BI-readiness, metric consistency, SLA compliance
Layer 1 — Raw Zone Testing
The raw zone is where files land — S3 prefixes, ADLS containers, or Databricks external locations. Nothing has been transformed. This is your first and best opportunity to catch problems before they propagate.
What goes wrong here: Files arrive in the wrong format (Parquet when you expected JSON), with unexpected schema changes, at the wrong volume, or not at all. A missing partition that silently drops a day's worth of records is a raw zone failure.
Checks for the Raw Zone
Check | What It Catches | SQL / Approach |
File arrival completeness | Missing partitions, skipped hours | Count expected vs. actual partition folders by date/hour |
Row count by partition | Drop vs. prior day baseline | Compare today's count to 7-day rolling average |
File format conformance | Schema-on-read failures | Attempt schema inference; fail if column count or types differ from registered schema |
Null rate on required fields | Source system stopped populating a key field | COUNT(*) - COUNT(field) per column, alert if > 0.1% |
File size anomaly | Truncated files, empty loads | Alert if file size < 10% of rolling average |
Duplicate file detection | Re-delivered files causing double-counts | Hash file contents or use ETag/MD5; alert if same hash seen twice in 24 hours |
AWS S3 approach: Use S3 Inventory or Athena to query s3://bucket/prefix/date=*/ partition counts against your expected schedule.
Azure ADLS approach: Azure Data Factory has native file existence checks in its validation activity. For custom checks, query the dfs.core.windows.net REST API for directory listing.
Databricks approach: Use dbutils.fs.ls() to enumerate partitions and compare against a schedule table, or use Delta Lake's DESCRIBE HISTORY to audit when each partition was last written.
-- Raw zone: detect missing date partitions
SELECT
expected_date,
CASE WHEN actual_count IS NULL THEN 'MISSING' ELSE 'PRESENT' END AS status,
COALESCE(actual_count, 0) AS row_count
FROM (
SELECT date_add(current_date(), - seq) AS expected_date
FROM (SELECT explode(sequence(0, 6)) AS seq)
) expected
LEFT JOIN (
SELECT DATE(partition_date) AS actual_date, COUNT(*) AS actual_count
FROM raw.events
GROUP BY 1
) actual ON expected.expected_date = actual.actual_date
HAVING status = 'MISSING'
ORDER BY expected_date DESC;The failure pattern to watch for: A source system moves from a UTC timestamp to a local timestamp without notice. Your partition logic splits records across the wrong day boundaries. Row counts look plausible per partition — the data just exists in the wrong partitions. This is caught only if you cross-check partition-level counts against an expected volume from the source system.
Layer 2 — Ingestion Zone Testing
The ingestion zone is where raw files get parsed, decoded, and loaded into your processing engine. This is the first point where records become rows — and where duplicate records, parsing failures, and latency violations first become measurable.
What goes wrong here: The same file gets re-delivered and loaded twice. A Kafka consumer resets its offset and replays 6 hours of events. A CDC stream catches an UPDATE as both a DELETE and an INSERT. Deduplication logic that worked for yesterday's volume fails under today's load.
Checks for the Ingestion Zone
Check | What It Catches | Implementation |
Row count parity | Rows lost or added during parsing | Compare raw file row count to ingested table count by partition |
Duplicate record detection | Re-delivered files, offset resets | Count distinct primary keys vs. total rows; alert if duplicates > 0 |
Parse error rate | Malformed records silently dropped | Log parse failures; alert if error rate > 0.01% |
Ingestion latency | Files landing late, consumer lag | Compare file_arrival_time to ingestion_complete_time; alert if > SLA |
Schema evolution detection | New column added without notification | Compare column list to registered schema at each load |
Null primary key rate | Source sent records with no business key | COUNT(*) WHERE primary_key IS NULL; this should always be 0 |
-- Deduplication check: identify duplicate records by primary key
SELECT
source_id,
COUNT(*) AS occurrence_count
FROM ingestion.events
WHERE partition_date = CURRENT_DATE - INTERVAL '1' DAY
GROUP BY source_id
HAVING COUNT(*) > 1
ORDER BY occurrence_count DESC
LIMIT 100;
-- Row count parity: raw vs ingested
SELECT
r.partition_date,
r.raw_row_count,
i.ingested_row_count,
r.raw_row_count - i.ingested_row_count AS row_delta,
ROUND(100.0 * (r.raw_row_count - i.ingested_row_count) / r.raw_row_count, 2) AS loss_pct
FROM (
SELECT partition_date, COUNT(*) AS raw_row_count FROM raw.events GROUP BY 1
) r
JOIN (
SELECT partition_date, COUNT(*) AS ingested_row_count FROM ingestion.events GROUP BY 1
) i USING (partition_date)
WHERE ABS(r.raw_row_count - i.ingested_row_count) > 0
ORDER BY partition_date DESC;The failure pattern to watch for: A Kafka consumer group is reset to replay from the beginning of a topic to fix a processing bug. The ingestion zone accumulates 3x the expected records. Aggregate queries downstream triple their output. The issue surfaces only when a director notices that weekly active users jumped 200% overnight.
Layer 3 — Cleansed / Standardized Zone Testing
The cleansed zone is where raw data gets normalized: nulls handled, types cast, strings trimmed, dates standardized. This is the highest-leverage layer for data quality checks because transformations here affect every downstream consumer.
What goes wrong here: A date field gets cast from VARCHAR to DATE, and records with dates like 99/99/9999 or 0000-00-00 silently become NULL instead of failing the job. A phone number field gets stripped of its country code. An encoding normalization converts a multi-byte UTF-8 character to a replacement character � and nobody notices.
Checks for the Cleansed Zone
Check | What It Catches | Implementation |
Type cast success rate | Silent NULL conversion from bad values | Compare NULL count before and after cast; alert if post-cast NULLs > pre-cast NULLs |
Encoding validation | UTF-8 corruption, replacement characters | Scan for � (U+FFFD) or ? substitution characters in text fields |
Date range validity | Impossible dates, future dates on historical fields | WHERE event_date > CURRENT_DATE OR event_date < '2000-01-01' |
String length anomalies | Truncation, padding, runaway concatenation | MAX(LENGTH(field)) vs. registered max length; alert on outliers |
Standardization completeness | Inconsistent formats after normalization | Count format variants in a standardized field (e.g., phone formats) |
Reference field validity | Foreign keys referencing retired or non-existent values | LEFT JOIN to reference table; count unmatched rows |
-- Encoding check: find records with UTF-8 replacement characters
SELECT
record_id,
customer_name,
LENGTH(customer_name) - LENGTH(REPLACE(customer_name, '�', '')) AS replacement_char_count
FROM cleansed.customers
WHERE customer_name LIKE '%�%'
LIMIT 50;
-- Type cast integrity: compare NULL rates before and after cleansing
SELECT
'raw' AS layer,
COUNT(*) AS total_records,
SUM(CASE WHEN birth_date IS NULL THEN 1 ELSE 0 END) AS null_count
FROM raw.customers
UNION ALL
SELECT
'cleansed',
COUNT(*),
SUM(CASE WHEN birth_date IS NULL THEN 1 ELSE 0 END)
FROM cleansed.customers;
-- If cleansed null_count > raw null_count: cast failures are silently creating NULLsThe failure pattern to watch for: A source system switches from DD/MM/YYYY to YYYY-MM-DD date format partway through a migration. Your cleansing logic handles the new format, but records from the first 3 months of the year ambiguously parse — 01/02/2024 is both Jan 2 and Feb 1 depending on format. Silently, half your records get wrong dates. This is only caught by comparing a sample of cleansed dates against the source system's own display.
Layer 4 — Conformed Zone Testing
The conformed zone applies business rules and integrates data across sources. This is where a customer_id from your CRM gets matched to a user_id from your product database. It is also where the most expensive data quality failures occur — because by this point, multiple upstream sources have contributed records.
What goes wrong here: A cross-source join on a key that is not truly unique in one of the source systems fans out records. A business rule that was valid last year (order_amount > 0) no longer covers edge cases (order_amount = 0 for fully comped orders). A reference dataset gets updated, and conformed records referencing the old values silently become unmatched.
Checks for the Conformed Zone
Check | What It Catches | Implementation |
Referential integrity | Orphaned foreign keys after join | LEFT JOIN reference_table; WHERE ref.key IS NULL; COUNT > 0 = violation |
Business rule enforcement | Records violating domain constraints | Run registered rule set against every batch; fail if violation rate > threshold |
Fan-out detection | Many-to-many joins inflating row counts | Compare post-join count to pre-join count; > 5% increase = investigate |
Cross-source key match rate | CRM-to-product ID mismatches | Count matched vs. unmatched records across source systems |
Temporal consistency | Event sequences that violate time order | WHERE event_end < event_start or created_at > updated_at |
Aggregate totals reconciliation | Conformed layer totals don't match source | Sum the same metric in conformed vs. raw; diff should be < 0.001% |
-- Fan-out detection: confirm joins aren't multiplying rows
WITH before_join AS (
SELECT COUNT(*) AS order_count FROM conformed.orders
),
after_join AS (
SELECT COUNT(*) AS joined_count
FROM conformed.orders o
JOIN conformed.promotions p ON o.customer_id = p.customer_id
)
SELECT
b.order_count,
a.joined_count,
a.joined_count - b.order_count AS row_inflation,
ROUND(100.0 * (a.joined_count - b.order_count) / b.order_count, 2) AS inflation_pct,
CASE WHEN a.joined_count > b.order_count * 1.05 THEN 'FAIL' ELSE 'PASS' END AS test_status
FROM before_join b, after_join a;
-- Cross-source key match rate
SELECT
COUNT(*) AS total_crm_customers,
SUM(CASE WHEN p.user_id IS NOT NULL THEN 1 ELSE 0 END) AS matched,
SUM(CASE WHEN p.user_id IS NULL THEN 1 ELSE 0 END) AS unmatched,
ROUND(100.0 * SUM(CASE WHEN p.user_id IS NULL THEN 1 ELSE 0 END) / COUNT(*), 2) AS unmatch_pct
FROM conformed.crm_customers c
LEFT JOIN conformed.product_users p ON c.email = p.email;The failure pattern to watch for: A marketing team loads a promotions table where one promotion applies to all customers. Every order record joins to every promotion record. The orders table has 1 million rows. The promotions table has 50 entries, but one entry has a NULL on the join key, which your database treats as matching everything. Revenue reports suddenly show 50x the expected figures.
Layer 5 — Enriched Zone Testing
The enriched zone adds derived features: calculated fields, ML model scores, third-party data appends, aggregated metrics. This layer is where business logic gets encoded into data — and where the widest variety of unique failure modes live.
What goes wrong here: A lifetime_value field is calculated using a window function with an off-by-one error in the date range. An ML model scoring pipeline returns NaN for records outside its training distribution, and downstream systems treat NaN as 0. A third-party data append (firmographic data for B2B accounts) has a 30-day lag, enriching last month's data with last-last-month's firmographic snapshot.
Checks for the Enriched Zone
Check | What It Catches | Implementation |
Derived field range validity | Calculated values outside expected range | WHERE ltv < 0 OR ltv > 1000000; configure expected bounds per metric |
NaN/Infinity detection | ML model score failures | WHERE score != score (NaN check in SQL) or IS_NAN(score) in Spark |
Third-party append match rate | Low join rate = stale or missing enrichment | Count enriched vs. unenriched records; alert if append rate drops 10% vs. baseline |
Aggregation window correctness | Wrong date range in window functions | Spot-check aggregated values against manually calculated point values |
Feature drift detection | ML input features shifting from training distribution | Compare mean and std dev of each feature column to training baseline |
Derived field consistency | Two fields that should sum/relate don't | WHERE field_a + field_b != field_c where c is defined as their sum |
-- NaN detection in Databricks/Spark SQL
SELECT
COUNT(*) AS total_scored,
SUM(CASE WHEN isnan(model_score) THEN 1 ELSE 0 END) AS nan_count,
SUM(CASE WHEN isinf(model_score) THEN 1 ELSE 0 END) AS inf_count,
SUM(CASE WHEN model_score IS NULL THEN 1 ELSE 0 END) AS null_count,
ROUND(100.0 * SUM(CASE WHEN isnan(model_score) OR isinf(model_score) OR model_score IS NULL THEN 1 ELSE 0 END) / COUNT(*), 2) AS bad_score_pct
FROM enriched.customer_scores
WHERE score_date = CURRENT_DATE;The failure pattern to watch for: A customer_segment field is calculated from lifetime_value with a threshold of $500 for the "premium" tier. The LTV calculation uses SUM(order_amount) with a date range filter. A timezone bug causes one day of orders to be counted twice for some customers. LTV is inflated, and 15% of customers are promoted to "premium" who shouldn't be. Email campaigns fire to the wrong segment.
Layer 6 — Gold / Serving Zone Testing
The gold zone is what your BI tools, analysts, and applications read from. It is the end of the pipeline and the last chance to catch anything that made it through all previous layers. By this point, fixing a problem means tracing it back through five upstream layers — which is why catching it here is expensive.
What goes wrong here: A metric that was correct yesterday reads wrong today because the underlying aggregation logic assumes a complete day of data, but the gold zone gets refreshed at 6 AM before all source data has arrived. A rollup table that powers an executive dashboard hasn't been updated since the underlying model was refactored. A new data consumer reads from the gold zone directly and runs a query that doesn't apply the expected filter — so they see 40% more records than intended.
Checks for the Gold Zone
Check | What It Catches | Implementation |
Freshness SLA compliance | Stale tables served to BI tools | MAX(updated_at) vs. expected refresh time; alert if > SLA threshold |
Metric consistency with conformed layer | Gold zone metric doesn't match conformed source | Reconcile key metrics (revenue, MAU, claims) between gold and conformed |
Row count stability | Unexpected drops or spikes in serving tables | Compare today's count to 7-day rolling average; alert if > 10% deviation |
Null rate in key BI dimensions | Dimensions that drive filters or GROUP BY | Zero-tolerance null check on dimension keys used in BI aggregations |
BI access filter validation | Data leakage across tenants or access boundaries | Sample records per access group; confirm correct records are visible |
Partition coverage | Partitions expected by BI tools exist | Assert that all expected date/region/product partitions are present |
-- Gold zone freshness check
SELECT
table_name,
MAX(updated_at) AS last_refresh,
CURRENT_TIMESTAMP - MAX(updated_at) AS staleness,
expected_refresh_interval,
CASE
WHEN CURRENT_TIMESTAMP - MAX(updated_at) > expected_refresh_interval * 1.2
THEN 'STALE'
ELSE 'FRESH'
END AS freshness_status
FROM gold.table_refresh_log
GROUP BY table_name, expected_refresh_interval
HAVING freshness_status = 'STALE'
ORDER BY staleness DESC;
-- Metric reconciliation: gold vs. conformed
SELECT
'gold' AS layer,
SUM(revenue_amount) AS total_revenue
FROM gold.revenue_summary
WHERE report_date = CURRENT_DATE - INTERVAL '1' DAY
UNION ALL
SELECT
'conformed',
SUM(order_amount)
FROM conformed.orders
WHERE order_date = CURRENT_DATE - INTERVAL '1' DAY
AND status = 'completed';
-- Revenue delta > 0.1% = reconciliation failureThe Complete 6-Layer Data Lake Testing Checklist
Use this as your implementation checklist. Layer 1–2 checks should be automated and run at ingestion time. Layer 3–6 checks should run as part of your transformation pipeline.
Layer | Zone | Priority Checks | Run At |
1 | Raw | File arrival, row count vs. baseline, format conformance, null rate on PKs | File landing |
2 | Ingestion | Deduplication, row parity, parse error rate, latency SLA | Post-load |
3 | Cleansed | Type cast null rate, encoding scan, date range validity, string length | Post-transform |
4 | Conformed | Referential integrity, fan-out detection, cross-source match rate, business rules | Post-join |
5 | Enriched | Derived field ranges, NaN/Inf detection, append match rate, feature drift | Post-enrichment |
6 | Gold | Freshness SLA, metric reconciliation, row stability, BI filter validation | Pre-serve |
Implementation Priority
If you're starting from zero, implement in this order:
Raw zone file arrival check — know when data doesn't arrive before your downstream pipeline starts
Ingestion row count parity — confirm nothing is lost in parsing
Gold zone freshness — know when your serving layer is stale before analysts report it
Conformed fan-out detection — the most expensive failure to fix if missed
Everything else in layers 3 and 5 — by priority based on your specific failure history
The pattern is clear: test at the point of entry for each layer, not just at the output. A problem caught at layer 1 costs nothing to fix. The same problem caught by an executive at layer 6 costs days of investigation and remediation.
Vexdata automates data lake testing across all six layers, with pre-built checks for AWS S3, Azure ADLS, and Databricks that deploy in hours, not months. See how it works at vexdata.io/data-lake-testing or book a 30-minute walkthrough to see it running against your stack.




Comments