Writing · contract-driven-data-pipeline

Why My Data Pipeline Rejects 84% On Purpose

Contract-Driven Data Pipeline · 11 min read · 2,478 words

Contract-Driven Data Pipeline — Faker to S3 to PySpark to PostgreSQL to dbt, orchestrated by Airflow

A pipeline that throws away 838 of every 1,000 rows sounds broken. Mine isn't — it's doing exactly what I designed it to do, and it can name the specific rule that killed each of those 838 rows.

That second part is the whole project. Because the question every data team eventually gets asked is not "did the pipeline run?" It's "why isn't this record in the warehouse?" — and the honest answer is usually a shrug.

I built the opposite of that shrug.

Architecture — Faker to the S3 raw zone, PySpark enforcing the contract, accepted rows to PostgreSQL and rejected rows to the S3 rejects and quality zones, then dbt silver and marts, consumed by Athena, Grafana and Streamlit
The data plane end to end, with the five Airflow tasks along the top. 162 accepted rows go one way, 838 rejected the other — each carrying the rule it broke.

The shrug is an architecture problem

The first version of this pipeline did what most pipelines do. Validation lived inline in a PySpark function: a trim() here, a regex there, a .filter() that quietly deleted anything inconvenient. A row that failed simply ceased to exist.

That's three sources of truth pretending to be one:

  1. The code that accepts rows.
  2. The (missing) explanation of why rows were dropped.
  3. The human-readable documentation of what "valid" means — a separate Markdown file that started drifting the moment I saved it.

They disagree eventually. They always do. And when they do, nobody notices, because the evidence was deleted.

Amazon Athena querying the raw S3 zone — rows with blank names and emails, phone numbers like 001-826-759-8673, zip codes like ABCDE, and ages of -5, 0 and 150
The input, queried straight over the S3 raw zone with Athena. Ages of 150 and -5, zip codes spelling ABCDE, blank names. This is what the contract is up against.

One contract, four artifacts

The fix was to pull the rules out of Spark entirely, into a pure Python module — no Spark, no I/O, no imports beyond dataclasses. Each field gets exactly one rule, expressed as data rather than logic:

FieldRule("phone", REGEX, "invalid_phone",
          "Greek mobile number (69 + 8 digits).",
          DIRECT_IDENTIFIER, pattern=PHONE_REGEX)

There are six of them — name, email, phone, zip_code, age, city — in a single frozen tuple. From that one definition I generate four things that are now structurally incapable of disagreeing:

That last one matters more than it looks. Documentation drift isn't a documentation problem — it's a governance problem with a documentation symptom. Making it a red build is the cheapest fix I know.

How Spark compiles the contract

The interesting part is that the accept path and the reject path are built from the same predicate function, so they can't diverge:

def _rule_ok(rule: FieldRule) -> Column:
    c = col(rule.field)
    if rule.kind == NON_EMPTY:
        return c.isNotNull() & (length(c) > 0)
    if rule.kind == REGEX:
        return c.isNotNull() & c.rlike(rule.pattern)
    if rule.kind == INT_RANGE:
        return c.isNotNull() & (c >= rule.minimum) & (c <= rule.maximum)

Three design choices are hiding in those six lines.

It's null-safe by construction. Every predicate starts with isNotNull(). In Spark SQL a null propagates through comparisons as null, not false — so without that guard, null rows fall out of both the accept filter and the reject filter and vanish into the gap. With it, the accepted set and the rejected set are a provable partition: every input row lands in exactly one. There's a test that asserts precisely that (test_clean_and_rejected_partition_the_input).

Both paths normalise identically. A shared _prepared() step trims the string fields and casts age to int before either path evaluates. Otherwise " Athens " could be accepted by one path and rejected by the other.

Rejection precedence is the contract's declaration order. The reject path folds the rules into a chained when(...), so a row failing three checks is attributed to the first one in the tuple. Simple, deterministic, and testable.

There are no Python UDFs anywhere — the whole transform is Spark SQL built-ins. That's not aesthetics: it means executors never need the contract module shipped to them via --py-files, and the job runs unchanged in-process or on a cluster.

The pseudonym is part of the contract too

The loaded user_id is not the source id. It's a deterministic MD5 of name || email || phone — the three fields the contract labels as direct identifiers:

df.withColumn(SURROGATE_KEY, md5(concat_ws("||", *SURROGATE_SOURCES)))

