top of page

Schema Drift in Data Pipelines: 4 Failure Modes and How to Catch Them

Writer: Vexdata
Vexdata
11 minutes ago
11 min read

Your Airflow DAG finished at 06:14. Exit code: 0. No alerts fired. By 9 AM your revenue dashboard showed $0 for the entire prior day.


The root cause? A backend engineer renamed uid to user_id in the transactions table the night before. Every downstream join returned NULL. Every revenue metric hit zero. And because the pipeline technically completed without errors, no one was paged.


This is schema drift — and it is one of the most common, most damaging, and most preventable causes of silent data quality failures in modern data stacks.


Schema drift occurs when a source system changes its structure — column names, data types, nullability constraints, or column presence — without coordinating with downstream consumers. Unlike a pipeline crash, schema drift rarely throws an error. It just corrupts your data quietly, often for hours or days before anyone notices.


In this guide, we break down:

  • The four failure modes that cause the most downstream damage

  • How schema fingerprinting detects drift before your pipeline runs

  • How to write dbt column contracts that block bad deploys

  • How to build an Airflow pre-run gate that stops pipelines before they write corrupt data

  • A governance framework for classifying changes as BLOCK vs. WARN


All patterns include working SQL and Python you can drop into your stack today.


What Is Schema Drift — and Why Pipelines Don't Catch It

Schema drift is any change to a table's structure that wasn't coordinated with downstream systems. It happens constantly in real engineering environments: a developer adds a NOT NULL constraint, a DBA renames a column for clarity, a SaaS vendor changes a field type between API versions. None of these changes cause an immediate application error — but all of them can corrupt a data pipeline silently.


The reason pipelines don't catch it is architectural. Most ETL and ELT tools copy rows, not schemas. They check whether data moved, not whether data means the same thing it did yesterday. Unless you've explicitly built a schema contract layer, your pipeline will happily load NULL-filled rows or miscast integers with a green checkmark.


Additive vs. Breaking Changes

Change Type

Category

Pipeline Impact

New column added

Additive (safe)

Downstream ignores unknown columns

Column renamed

Breaking

All joins/references return NULL

Data type changed

Breaking

Silent casts fail; NULLs or wrong values

NOT NULL constraint added

Breaking

INSERTs fail; pipeline errors at load

Column removed

Breaking

Any SELECT on that column fails or returns NULL


Additive changes are almost always safe. Breaking changes are dangerous precisely because they often don't raise errors — they just change what the data means.


Schema Fingerprinting: The First Line of Defense


The fastest way to detect any structural change is to hash the schema and compare it to a known-good baseline. If the hash changes, something changed — and you can block the pipeline before it runs.

-- Generate a schema fingerprint for any table
SELECT
  MD5(
    STRING_AGG(
      column_name || ':' || data_type,
      ',' ORDER BY column_name
    )
  ) AS schema_hash
FROM information_schema.columns
WHERE table_name = :table_name;

Run this before every pipeline execution, compare against your stored contract hash, and you'll catch any column-level change — rename, type change, addition, or removal — in a single query.

Store your baseline hashes in a schema_contracts table:

CREATE TABLE schema_contracts (
  schema_name     VARCHAR(100),
  table_name      VARCHAR(100),
  column_name     VARCHAR(100),
  data_type       VARCHAR(100),
  is_nullable     BOOLEAN,
  registered_at   TIMESTAMP DEFAULT NOW(),
  PRIMARY KEY (schema_name, table_name, column_name)
);

This gives you a persistent record of what the schema should look like, which every detection pattern below builds on.


Failure Mode 1: Column Rename

What Happens

A backend engineer renames uid to user_id for clarity. In the application, it's a one-line migration. In the data pipeline, it's a catastrophe.


Every SQL query that references uid now returns NULL — silently. There's no ColumnNotFoundError in most data warehouses; the engine just fills with NULL and moves on. Every join on that column fails. Every aggregation returns zero. Every dashboard built on those metrics shows wrong numbers.


Real-world scenario: A fintech pipeline joins transactions to user profiles on uid. After the rename, 100% of joins return NULL. The revenue dashboard shows $0 for the day. The pipeline completed with exit code 0.


