Post

Schema Evolution Without Breaking Downstream Jobs: A Practical Guide

In this article, let us look at one of those problems that nobody talks about until it hits them in production: schema evolution. You have a pipeline that has been running fine for months. Then someone adds a column to the source table, or renames one, or — worst case — drops a column your downstream job was relying on. Suddenly your dbt models fail, your dashboard shows empty widgets, and someone is pinging you on Slack at 9 PM.

Schema changes are inevitable in any project that lasts more than a few weeks. The question is not whether they will happen, but whether you have a plan for when they do. Let us walk through a few practical strategies I have used to handle this, what works, what does not, and what you should watch out for.

The Core Problem

Most data pipelines are built assuming the schema is fixed. A Spark job reads from a Parquet table, a dbt model selects specific columns from a source, a BigQuery scheduled query expects a certain structure. When that structure changes, things break in ways that are not always obvious.

Here is a simple example. Say you have a pipeline that reads customer orders from a table like this:

1
2
3
4
5
6
SELECT 
    order_id,
    customer_id,
    order_date,
    amount
FROM raw_orders

Then someone adds an order_status column and renames amount to total_amount. Your query still runs — it just returns NULL for amount on new data if the column was renamed at the source. Or worse, if someone dropped amount, your query fails entirely. The downstream job that aggregates daily revenue? It now shows zero for the day. Nobody notices until the weekly report goes out.

The thing to understand is that different tools handle these situations differently. Spark with Parquet is more forgiving because Parquet stores schema per file. A CSV or JSON source? Much less so. A database table? If someone runs an ALTER TABLE, your view might just break without warning.

Strategy 1: Add-Only Compatibility

The simplest rule you can enforce with your upstream teams: only add columns, never remove or rename them.

This is not as naive as it sounds. In a lot of organisations, source systems like application databases have their own lifecycle. You cannot control what the backend team does. But for intermediate tables that your team owns — like the ones in your data lake — you can absolutely make this a policy.

The idea is: if a column is deprecated, you stop populating it and add a comment, but you do not drop it for at least N months. This gives downstream consumers time to migrate.

Here is what that looks like in practice with a Delta table:

1
2
3
ALTER TABLE silver.orders ADD COLUMNS (order_status STRING);
-- Add a comment to the old column so people know
COMMENT ON COLUMN silver.orders.amount IS 'DEPRECATED: use total_amount instead. Will be removed after 2026-09.';

For the renaming case, you keep both columns populated for a transition period:

1
2
3
4
5
6
7
8
INSERT INTO silver.orders
SELECT 
    order_id,
    customer_id,
    order_date,
    amount,  -- old name, keep populating
    amount AS total_amount  -- new name
FROM raw_orders

This is not elegant, but it works. Downstream jobs have time to migrate at their own pace. The cost is some duplicated data for a few months, which is usually cheaper than a pipeline outage.

Strategy 2: Views as an Abstraction Layer

If you control the ingestion, one of the most effective patterns is to write your downstream jobs against views, not raw tables. When the underlying schema changes, you update the view definition instead of every downstream query.

Here is a concrete example. Assume you have a raw table raw_customer_events that gets ingested from Kafka or a CDC stream. The backend team changes the structure. Instead of updating 15 dbt models, you create a view:

1
2
3
4
5
6
7
8
9
10
CREATE OR REPLACE VIEW staging.customer_events AS
SELECT 
    event_id,
    customer_id,
    event_type,
    COALESCE(event_timestamp, created_at) AS event_time,  -- handle renamed column
    COALESCE(metadata, '{}') AS metadata,                   -- handle new nullable column
    -- old column 'user_agent' was dropped; provide a default
    'unknown' AS user_agent
FROM raw_customer_events

Now your downstream models select from staging.customer_events and they do not need to know that the underlying table changed. You make the schema change once, in one place, and everything keeps working.

This is essentially the same idea as having a silver layer in medallion architecture — apply schema enforcement and normalisation early so the rest of the pipeline can trust the structure.

Strategy 3: Schema Registries and Contracts

If you are working with streaming data or an event-driven architecture, a schema registry becomes almost mandatory. Tools like Confluent Schema Registry (for Kafka) or AWS Glue Schema Registry enforce compatibility rules before a producer can publish with a new schema.

The main compatibility modes you will deal with are:

ModeWhat It AllowsBest For
BACKWARDNew schema can read old dataPipelines where consumers update first
FORWARDOld schema can read new dataPipelines where producers update first
FULLBoth directions must be compatibleShared topics with multiple teams
NONENo checks at allDevelopment and prototyping

In a typical setup, you configure your Kafka topic to use BACKWARD compatibility. This means a consumer that was built against schema v1 can still deserialize messages produced with schema v2. The catch: you can only add fields with defaults, and you cannot remove required fields.