The same person always maps to the same surrogate, which is what makes the downstream ON CONFLICT (user_id) DO NOTHING upsert idempotent — you can re-run the pipeline all day without duplicating anyone. And no natural key ever becomes the join key in the warehouse.

The data dictionary explains that in prose, automatically, because both the classification and the surrogate's source fields come from the same tuple.

Secrets in Airflow, logic in plain Python

There's a quiet decision underneath all of this that makes the testing possible.

Airflow connections are declared as code — AIRFLOW_CONN_AWS_DEFAULT, AIRFLOW_CONN_POSTGRES_DEFAULT, AIRFLOW_CONN_SPARK_DEFAULT — as env vars in Compose, in JSON form (the URI form mangles AWS secret keys that contain slashes). Nobody clicks through the Airflow UI to set up a connection; docker compose up produces a working stack.

But the ETL scripts never read those connections. The DAG resolves the credentials and injects them:

client = S3Hook(aws_conn_id="aws_default").get_conn()
upload_to_s3(client, local_path, bucket, key)

The loader gets the same treatment — the task builds a connect(dbname) factory from BaseHook.get_connection("postgres_default") and hands it in. So secrets live in Airflow's connection store at orchestration time, while generate_dirty_data_S3.py and load_to_db_final.py stay ordinary Python modules that import and run without Airflow installed anywhere.

That's not a purity exercise. It's exactly why the 56-test suite and the Postgres smoke job can run in CI on a plain Python image, and why the transform can be unit-tested in a local SparkSession. The same reason the heavy imports sit inside the task functions rather than at module level: DAG parsing stays light and never breaks on a missing dependency.

Making quality visible, not assumed

Every run computes a data-quality report — total, accepted, rejected, and rejected-by-reason. That report goes to four places:

  1. Logged as JSON in the Airflow task.
  2. Written to data/dq_report.json.
  3. Uploaded to a date-partitioned quality/dt=<ds>/ zone in S3.
  4. Pushed to StatsD as airflow.dq.* gauges over UDP.

Point 4 is the one I'd fight for. Airflow already emits StatsD metrics, so the Spark task just borrows the same host and port that Airflow is configured with, and the numbers land in the exact same Prometheus → Grafana path as the operational ones. A statsd_mapping.yml rewrites the dotted metric names into properly labelled Prometheus series.

Grafana "Airflow — Pipeline Observability" dashboard showing task duration by stage and DAG run duration on top, and underneath a Data Quality row with the accept-rate gauge, accepted versus rejected, and rejections by reason
One dashboard, nine panels. Ops metrics on top, data quality directly underneath — same screen, same refresh, same on-call.

The result: task durations by stage, DAG-run duration, task finishes by state, scheduler heartbeat — and right underneath them, the accept-rate gauge, accepted-vs-rejected, and rejections by reason. Data quality sits next to operational health instead of in a separate silo nobody opens.

Here's one representative run of 1,000 rows:

Amazon Athena running the saved rejections_by_reason query over the S3 rejects zone, returning the count of quarantined rows grouped by rejection_reason
The same numbers, reachable by anyone with SQL and no access to my laptop. rejections_by_reason is a Terraform-managed saved query over the rejects zone.

The low accept rate is deliberate: the Faker generator corrupts several fields independently, so a row must survive all six checks. The exact number moves run to run; the shape holds — phone and email are the strictest formats, so they dominate.

What this is worth outside the engineering team

It's worth saying plainly what the rejection lineage buys, because it isn't an engineering nicety.

An audit gets an answer instead of an investigation. When someone asks why a particular customer isn't in the quarterly report, the answer is a rule name and a timestamped object in S3 — not a two-day archaeology exercise across logs that have already rotated.

Root cause points upstream, with a number attached. 348 rows failing the phone format isn't a pipeline bug. It's a source system emitting data that violates the agreed shape, and now it's a figure you can take to the team or vendor producing it. Without the lineage, that signal is indistinguishable from "the pipeline ran fine."

Degradation shows up as a slope, not a surprise. Because every run's report lands in the quality/ zone, accept_rate_over_time is a query anyone can run. A source that quietly starts sending malformed postcodes becomes visible as a trend line — before someone notices a dashboard is wrong.

Most organisations find out about data quality problems from a business user. This inverts that.

The warehouse end: bulk, idempotent, and gated

The loader uses psycopg2.execute_values rather than row-by-row inserts, with ON CONFLICT (user_id) DO NOTHING, and reads cur.rowcount afterwards so the log distinguishes actually inserted from skipped as duplicate. CREATE DATABASE — which can't be parameterised — goes through sql.Identifier instead of string formatting. zip_code stays TEXT, because a 5-digit postcode in an INTEGER column silently loses its leading zero.

