Skip to main content
Data Engineering advanced Lesson 10 of 10

Schema Evolution and Data Contracts

Which schema changes are safe, why backward and forward compatibility are different questions, and enforcing a contract in CI so a breaking change fails the producer's build.

Every dataset has a schema, whether it is declared or not. The question is only whether a change to it is caught in a pull request or discovered by a dashboard at 9am.

Which changes are safe

ChangeBackward compatibleForward compatible
add optional field with default
add required field, no default
remove optional field
remove required field
rename a field
widen a type (intlong)
narrow a type (longint)
change semantics, same type✅✅✅✅
  • Backward compatible — new consumer reads old data. Needed when consumers upgrade first.
  • Forward compatible — old consumer reads new data. Needed when producers upgrade first, which is the normal case for a shared dataset with many readers.
  • Full — both. What a widely consumed dataset should require.

The last row is the dangerous one. Redefining amount from gross to net passes every structural check ever written and silently changes every number downstream.

Evolution in practice

import duckdb
con = duckdb.connect()

con.execute("""
    copy (select * from (values
        (1001, 1, date '2026-01-04', 'completed', 25.50),
        (1002, 2, date '2026-01-04', 'completed', 12.00)
    ) t(order_id, customer_id, ordered_at, status, amount))
    to 'orders_v1.parquet' (format parquet)
""")

con.execute("""
    copy (select * from (values
        (1003, 1, date '2026-01-05', 'completed', 40.00, 'web'),
        (1004, 3, date '2026-01-05', 'refunded',   8.75, 'app')
    ) t(order_id, customer_id, ordered_at, status, amount, channel))
    to 'orders_v2.parquet' (format parquet)
""")

print(con.execute("""
    select * from read_parquet(['orders_v1.parquet','orders_v2.parquet'], union_by_name = true)
    order by order_id
""").df().to_string(index=False))
 order_id  customer_id ordered_at    status  amount channel
     1001            1 2026-01-04 completed   25.50    None
     1002            2 2026-01-04 completed   12.00    None
     1003            1 2026-01-05 completed   40.00     web
     1004            3 2026-01-05  refunded    8.75     app

An added nullable column reads cleanly across both files — old rows get NULL. That is the one change you can make freely, and the reason “add a column” is the safe answer to almost every schema request.

Without union_by_name the same read fails:

con.execute("select * from read_parquet(['orders_v1.parquet','orders_v2.parquet'])").df()
duckdb.duckdb.InvalidInputException: Invalid Input Error: Schema mismatch between files.
Column count mismatch: expected 5 columns but found 6.
Use union_by_name = true to read files with different schemas.

The rename

con.execute("""
    copy (select * from (values
        (1005, 2, date '2026-01-06', 'completed', 63.20, 'web')
    ) t(order_id, customer_id, ordered_at, status, amount_gbp, channel))
    to 'orders_v3.parquet' (format parquet)
""")

print(con.execute("""
    select order_id, amount, amount_gbp
    from read_parquet(['orders_v2.parquet','orders_v3.parquet'], union_by_name = true)
    order by order_id
""").df().to_string(index=False))
 order_id  amount  amount_gbp
     1003   40.00        None
     1004    8.75        None
     1005    None       63.20

Two half-empty columns, and every existing query — sum(amount) — silently starts excluding new rows. No error anywhere.

The safe version is an expand-and-contract migration:

# phase 1: write both, keep the old one authoritative
con.execute("""
    copy (select order_id, amount, amount as amount_gbp from read_parquet('orders_v2.parquet'))
    to 'orders_dual.parquet' (format parquet)
""")
print(con.execute("select * from 'orders_dual.parquet'").df().to_string(index=False))
 order_id  amount  amount_gbp
     1003   40.00       40.00
     1004    8.75        8.75

Then migrate consumers, verify nobody reads the old name, and only then drop it. Three deploys instead of one — which is the actual cost of a rename on a shared dataset, and the reason to get names right the first time.

A contract