Why It's Hard to Detect

A rename looks like a column removal + column addition. No rename event exists in information_schema. The only way to detect it programmatically is to diff the current schema against your contract and flag the combination of a missing column and a new column with a similar pattern.

-- Detect column renames by finding removed + added columns
SELECT
  'REMOVED' AS change_type,
  column_name
FROM schema_contracts
WHERE
  schema_name = :schema
  AND table_name = :table
  AND column_name NOT IN (
    SELECT column_name
    FROM information_schema.columns
    WHERE table_schema = :schema AND table_name = :table
  )

UNION ALL

SELECT
  'ADDED' AS change_type,
  column_name
FROM information_schema.columns
WHERE
  table_schema = :schema
  AND table_name = :table
  AND column_name NOT IN (
    SELECT column_name
    FROM schema_contracts
    WHERE schema_name = :schema AND table_name = :table
  );

If this query returns one REMOVED and one ADDED row in the same run, treat it as a likely rename and block until confirmed.


The Safe Migration Pattern

When a rename is intentional, use a dual-write strategy to give downstream consumers time to migrate:

-- Step 1: Add the new column alongside the old one
ALTER TABLE transactions ADD COLUMN user_id BIGINT;

-- Step 2: Populate new column from old
UPDATE transactions SET user_id = uid;

-- Step 3: Keep both columns in sync via trigger (or app logic) during migration window
-- Step 4: After all downstream consumers are updated, drop the old column
ALTER TABLE transactions DROP COLUMN uid;

This buys your data team a migration window of days or weeks rather than forcing an immediate cutover at 2 AM.


Failure Mode 2: Data Type Change


What Happens

A developer changes order_id from INTEGER to VARCHAR(36) to support UUID-format order IDs from a new system. In the application layer, the ORM handles the cast automatically. In the data warehouse, every downstream join on order_id silently breaks.

The most insidious case: Snowflake and BigQuery will often not raise an error when you cast VARCHAR to INTEGER — they'll just return NULL for any row where the cast fails. Your pipeline completes. Your metrics drop. Your alerts stay silent.


Real-world scenario: A SaaS vendor updates their API between versions, changing a numeric customer_id to a UUID string. The Fivetran sync continues successfully. Every aggregation on customer-level revenue now returns NULL because INTEGER = VARCHAR never matches in a join.


Detecting Type Changes

Compare data_type between your contract and information_schema:

SELECT
  c.column_name,
  c.data_type          AS current_type,
  sc.data_type         AS expected_type,
  'TYPE_CHANGED'       AS change_category
FROM information_schema.columns c
JOIN schema_contracts sc
  ON  sc.schema_name = c.table_schema
  AND sc.table_name  = c.table_name
  AND sc.column_name = c.column_name
WHERE
  c.table_schema = :schema
  AND c.table_name = :table
  AND c.data_type <> sc.data_type;

Why This Is a BLOCK-Level Change


Type changes are not safe to run through automatically because:

  1. The old data that's already in your warehouse was stored with the old type. New data lands with the new type. You now have a mixed-type column with no clean way to reconcile.

  2. Aggregation functions (SUM, AVG) will fail or return NULL on VARCHAR columns.

  3. JOIN predicates comparing different types can silently return no matches.


The only safe response to a type change is to stop the pipeline, alert the data team, and agree on a migration plan — usually involving a new column, a backfill, and a swap.


Failure Mode 3: NOT NULL Constraint Added

What Happens

A DBA adds a NOT NULL constraint to the region_code column to enforce data quality at the source. Any INSERT that doesn't supply region_code will now fail with a constraint violation. This means your pipeline — which was happily writing NULL for optional fields — now errors out at load time.


Unlike Failure Modes 1 and 2, this one does produce an error. The danger isn't silent corruption; it's total pipeline failure at an unexpected time — usually 2 AM during a scheduled batch run.


Real-world scenario: A morning report is missing for the third largest market. The on-call engineer pages at 6 AM to find the nightly batch failed at 02:17 with ERROR: null value in column "region_code" violates not-null constraint. The constraint was added in a routine migration the day before. No one told the data team.


