data-validation-pipelines

A validation *boundary* is any point where data crosses from a system you do not

SKILL.md

GitHub

Data Validation Pipelines (Verbose)

Core Patterns

Validate at the Boundary

A validation boundary is any point where data crosses from a system you do not control into one you do: an HTTP response, an S3 drop, a Kafka topic, a partner SFTP file, a form submission. Put one gate at each boundary that parses raw input into a typed domain object. Everything downstream of the gate may assume validity.

from datetime import datetime, timezone
from pydantic import BaseModel, Field, ValidationError, field_validator

class OrderEvent(BaseModel):
    order_id: str = Field(pattern=r"^ORD-[0-9A-F]{12}$")
    customer_id: int = Field(gt=0)
    total_cents: int = Field(ge=0, le=1_000_000)   # >$10k routes to manual review
    currency: str = Field(pattern=r"^[A-Z]{3}$")
    placed_at: datetime

    @field_validator("placed_at")
    @classmethod
    def sane_timestamp(cls, v: datetime) -> datetime:
        now = datetime.now(tz=timezone.utc)
        if v > now:
            raise ValueError("placed_at in the future")
        if (now - v).days > 365:
            raise ValueError("placed_at older than retention window")
        return v

The alternative — validating defensively wherever data is used — fails for three reasons. The checks are never complete, because nobody knows the full set of call sites. They drift apart, so one module treats currency="" as USD and another raises. And they lose provenance: by the time a NoneType error surfaces in a report, the file that produced it is four hops upstream and two days old.

Parse, don't validate: the gate's output type should make invalid states unrepresentable. If an OrderEvent exists, its currency is three uppercase letters — no downstream code needs to re-check.

Schema Validation vs Data Quality

These are different jobs, and teams routinely ship only the first.

Schema validation Data quality validation
Question Is it shaped right? Is it true?
Catches Missing column, wrong type, bad enum Impossible values, stale loads, broken joins
Fails when Producer changes structure Producer changes meaning
Tooling Pydantic, Avro/Protobuf + registry, dbt contracts Great Expectations, dbt tests, Soda, custom SQL
Cost Per-row, cheap Aggregate scan, expensive

The dangerous class of bug lives entirely in the second column. An upstream team renames a field, backfills the old one with zeros for compatibility, and every type check passes while revenue_usd is 0.0 for a month. Or a currency column silently switches from USD to local currency: still float64, still positive, still non-null, and every regional total is now nonsense.

Semantic checks that repeatedly earn their keep:

import great_expectations as gx

batch.expect_column_values_to_be_between("age", min_value=0, max_value=120)
batch.expect_column_values_to_be_in_set("country_code", VALID_ISO_3166)
batch.expect_column_values_to_be_unique("order_id")
batch.expect_column_pair_values_A_to_be_greater_than_B("shipped_at", "placed_at")
batch.expect_table_row_count_to_be_between(min_value=3_500_000, max_value=5_000_000)
batch.expect_column_mean_to_be_between("total_cents", min_value=1500, max_value=9000)

That last one — asserting on a distribution statistic rather than a row predicate — is what catches the unit change, the silent backfill, and the deduplication regression. No per-row rule can see any of those.

Volume, Freshness, and Referential Integrity

Missing data is more common than malformed data and far quieter. Three assertions cover most of it:

-- 1. Freshness: has anything landed recently?
select max(loaded_at) as last_load,
       datediff('minute', max(loaded_at), current_timestamp) as staleness_min
from analytics.orders
having staleness_min > 90

-- 2. Volume: is today's partition in family with recent history?
with daily as (
  select load_date, count(*) as n
  from analytics.orders
  where load_date >= dateadd('day', -28, current_date)
  group by 1
),
baseline as (select median(n) as med from daily where load_date < current_date)
select d.load_date, d.n, b.med
from daily d cross join baseline b
where d.load_date = current_date
  and (d.n < 0.7 * b.med or d.n > 1.5 * b.med)

-- 3. Referential integrity: orphaned foreign keys
select o.customer_id
from analytics.orders o
left join analytics.customers c on o.customer_id = c.customer_id
where c.customer_id is null

In dbt these map to dbt_utils.recency, a custom volume test, and the built-in relationships test — worth using the packaged versions so results land in the manifest and show up in the docs graph rather than only in a job log.

