← All cheatsheets
Data Engineer · #090 · November 2, 2026 · 3 min read

Schema drift: the upstream rename that loads NULLs for a week

A source column renamed on a Tuesday, a pipeline that stays green while it loads NULLs, the schema check at ingest that fails the run instead, the dbt and Great Expectations tests that catch it on night one, and the data contract: one page.

Get the free PDF

One page, print-ready, free to share. No signup needed.

Download the PDF

Tuesday, 09:40, someone upstream renames amount to amount_cents. No mail, no PR on your side. The loader runs at 10:00, finishes green, and amount is NULL from that day on. Six days later the revenue dashboard shows a flat line and the question comes to you. This page covers the check at ingest that fails the run instead, where dbt and Great Expectations catch the same thing, and the contract that makes the producer tell you first. The print-ready A4 PDF is at the bottom.

What happened

  • amount became amount_cents upstream. No mail.
  • The loader mapped nothing to amount. The job stayed green and the column went NULL for six days.

A rename is the common case. A retype (an integer that becomes a string, a timestamp that loses its time zone) and a dropped column do the same thing with different symptoms.

Catch it at ingest

  • An expected schema: the columns and their types, in a file, in git.
  • Diff it on every run. A missing or retyped column fails the run, before the load.
  • dbt not_null in schema.yml: fails the first night the column is NULL.
  • Great Expectations: expect_column_to_exist, expect_column_values_to_be_of_type.

The check, before the load

Twelve lines of Python in front of the loader. The run fails at 09:41 with the column name in the message.

expected = {
    'order_id': 'int64',
    'amount': 'float64',
    'placed_at': 'datetime64[ns]',
}
actual = df.dtypes.astype(str).to_dict()

missing = expected.keys() - actual.keys()
retyped = {c for c in expected
           if c in actual and actual[c] != expected[c]}
if missing or retyped:
    raise ValueError(f'schema drift: {missing} {retyped}')

A rename shows up as missing. A string where a float was shows up as retyped. Extra columns are allowed here on purpose: additions rarely break a load, they just need a ticket. Log them.

Where each tool catches it

ToolYou writeFires
ingest checkthe expected dict, diffed per runbefore the load
dbtnot_null, accepted_values in schema.ymlat dbt test, after the load
Great Expectationsexpect_column_to_exist, expect_column_values_to_be_of_typeat the checkpoint, before or after
data contractthe schema, versioned, owned upstreambefore they deploy

The dbt version, for the column in question:

models:
  - name: stg_orders
    columns:
      - name: amount
        tests:
          - not_null

It runs after the load, so the NULL rows are already in staging. But it fires the first night, not six days later, and the failing test names the column.

Remember

  • A data contract is the ingest check moved to the producer. They run it before they deploy.
  • A NULL rate alert is the last net: amount going from 0% NULL to 100% NULL in one day is not drift, it is a break, and it should page someone.

The trap: the check that passes anyway

You didWhat happensDo instead
SELECT * into the targeta new column shifts positions; the insert succeeds with values in the wrong columnsname the columns on both sides
nullable target columnamount NULL loads without complaintNOT NULL on the target, or a not_null test
checked after the loadthe bad rows are in, downstream already rancheck the file, then load

The order matters: ingest check, then load, then dbt tests, then the NULL rate alert. Each one is cheaper to fix than the next.

Frequently asked questions

What is schema drift?
A change in the structure of a data source that nobody told the pipeline about: a column renamed, retyped, dropped or added. The classic is a rename. The old column is gone, the loader maps nothing to the target column, the job finishes green and the column is NULL from that day on.
How do I catch schema drift at ingest?
Keep the expected columns and types in a file in git, and on every run diff them against what arrived. A missing or retyped column fails the run before the load, with the column name in the error. Extra columns can be allowed with a warning.
How do dbt tests catch it?
A not_null test on the column in schema.yml fails the first night the column arrives as NULL. accepted_values and a type check catch a retype. dbt tests run after the load, so the bad rows are already in staging, but the alert fires within a day.
What is a data contract?
The same schema check, moved to the producer. The expected schema is versioned and owned by the team that emits the data, and they run the check before they deploy. A rename still happens, but it arrives as a ticket and a version bump instead of a NULL column.

Get the free PDF

One page, print-ready, free to share. No signup needed.

Download the PDF

More cheatsheets