·7 min read

What a diff of a table looks like

Code has diffs and data mostly does not, which is why bad numbers ship. Build the diff, then watch what a missing key does to it.

Every code change I ship comes with a diff. Someone reads the red and green lines and decides. The data that code produces gets no such treatment: the model runs, the tests pass, the number on the dashboard moves, and nobody can say which rows moved it. That gap is where most of the bad numbers I have shipped came from.

customers, version A → version B

Illustrative table. The diff and the numbers are computed live.

version A (before)14 rows
id
name
plan
region
mrr
seats
1
Acme
basic
EU
29
2
2
Birch
pro
US
79
5
3
Cinder
basic
US
29
1
4
Dune
team
US
199
12
5
Ember
basic
UK
29
1
6
Fjord
pro
EU
79
3
7
Gale
basic
US
29
2
8
Halo
team
EU
199
20
9
Iris
pro
US
79
4
10
Juno
basic
UK
29
1
11
Kite
pro
US
79
6
12
Lumen
team
EU
199
9
13
Moss
basic
EU
29
2
14
Nova
pro
US
79
3
version B (after)15 rows
id
name
plan
region
mrr
seats
1
Acme
basic
EU
29
2
2
Birch
pro
US
79
5
3
Cinder
pro
US
29
1
4
Dune
team
US
199
12
5
Ember
basic
UK
29
1
6
Fjord
pro
EU
79
3
8
Halo
team
EU
199
20
9
Iris
pro
US
149
4
10
Juno
basic
UK
29
1
11
Kite
pro
US
79
6
12
Lumen
team
UK
199
9
13
Moss
basic
EU
29
2
14
Nova
pro
US
79
3
15
Opal
basic
UK
29
1
16
Pike
team
US
199
15

Two versions of the same table

A customers model, before and after a change to the SQL. Fourteen rows on one side, fifteen on the other. Look at them the way a reviewer looks at a screenshot in a pull request. Nothing jumps out.

The diff lights up

Key both versions on id and compare cell by cell. Two rows added, one removed, three cells changed. That is the whole change, and it took a second to see once the rows were aligned.

Someone renamed a column

The same change, but mrr is now called mrr_usd. A naive diff sees a column vanish and a column appear, so every surviving row looks changed and the one real mrr change is buried in the noise.

What it does to the numbers

A diff is only useful if it explains the metric. The row count, the sum and the average each move, and every move is traceable to specific rows: one removed, two added, one edited.

The key trap

Take the key away and compare by row position instead. One removed row shifts everything below it, and three real changes become dozens. The removed row does not even show up as removed.

Edit version B

Every cell on the right is editable. Change a value, delete an id, toggle the key or the rename, and watch the diff and the metric deltas recompute.

What the tests do not catch

dbt tests are good at the questions they ask. Is the key unique. Is the column non-null. Are the values in the accepted set. Does the foreign key resolve. I run them on everything and I would not ship without them.

They do not ask the question a reviewer actually has, which is: what changed. A model can pass every test before and after a change while quietly dropping a customer, doubling a row, or shifting revenue between months. The tests are assertions about one version of the table. A diff is a statement about two.

The table above is small enough to eyeball, and even so the first scene should have looked like nothing happened. Fourteen rows against fifteen. Real models are millions of rows. Nobody is eyeballing anything.

3

cells actually changed

in the illustrative table, keyed on id

28

cells a naive diff reports

after mrr is renamed to mrr_usd

48

cells a positional diff reports

same change, no key

How to diff a model in practice

The cheapest version needs no tooling. Build the model twice, once from the branch you are merging into and once from your branch, into two schemas. Then ask the warehouse.

EXCEPT gives you the rows that differ in either direction. It is fast to write and it is the first thing I run.

(SELECT 'removed' AS status, * FROM analytics_main.customers
 EXCEPT
 SELECT 'removed', * FROM analytics_branch.customers)
UNION ALL
(SELECT 'added' AS status, * FROM analytics_branch.customers
 EXCEPT
 SELECT 'added', * FROM analytics_main.customers);

EXCEPT has the same weakness as the naive diff in scene three: a changed cell shows up as one row removed and one row added, and it cannot tell you which cell. For that you need the key.

WITH a AS (SELECT * FROM analytics_main.customers),
     b AS (SELECT * FROM analytics_branch.customers)
SELECT
  COALESCE(a.id, b.id) AS id,
  CASE
    WHEN a.id IS NULL THEN 'added'
    WHEN b.id IS NULL THEN 'removed'
    ELSE 'changed'
  END AS status,
  a.plan AS plan_before, b.plan AS plan_after,
  a.mrr  AS mrr_before,  b.mrr  AS mrr_after
FROM a
FULL OUTER JOIN b ON a.id = b.id
WHERE a.id IS NULL
   OR b.id IS NULL
   OR a.plan IS DISTINCT FROM b.plan
   OR a.mrr  IS DISTINCT FROM b.mrr;

That query is the whole algorithm in the component. Full outer join on the key, null on one side means added or removed, IS DISTINCT FROM per column means changed. Wrap it in a macro that reads the column list from the information schema and you have a diff for any model in the project. Aggregate mrr_after - mrr_before over the result and you have scene four: the metric delta with the rows responsible.

The key matters more than the tool

Scene five is the part I would put on a poster. Take the key away and the same three changes become 48, and the removed row vanishes from the report because positional comparison cannot see removal, only shifted values. Every diff tool on the market, paid or open source, degrades to that when the model has no stable primary key. The tool is not the hard part. The key is.

That has a consequence for how you write models. A mart with no natural key, or with a surrogate key built from ROW_NUMBER(), is not diffable. The key changes whenever the row order changes, and the row order changes whenever the warehouse feels like it. If you want to be able to review what a change did, every model needs a key that means something, and a test that says it is unique.

The rename in scene three is the other half of the same lesson. A diff compares columns by name, so a rename looks like a delete and an insert. Tools handle this with a column map, and so does the component. Do not rely on that. Rename columns in their own pull request, with a diff that shows zero cell changes, and change values in another.

What to do this week

Once the diff is in the review, the conversation changes. Nobody asks whether the tests passed. They ask why row 9 went from 79 to 149, which is the right question.

ShareXBlueskyLinkedInEmail

One visual essay, every week.

No roundups, no sponsors. Unsubscribe in one click.