Detection Query

SELECT
  c.column_name,
  c.is_nullable        AS current_nullable,
  sc.is_nullable       AS expected_nullable,
  'NULLABILITY_CHANGED' AS change_category
FROM information_schema.columns c
JOIN schema_contracts sc
  ON  sc.schema_name = c.table_schema
  AND sc.table_name  = c.table_name
  AND sc.column_name = c.column_name
WHERE
  c.table_schema = :schema
  AND c.table_name = :table
  AND c.is_nullable  <> sc.is_nullable
  AND c.is_nullable  = 'NO';   -- NOT NULL was added

Response Strategy


A newly-added NOT NULL constraint requires an immediate review of all pipeline write paths:

  • Identify every INSERT/UPSERT that targets this table

  • Verify that each write path supplies a non-NULL value for the newly constrained column

  • If a default value is appropriate, add it at the pipeline level before the constraint causes a production failure

  • If the column is legitimately optional in some flows, push back on the constraint or agree on a default


Failure Mode 4: Column Removed


What Happens


A deprecated column is finally dropped from a source table. The backend team has been ignoring it for months. Nobody told the data team it was still being used in seven downstream dbt models, two Looker dashboards, and a Metabase report the VP of Sales checks every morning.


A column removal is the highest-severity schema drift event. It can break an arbitrary number of downstream assets simultaneously. And unlike a rename or type change which might produce NULL values, a column removal often causes hard errors: column "discount_amount" does not exist.


Real-world scenario: An e-commerce team drops promo_code from the orders table after a promotional system migration. Six hours later, the nightly dbt run fails across 4 models. The marketing attribution dashboard shows no data. The campaign performance report that goes to the executive team at 8 AM is empty.


Detection Query

SELECT
  sc.column_name,
  'COLUMN_REMOVED' AS change_category,
  CASE
    WHEN sc.column_name IN (
      SELECT column_name
      FROM pipeline_dependencies
      WHERE schema_name = :schema AND table_name = :table
    ) THEN 'CRITICAL'
    ELSE 'REVIEW'
  END AS severity
FROM schema_contracts sc
WHERE
  sc.schema_name = :schema
  AND sc.table_name = :table
  AND sc.column_name NOT IN (
    SELECT column_name
    FROM information_schema.columns
    WHERE table_schema = :schema AND table_name = :table
  );

Why Lineage Tracking Matters Here

The only way to know the blast radius of a column removal is column-level lineage: a map of every model, query, and dashboard that references a given column. Without it, you're discovering breakage model by model as each nightly run fails.


Tools like dbt's ref() and source() macros, combined with manifest.json parsing, can give you this lineage. Vexdata's automated validation layer layers on top of that, giving you pre-run impact analysis before a change ever reaches production.


Automated Detection: The SchemaDriftDetector

Manual schema checks don't scale. When you have dozens of source tables feeding hundreds of models, you need a detection layer that runs automatically before every pipeline execution and classifies changes by severity without human intervention.


Here's a production-ready SchemaDriftDetector class you can integrate into any Python-based orchestration stack:

from dataclasses import dataclass, field
from typing import Optional
from enum import Enum
import sqlalchemy as sa

class DriftSeverity(Enum):
    BLOCK  = "BLOCK"   # Stop the pipeline immediately
    WARN   = "WARN"    # Alert but allow run to proceed
    INFO   = "INFO"    # Log only; safe additive change

@dataclass
class DriftEvent:
    change_type: str
    column_name: str
    severity: DriftSeverity
    details: dict = field(default_factory=dict)

@dataclass
class SchemaDriftReport:
    schema: str
    table: str
    events: list[DriftEvent] = field(default_factory=list)

    @property
    def blocking(self) -> bool:
        return any(e.severity == DriftSeverity.BLOCK for e in self.events)

    @property
    def has_warnings(self) -> bool:
        return any(e.severity == DriftSeverity.WARN for e in self.events)