# contracts/orders.yml
dataset: bookshop.silver.orders
version: 2.1.0
owner: [email protected]
description: One row per non-pending order. Amounts are gross, in GBP, excluding shipping.

schema:
  - name: order_id
    type: bigint
    required: true
    unique: true
  - name: customer_id
    type: bigint
    required: true
    description: References bookshop.silver.customers.customer_id
  - name: ordered_at
    type: date
    required: true
  - name: status
    type: string
    required: true
    accepted_values: [completed, returned, refunded]
  - name: amount
    type: "decimal(10,2)"
    required: true
    description: Gross order total in GBP, excluding shipping. Refunds are negative.
  - name: channel
    type: string
    required: false        # added in 2.1.0
    accepted_values: [web, app, phone]

guarantees:
  freshness: 6h
  compatibility: full
  volume_change_threshold: 0.5

The parts that matter are not the column list. compatibility: full says what changes are permitted, freshness and volume_change_threshold say what the consumer can rely on operationally, and the amount description pins down the semantics that no type can express.

Enforcing it in CI

# check_contract.py
import duckdb, sys, yaml

TYPE_MAP = {"bigint": "BIGINT", "date": "DATE", "string": "VARCHAR", "decimal(10,2)": "DECIMAL(10,2)"}

def check(contract_path, table):
    contract = yaml.safe_load(open(contract_path))
    con = duckdb.connect("bookshop.duckdb")
    actual = {r[0]: r[1] for r in con.execute(f"describe {table}").fetchall()}
    expected = {f["name"]: f for f in contract["schema"]}

    breaking, additive = [], []

    for name, field in expected.items():
        if name not in actual:
            breaking.append(f"missing column '{name}'")
        elif actual[name] != TYPE_MAP[field["type"]]:
            breaking.append(f"'{name}': contract says {TYPE_MAP[field['type']]}, table has {actual[name]}")

    for name in actual:
        if name not in expected:
            additive.append(f"undeclared column '{name}'")

    for f in contract["schema"]:
        if f.get("required") and con.execute(f"select count(*) from {table} where {f['name']} is null").fetchone()[0]:
            breaking.append(f"'{f['name']}' is required but contains nulls")
        if f.get("unique") and con.execute(
            f"select count(*) - count(distinct {f['name']}) from {table}").fetchone()[0]:
            breaking.append(f"'{f['name']}' is not unique")
        if f.get("accepted_values"):
            vals = "', '".join(f["accepted_values"])
            bad = con.execute(f"select count(*) from {table} where {f['name']} not in ('{vals}')").fetchone()[0]
            if bad:
                breaking.append(f"'{f['name']}': {bad} rows outside accepted values")

    for a in additive:
        print(f"ADDITIVE  {a}")
    for b in breaking:
        print(f"BREAKING  {b}")
    if breaking:
        sys.exit(f"\ncontract {contract['dataset']} v{contract['version']} violated: {len(breaking)} breaking change(s)")
    print(f"\ncontract {contract['dataset']} v{contract['version']} satisfied")

check("contracts/orders.yml", "bookshop.silver.orders")
python check_contract.py
contract bookshop.silver.orders v2.1.0 satisfied

Now rename the column at the source and re-run:

BREAKING  missing column 'amount'
ADDITIVE  undeclared column 'amount_gbp'

contract bookshop.silver.orders v2.1.0 violated: 1 breaking change(s)
# .github/workflows/contracts.yml
- run: python check_contract.py
Run python check_contract.py
BREAKING  missing column 'amount'
Error: Process completed with exit code 1

The producer’s pull request is red. That is the entire point — a contract that is only checked at read time tells the consumer they are broken, which they already knew.

Handling what you did not expect

Contracts work when both sides participate. For a feed you do not control, keep unknown fields instead of failing on them:

con.execute("""
    create or replace table bronze_orders as
    select
        try_cast(json_extract_string(payload, '$.order_id')  as bigint)        as order_id,
        try_cast(json_extract_string(payload, '$.amount')    as decimal(10,2)) as amount,
        json_extract_string(payload, '$.status')                               as status,
        payload                                                                as _raw,
        now()                                                                  as _ingested_at
    from (values
        ('{"order_id":1001,"amount":"25.50","status":"completed"}'),
        ('{"order_id":1002,"amount":"12.00","status":"completed","channel":"web","promo":"NEWYEAR"}')
    ) t(payload)
""")

print(con.execute("""
    select order_id, amount,
           json_keys(_raw) as keys_present
    from bronze_orders order by order_id
""").df().to_string(index=False))
 order_id  amount                                       keys_present
     1001   25.50                  [order_id, amount, status]
     1002   12.00 [order_id, amount, status, channel, promo]

Two new fields appeared and nothing broke, because the raw payload is retained. Detect the change rather than discovering it:

print(con.execute("""
    select unnest(json_keys(_raw)) as field, count(*) as rows
    from bronze_orders group by 1 order by rows
""").df().to_string(index=False))
     field  rows
     promo     1
   channel     1
    status     2
    amount     2
  order_id     2

A field present in some rows and not others is either a new addition or a removal in progress. Alerting on a change to that key set gives you a day’s notice instead of an incident.

Versioning a dataset

bookshop.silver.orders_v1     deprecated 2026-06-30, read-only
bookshop.silver.orders_v2     current
bookshop.silver.orders        view → orders_v2

Publish both during the migration, point the unversioned name at the current version, and give consumers a deprecation date. Track who is still reading the old one before removing it:

print(con.execute("""
    select object_name, count(*) as reads, max(query_time) as last_read
    from query_log
    where object_name like '%orders_v1%' and query_time >= current_date - 30
    group by 1
""").df().to_string(index=False))
        object_name  reads           last_read
 silver.orders_v1       412 2026-09-08 14:22:00

412 reads last month means the deprecation is not done, whatever the date on the announcement. Every warehouse has this log — Snowflake’s query_history, Databricks’ system.access.audit, Postgres with pg_stat_statements — and checking it is the difference between a clean removal and an outage.

Practice

1. Add a nullable column to one file and read both together.
 order_id  amount channel
     1001   25.50    None
     1003   40.00     web

Old rows get NULL. This is the only structural change that is safe in both directions, which is why “add a column” should be the default answer to a schema request.

2. Rename a column and query the union.
 order_id  amount  amount_gbp
     1003   40.00        None
     1005    None       63.20

Two half-populated columns and sum(amount) quietly excluding new rows. Expand-and-contract — write both, migrate, then drop — is three deploys and the only safe path.

3. Break the contract and run the CI check.
BREAKING  missing column 'amount'
Error: Process completed with exit code 1

Caught in the producer’s pull request, before any data was written. Run the same check post-load as well, since a contract can be violated by data as well as by code.

4. Track the key set of a raw JSON feed over time.
     field  rows
     promo     1
   channel     1
  order_id     2

Fields present in only some rows are changes in flight. Alerting on the key set is how you find out about an upstream change before a consumer does.

That closes the data engineering track. The thread through all ten lessons: a pipeline’s correctness is decided by what happens on the second run, the late row, and the change nobody told you about — not by the transformation in the middle.

Frequently Asked Questions

What is backward compatibility in a schema?
New consumer code can read data written with the old schema. Adding an optional field with a default is backward compatible; removing a required field is not. It is the compatibility mode you need when consumers upgrade before producers.
What is a data contract?
An explicit, versioned agreement about a dataset's schema, semantics, freshness and quality, owned by the producer. Its value is not the document — it is that a change violating it fails a build rather than someone's dashboard.
How should a column be renamed safely?
Add the new column, write both for a deprecation period, migrate consumers, then remove the old one. A rename is a delete plus an add, and a delete is breaking for every consumer that reads it.
Where should schema compatibility be enforced?
In the producer's CI, before the change is merged. Detecting an incompatible schema at read time means the data is already published and the consumer is already broken — the check has to run earlier than that.