Here is an Avro schema evolution example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Version 1
{
  "type": "record",
  "name": "OrderEvent",
  "fields": [
    {"name": "order_id", "type": "string"},
    {"name": "amount", "type": "double"},
    {"name": "currency", "type": "string", "default": "USD"}
  ]
}

// Version 2  add a field with a default (BACKWARD compatible)
{
  "type": "record",
  "name": "OrderEvent",
  "fields": [
    {"name": "order_id", "type": "string"},
    {"name": "amount", "type": "double"},
    {"name": "currency", "type": "string", "default": "USD"},
    {"name": "discount_applied", "type": "boolean", "default": false}
  ]
}

The default is what makes this work. Old consumers that do not know about discount_applied will see the default value when they read v2 messages.

In practice, I have found that schema registries work well for Kafka topics and event streams, but they are less useful for batch pipelines that read from data lakes or databases. For those, views and add-only policies tend to be more practical.

Strategy 4: Contract Testing in CI/CD

Even with all the above strategies, things slip through. A good safety net is to add schema contract tests to your CI/CD pipeline. The idea is simple: before deploying a change to your dbt models or Spark jobs, run a quick test that validates the schema of every source table your pipeline depends on.

Here is a lightweight approach using dbt’s built-in source freshness and schema tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
# In your dbt schema.yml
version: 2
sources:
  - name: raw
    tables:
      - name: orders
        columns:
          - name: order_id
            tests:
              - not_null
          - name: total_amount
            tests:
              - not_null

But this only tests your output. For checking whether expected columns exist in your source, you can add a custom generic test:

1
2
3
4
5
6
7
8
-- A custom test to check if expected columns exist in a source
{% test expect_columns(model, column_list) %}
SELECT column_name
FROM information_schema.columns
WHERE table_name = '{{ model }}'
  AND column_name IN ({{ column_list }})
HAVING COUNT(*) != {{ column_list.split(',').length }}
{% endtest %}

I have also seen teams write a small Python script that runs as a GitHub Action, connects to BigQuery or Snowflake, and checks that all expected columns are present before merging a PR. It is cheap insurance.

Things to Watch Out For

There are a few limitations and caveats worth mentioning here.

First, not all schema changes are equal. Adding a nullable column is almost always safe. Changing a column type from INT to BIGINT is usually fine in databases but can break Parquet readers if they use strict type casting. Dropping a column that nobody uses should be safe, but you would be surprised how often “nobody uses” is wrong.

Second, data lineage matters more than you think. If you do not know which dashboards, models, and ML features depend on a column, you cannot safely deprecate it. Tools like dbt docs lineage graph, OpenLineage, or even a simple spreadsheet help here. I have seen teams send a Slack message to a channel saying “we are dropping column X in 30 days, here are all the jobs that reference it” — and that is honestly effective if you are a small team.

Third, if you are using tools like dbt, the ref() function gives you built-in lineage that you can use to your advantage. Before dropping a column, run:

1
dbt ls --select source:raw.orders+

This shows every model downstream of that source. If the list is longer than you expected, do not drop the column yet.

Fourth, schema evolution with nested data (JSON columns, structs in BigQuery, ARRAY types) is much harder to handle gracefully. Most compatibility tools do not go deep into nested structures. If your data model relies heavily on nested fields, invest in a robust transformation layer early.

What Changes in a Production Environment

These strategies work, but in a real production environment there are a few extra things to consider.

For one, you need monitoring. When a schema change happens in a source system, you want to know about it before the pipeline fails. A simple approach is to run a daily query that compares the current schema of your source tables against a stored baseline and alerts if something changed.

Second, rollback planning. Even with compatibility rules, sometimes a schema change still breaks things. Know how to roll back your ingestion or transformation to a previous working version. If you are using dbt, this is straightforward — just deploy the previous commit. For streaming pipelines with a schema registry, you might need to roll back the producer to an older schema version.

Third, do not rely on schema-on-read to save you. Yes, tools like Athena and Spark can infer schemas at query time. But if your downstream models have hard-coded column references, schema-on-read does not help. It only defers the problem to when your query actually runs.

Conclusion

Schema evolution is one of those things that separates a mature data platform from a fragile one. The strategies are not complicated — add-only policies, views as abstractions, schema registries for streaming, and contract tests in CI/CD — but they require discipline. The hardest part is usually not the technical implementation, but getting upstream teams to understand that a “small schema change” to them can mean a full pipeline outage for the data team.

Start with views and add-only policies. They cost almost nothing and solve 80% of the problem. If you are working with streaming, invest in a schema registry early — retrofitting compatibility rules onto a live topic is much harder than setting it up from the start. And whatever you do, add some kind of schema validation in CI/CD. It catches problems before they become production incidents, which is exactly where you want them caught.

This post is licensed under CC BY 4.0 by the author.