On top of that, dbt builds a silver view (stg_users, adding email_domain and age_band) feeding two marts (users_by_city, users_by_age_band). Then a fifth DAG task runs dbt testnot_null, unique, accepted_values. A bad load fails the DAG instead of quietly publishing broken marts.

pgAdmin showing the dbt marts in PostgreSQL — users_by_age_band and users_by_city tables with their user counts
The clean end: users_by_age_band and users_by_city, materialised and tested by dbt. Everything in these tables survived all six contract rules.

A Streamlit dashboard sits over the marts, with a demo mode that synthesises the same shapes so the BI layer is reviewable without standing up the whole stack.

Two planes, deliberately separated

Infrastructure and the pipeline have different lifecycles, so they're driven by different tools:

AWS S3 console showing the data-lake bucket with three top-level zones: quality/, raw/ and rejects/
Three zones, date-partitioned by run: raw/ the auditable history, rejects/ the quarantine, quality/ the per-run report. Glue catalogues all three for Athena.

A one-time bootstrap creates the remote state bucket, a DynamoDB lock table, and a GitHub OIDC deployer role. PRs touching infra/terraform/** get a read-only plan; the real apply is a manual button gated by a production environment approval. There are no long-lived AWS keys in GitHub at all — and the pipeline identity and the deployer identity are two separate principals that never share credentials.

What CI actually gates

Four jobs, plus a fifth workflow:

63 tests total. The split isn't tidiness. The test job never installs Airflow at all, so test_dag_integrity.py guards itself with pytest.importorskip and quietly sits out that run. Resolving Airflow 2.11 against its official constraints file is fragile enough that pinning the Spark provider version alongside it makes the resolve impossible — so the DAG checks get their own job with their own dependency set. In the runtime image the two live together happily; it's CI's constraint resolution that objects.

Three things that fought back

Spark on Apple Silicon. The first image installed an x86-only JDK, and spark-submit died on arm64 with a qemu-x86_64: Could not open '/lib64/ld-linux-x86-64.so.2' loader error that takes a while to trace to the base image. The fix: an arch-aware Temurin install that picks aarch64 or x64 at build time. I also defaulted the transform to in-process local[*], with the standalone cluster left opt-in — portability beat distribution for a laptop demo.

Athena and pretty-printed JSON. I wrote the DQ report as pretty-printed, multi-line JSON. It read beautifully and broke Athena instantly with HIVE_CURSOR_ERROR — the JSON SerDe reads one object per line. Single-line NDJSON fixed it.

dbt's dependency footprint. Installing dbt-postgres on its own let the resolver pull dbt-core 2.0 — the new Fusion engine, which doesn't support the Postgres adapter (InvalidConfig dbt1005). dbt now lives in its own virtualenv inside the image, pinned to dbt-core~=1.8.0, and the DAG shells out to that binary. Keeping dbt out of Airflow's environment is the difference between a build that resolves and one that doesn't.

Airflow Graph view of the s3-to-postgres-etl DAG — run_ingestion, spark-clean-task, run_loading, run_dbt and run_dbt_test, all five tasks green
What all of the above buys you: one click, five tasks, all green. Ingest, clean against the contract, load, build the marts, test them.

What I'd change

Rejection precedence hides multi-failure rows. A row with both a bad email and a bad phone is counted once, under whichever rule comes first. That makes the dashboard tidy and slightly dishonest. A better version records every rule a row violated.

The local CSV hop is a teaching choice, not a production one. The pipeline stages through a local file between S3 and Postgres so each ETL stage is separately observable. In production I'd read S3 directly into Spark and write over JDBC.

The takeaway

The single most useful decision here was refusing to let validation logic exist in more than one place. Once the contract became the source of truth, rejection lineage, PII classification, and documentation stopped being three separate chores and became things I got for free.

The pipeline doesn't just move data. It can always tell you what it refused to move, and why.

Full repo — architecture diagrams, the Terraform/OIDC setup, and screenshots from a real run: https://github.com/theofanis-tsakanikas/contract-driven-data-pipeline

If you run pipelines in production: what happens to your rejected rows? Can you point at any one of them and name the rule that dropped it? I'd genuinely like to know how other teams handle this.


One of a series of write-ups on the projects in my portfolio — each one a reference implementation of the trust layer that makes data and AI safe to ship.

The code, the CI and every test behind this article are public.