Building Reliable Data Pipelines for ML

Machine learning models are only as good as the data they train on — and only as reliable as the pipelines that deliver that data. Yet data pipelines are often the least engineered part of an ML system: written as one-off scripts, lacking tests, without retry logic, and impossible to debug when they fail silently at 3am.

This article applies software engineering principles to data pipelines: idempotency, testability, schema contracts, observability, and incremental processing. The goal is a pipeline you can trust at scale — one that fails loudly, recovers automatically, and is as easy to reason about as any well-structured service.

The Core Properties of a Reliable Pipeline

A pipeline is reliable when it satisfies four properties:

The most common failure mode: A pipeline that is not idempotent. A failed run leaves partial state. The rerun appends to the partial state instead of replacing it. Two weeks later, your training dataset has double-counted records and you do not know it.

The Medallion Architecture

The most widely adopted pattern for organizing a data lakehouse is the Medallion Architecture: three layers with progressively higher data quality.

Bronze
raw ingestion
→
Silver
cleaned & validated
→
Gold
feature-ready
→
ML Training
model inputs
Each layer is append-only, independently queryable, and reprocessable from the layer above

Idempotency by Design

Every pipeline step should be designed to be safely rerun. The two most reliable techniques:

Overwrite, Don't Append

For batch pipelines, write results to a partition and overwrite that partition on rerun. With Delta Lake or Apache Iceberg, partition overwrite is atomic — the reader sees either the old data or the new data, never a partial state mid-write.

# PySpark — idempotent partition overwrite with Delta Lake from pyspark.sql import SparkSession spark = SparkSession.builder.getOrCreate() spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic") df_silver = ( spark.read.format("delta").load("s3://lake/bronze/transactions") .filter("event_date = '2025-02-15'") .dropDuplicates(["transaction_id"]) .withColumn("amount_usd", col("amount_cents") / 100) ) # Overwrite only the 2025-02-15 partition — safe to rerun (df_silver .write .format("delta") .mode("overwrite") .partitionBy("event_date") .save("s3://lake/silver/transactions") )

Upsert with Merge

For slowly-changing data (user profiles, product catalog), use a MERGE statement. If the record already exists, update it. If it does not, insert it. Running twice produces the same final state.

-- Delta Lake MERGE — idempotent upsert MERGE INTO silver.users AS target USING bronze.users_staging AS source ON target.user_id = source.user_id WHEN MATCHED AND source.updated_at > target.updated_at THEN UPDATE SET * WHEN NOT MATCHED THEN INSERT *;

Schema Contracts with dbt

dbt (data build tool) brings software engineering practices to SQL transformations: version control, testing, documentation, and lineage tracking. Every transformation is a model; every model has tests.

# models/silver/transactions.yml — schema contract version: 2 models: - name: silver_transactions description: Cleaned and validated transaction records columns: - name: transaction_id description: Unique transaction identifier tests: - unique - not_null - name: amount_usd tests: - not_null - dbt_utils.expression_is_true: expression: "amount_usd > 0" - name: customer_id tests: - not_null - relationships: to: ref('silver_customers') field: customer_id

When dbt test fails, the pipeline halts. No silent corruption propagates to the gold layer or into training data. The failure is logged, alerted, and the run is marked as requiring manual review.

Orchestration with Apache Airflow

Airflow orchestrates the sequence of pipeline tasks, handles retries, and provides a UI for monitoring run history. Key practices for production Airflow DAGs:

from airflow.decorators import dag, task from airflow.utils.dates import days_ago from datetime import timedelta @dag( schedule_interval="@daily", start_date=days_ago(1), catchup=False, # Don't backfill missed runs on startup max_active_runs=1, # Prevent overlapping runs for the same DAG default_args={ "retries": 3, "retry_delay": timedelta(minutes=5), "retry_exponential_backoff": True, "execution_timeout": timedelta(hours=2), "on_failure_callback": alert_slack, } ) def ml_feature_pipeline(): @task def ingest_bronze(ds: str): """Idempotent ingestion for logical_date=ds""" run_ingestion(date=ds) @task def transform_silver(ds: str): run_dbt_model("silver_transactions", date=ds) @task def build_gold_features(ds: str): run_dbt_model("gold_churn_features", date=ds) @task def validate_features(ds: str): run_great_expectations_suite("churn_features", date=ds) bronze = ingest_bronze() silver = transform_silver() gold = build_gold_features() check = validate_features() bronze >> silver >> gold >> check dag = ml_feature_pipeline()

Key pattern: Pass the ds (logical date) explicitly to every task. This makes every task a pure function of its inputs and enables safe backfilling: you can rerun any historical date window independently without side effects on other dates.

Handling Late-Arriving Data

In real systems, data arrives late. A transaction that happened on Monday may only reach the data lake on Wednesday due to batch ETL delays, mobile app sync, or source system lag. Two strategies:

Watermark-based Processing (Streaming)

In streaming pipelines (Spark Structured Streaming, Flink), a watermark defines how late an event can arrive and still be included in a window computation. Events arriving after the watermark are either included in a late-data bucket or discarded — but never silently corrupt a closed window.

Reprocessing Windows (Batch)

For batch pipelines, always reprocess the last N days of data to catch late arrivals. On each daily run, overwrite not just today's partition but also the previous 3 days. This is safe because the write is idempotent — and it ensures late-arriving records are reflected in features used for training.

# Reprocess last 3 days to absorb late-arriving records from datetime import date, timedelta def get_reprocessing_dates(logical_date: date, lookback_days: int = 3): return [logical_date - timedelta(days=i) for i in range(lookback_days + 1)] # Run transformation for each date window for process_date in get_reprocessing_dates(today): run_silver_transform(date=process_date)

Pipeline Observability

Every production pipeline needs metrics surfaced to a dashboard. The minimum viable set:

Key Takeaways

Back to all articles