Express bounds relative to a trailing window, not as constants. A threshold of row_count > 1000000 written when the table was young becomes meaningless once the table does ten million a day, and it will not fire on a 60% drop.

Quarantine Instead of Halting

A pipeline that fails the whole batch on the first bad row gets disabled by the third page. Split the stream:

valid, rejected = [], []
for raw in incoming:
    try:
        valid.append(OrderEvent.model_validate(raw))
    except ValidationError as exc:
        rejected.append({
            "raw": raw,
            "errors": exc.errors(),          # field, rule, and offending value
            "batch_id": batch_id,
            "rejected_at": datetime.now(tz=timezone.utc),
        })

write_rejects(rejected)
reject_rate = len(rejected) / max(len(incoming), 1)
if reject_rate > 0.01:
    raise PipelineError(f"reject rate {reject_rate:.2%} exceeds 1% threshold")
load(valid)

Keeping the raw payload is the part people skip and later regret — without it you can neither reproduce the failure nor replay the rows once the upstream bug is fixed. The reject table also doubles as the best data-quality dashboard you will get for free: rules ranked by fire count tell you which producer to go talk to.

Severity Tiers and Write-Audit-Publish

Severity Behavior Example
error Block publication; readers keep the last good version Primary key not unique; row count −80%
warn Publish, open a ticket, annotate the table New unseen category; null rate up 3pp
info Record a metric only Distribution shift inside tolerance

Pair error with write-audit-publish: load into a staging table, run the checks there, and swap the production pointer only if they pass. Readers never observe a partially-validated table, and rollback is a pointer move rather than a restore.

Contracts at the Producer

Runtime validation is a net; a contract is a fence. Where the producer is inside your organization, register the schema and make incompatible changes fail their CI rather than your 3am job.

# dbt: enforce the shape at build time
models:
  - name: dim_customer
    config:
      contract:
        enforced: true
    columns:
      - name: customer_id
        data_type: bigint
        constraints:
          - type: not_null
          - type: primary_key

Avro or Protobuf behind a schema registry set to BACKWARD compatibility does the same for streams: a producer that drops a required field cannot deploy at all.

For validation of the transformations between tables rather than the data entering them, see sql-optimization for query-level concerns and dimensional-modeling for grain and key integrity.

Common Anti-Patterns

❌ Null checks scattered through transformation code — incomplete, mutually inconsistent, and the error surfaces far from its source. ✅ One validation gate per boundary that parses into a typed object.

❌ Treating schema conformance as data quality — types pass while the values are nonsense. ✅ Add range, set, uniqueness, referential, freshness, and distribution checks.

❌ Failing the entire load on one bad row. ✅ Quarantine rows with their raw payload; fail on reject rate.

❌ Every check severity set to error — on-call learns to rerun with checks skipped, and validation stops existing in practice. ✅ Tier checks; reserve blocking for correctness-critical invariants.

❌ Validating only in the warehouse — the bad data is already six hours old and has fanned out to three downstream marts. ✅ Validate at ingest too; warehouse tests are the second line, not the first.

❌ Hard-coded thresholds nobody revisits. ✅ Bound against a trailing window so limits track real growth.

❌ Validation results that go nowhere but the job log. ✅ Persist outcomes as a table; alert on trends, not only the latest run.

❌ Checking only for nulls in a column that is never null — a rule that has never fired is telling you something, usually that it tests the wrong thing. ✅ Periodically review fire rates and retire or retarget dead rules.

Data Validation Checklist

  • Every external input has exactly one validation gate
  • Gate output is a typed object downstream code can trust
  • Schema checks cover types, required fields, enums, patterns
  • Semantic checks cover ranges, sets, uniqueness, cross-field consistency
  • Referential integrity tested on every foreign key
  • Freshness assertion with an explicit staleness SLA
  • Row-count check bounded against a trailing window, not a constant
  • At least one distribution statistic asserted per critical numeric column
  • Invalid rows quarantined with raw payload, failing rule, and batch id
  • Job fails on reject rate rather than on a single rejected row
  • Checks tiered into error / warn / info
  • Write-audit-publish so readers never see unvalidated data
  • Schema contract enforced at the producer where the producer is internal
  • Check results persisted and trended over time
  • Rule fire rates reviewed; dead rules retired