class SchemaDriftDetector:
    """
    Compares live table schema against a registered contract.
    Classifies each change by severity and returns a report
    with a blocking flag the pipeline can act on.
    """

    BLOCK_CHANGES  = {"COLUMN_REMOVED", "TYPE_CHANGED", "NULLABILITY_TIGHTENED"}
    WARN_CHANGES   = {"COLUMN_ADDED_NOT_NULL", "DEFAULT_CHANGED"}
    INFO_CHANGES   = {"COLUMN_ADDED_NULLABLE"}

    def __init__(self, engine: sa.Engine):
        self.engine = engine

    def _fetch_live_schema(self, schema: str, table: str) -> dict:
        query = sa.text("""
            SELECT column_name, data_type, is_nullable
            FROM information_schema.columns
            WHERE table_schema = :schema AND table_name = :table
        """)
        with self.engine.connect() as conn:
            rows = conn.execute(query, {"schema": schema, "table": table}).fetchall()
        return {r.column_name: {"type": r.data_type, "nullable": r.is_nullable} for r in rows}

    def _fetch_contract(self, schema: str, table: str) -> dict:
        query = sa.text("""
            SELECT column_name, data_type, is_nullable
            FROM schema_contracts
            WHERE schema_name = :schema AND table_name = :table
        """)
        with self.engine.connect() as conn:
            rows = conn.execute(query, {"schema": schema, "table": table}).fetchall()
        return {r.column_name: {"type": r.data_type, "nullable": r.is_nullable} for r in rows}

    def detect_drift(self, schema: str, table: str) -> SchemaDriftReport:
        live     = self._fetch_live_schema(schema, table)
        contract = self._fetch_contract(schema, table)
        report   = SchemaDriftReport(schema=schema, table=table)

        # Removed columns
        for col in contract:
            if col not in live:
                report.events.append(DriftEvent(
                    change_type="COLUMN_REMOVED",
                    column_name=col,
                    severity=DriftSeverity.BLOCK,
                    details={"expected": contract[col]}
                ))

        # Added columns
        for col in live:
            if col not in contract:
                severity = (
                    DriftSeverity.WARN if live[col]["nullable"] == "NO"
                    else DriftSeverity.INFO
                )
                report.events.append(DriftEvent(
                    change_type="COLUMN_ADDED",
                    column_name=col,
                    severity=severity,
                    details={"new": live[col]}
                ))

        # Type or nullability changes on existing columns
        for col in contract:
            if col not in live:
                continue
            if live[col]["type"] != contract[col]["type"]:
                report.events.append(DriftEvent(
                    change_type="TYPE_CHANGED",
                    column_name=col,
                    severity=DriftSeverity.BLOCK,
                    details={"from": contract[col]["type"], "to": live[col]["type"]}
                ))
            if live[col]["nullable"] == "NO" and contract[col]["nullable"] == "YES":
                report.events.append(DriftEvent(
                    change_type="NULLABILITY_TIGHTENED",
                    column_name=col,
                    severity=DriftSeverity.BLOCK,
                    details={"from": "nullable", "to": "not null"}
                ))

        return report

    def run_pre_pipeline_check(
        self,
        tables: list[tuple[str, str]]
    ) -> tuple[bool, list[SchemaDriftReport]]:
        """
        Run drift detection across multiple tables.
        Returns (safe_to_proceed, reports).
        """
        reports = [self.detect_drift(schema, table) for schema, table in tables]
        safe    = not any(r.blocking for r in reports)
        return safe, reports

Wiring It Into Airflow as a Pre-Pipeline Gate

The detector is most powerful as a gate that runs before any data movement. Add it as the first task in any DAG that reads from schema-sensitive sources:

from airflow.decorators import dag, task
from airflow.exceptions import AirflowFailException
from datetime import datetime

@dag(schedule_interval="0 2 * * *", start_date=datetime(2024, 1, 1), catchup=False)
def orders_pipeline():

    @task()
    def schema_gate():
        from schema_drift_detector import SchemaDriftDetector
        import sqlalchemy as sa

        engine   = sa.create_engine("postgresql+psycopg2://...")
        detector = SchemaDriftDetector(engine)

        MONITORED_TABLES = [
            ("public", "orders"),
            ("public", "order_items"),
            ("public", "customers"),
        ]

        safe, reports = detector.run_pre_pipeline_check(MONITORED_TABLES)

        for report in reports:
            for event in report.events:
                if event.severity.value == "BLOCK":
                    print(f"[BLOCK] {report.schema}.{report.table}: "
                          f"{event.change_type} on column '{event.column_name}' | {event.details}")
                elif event.severity.value == "WARN":
                    print(f"[WARN]  {report.schema}.{report.table}: "
                          f"{event.change_type} on column '{event.column_name}'")

        if not safe:
            raise AirflowFailException(
                "Schema drift detected. Pipeline blocked. Review BLOCK events above."
            )

    @task()
    def extract_orders():
        # Your actual ETL logic here
        pass

    schema_gate() >> extract_orders()

orders_pipeline()

This pattern ensures that no data movement happens until the schema is verified. BLOCK events raise an AirflowFailException, which pages on-call and stops the DAG. WARN events log to the task output for review but allow the run to proceed.



Schema Change Governance: BLOCK vs. WARN

Not all schema changes are equal. A governance framework gives your team a shared language for how to respond to each type — and removes the ambiguity that causes either over-reaction (blocking safe changes) or under-reaction (missing critical ones).


Change Classification Table

Change Type

Category

Auto-Response

Human Action Required

Column added (nullable)

Additive

WARN

Review if column is used downstream

Column added (NOT NULL, no default)

Breaking

BLOCK

Data team must update write paths

Column renamed

Breaking

BLOCK

Coordinate migration window; dual-write

Data type narrowed (e.g. TEXT → VARCHAR(50))

Breaking

BLOCK

Review truncation risk on existing data

Data type widened (e.g. INT → BIGINT)

Usually safe

WARN

Verify no downstream type-specific logic

NOT NULL added to existing column

Breaking

BLOCK

Update all write paths to supply value

Column removed

Breaking

BLOCK

Blast-radius analysis; update all consumers

Default value changed

Additive

WARN

Review if default was load-bearing

Index added/removed

Non-breaking

INFO

No pipeline action needed


dbt Column Contracts as Your Second Defense Layer


For dbt-based stacks, sources.yml column contracts give you a declarative schema test that runs on every dbt test or CI run:

# sources.yml
version: 2

sources:
  - name: public
    tables:
      - name: orders
        columns:
          - name: order_id
            data_tests:
              - not_null
              - unique
            meta:
              data_type: integer
          - name: customer_id
            data_tests:
              - not_null
              - relationships:
                  to: source('public', 'customers')
                  field: id
            meta:
              data_type: integer
          - name: order_date
            data_tests:
              - not_null
            meta:
              data_type: date
          - name: total_amount
            meta:
              data_type: numeric

Pair this with a custom dbt-contracts package or the community dbt-expectations package to enforce type-level constraints:

- name: total_amount
  data_tests:
    - dbt_expectations.expect_column_values_to_be_of_type:
        column_type: numeric

Now a type change on total_amount fails your CI pipeline before it ever reaches production.


The Three-Layer Defense Model

Layer

Tool

When It Runs

What It Catches

Layer 1

Schema fingerprinting (SQL)

Pre-pipeline, every run

Any structural change to monitored tables

Layer 2

SchemaDriftDetector (Python)

Pre-pipeline gate in Airflow/Prefect

Change classification: BLOCK / WARN / INFO

Layer 3

dbt column contracts (YAML)

CI/CD and scheduled dbt test runs

Type violations, null violations, referential integrity


Each layer catches different things at different times. Together, they give you defense-in-depth against schema drift — the pipeline never runs with stale assumptions, the CI never passes with broken contracts, and the data team always knows what changed before it breaks a dashboard.


What to Do Today

If you don't have a schema detection layer, start with the fingerprinting SQL. One query, one cron job, one Slack alert. You'll catch the next rename or type change before it costs you a morning report.


]If you're already on dbt, add column declarations to your highest-impact sources this week. Even three or four critical columns under contract is better than none.

If you want automated validation across your entire data stack — schema contracts, row-level quality gates, lineage-aware impact analysis, and pre-deployment testing — that's exactly what


Vexdata is built for.

 
 
 

Comments


bottom of page