learn-data-observability-with-phoebe / Builder session 4 of 8
Learn Data Observability with Phoebe · Builder session 4 of 8

Schema change: the break you can see coming

A schema change is the only failure on the whole list that leaves a receipt. The column list is stored, it is free to read, and comparing tonight's against last night's is two set differences. So this is the cheapest monitor in the set and also the one with the most interesting judgement call in it, because the same query that catches a dropped column on night 23 fires again on night 24 when somebody puts it back.

🟡 Core Analytics engineers 45 min
0-3 · Welcome 3-20 · Additive, breaking, and the ones in between 20-40 · Build-along: the column-set diff 40-45 · Q&A
Part 0

Where the monitor set stands

The artifact so far: a metrics table recording one row per night for analytics.orders_daily at Bellwether, plus a freshness monitor set from the trailing distribution and a volume monitor set from the same-weekday median. Three signals instrumented, two nights of the 30-night history caught. Tonight adds the fourth signal, and it is the one that arrives with a paper trail: the column set. Session 5 takes distribution, session 6 wires the whole thing into expectations as code.

Live = presented in the 45 minutes Self-study = the full depth, after class 🔍 Every diagram zooms

★ What you walk out with tonight. A nightly snapshot of information_schema.columns landing in the metrics table, a diff query that names what was dropped and what was added in one sentence, and a routing rule that sends an addition to a channel and a removal to the rota. Plus the decision that everybody postpones and then regrets: whether a rollback is a page.

LiveWhy schema is the cheapest signal to instrument3 min▶

Freshness needs a promise. Volume needs a history. Uniqueness needs a key you agree on. Schema needs none of those: the warehouse already publishes the column list of every table it holds, and the check is a comparison of two lists. There is nothing to tune, no band to widen, no percentile to argue about. Snapshot the list nightly and the monitor is finished.

What makes it interesting is the other end. A freshness page says "late", which everybody understands. A schema page says "the shape changed", and what happens next depends entirely on which change it was. One class of change is harmless, one breaks loudly, and one passes every check you own while quietly corrupting numbers downstream.

Real world

On night 23 at Bellwether, ship_region was dropped and promo_code was added in the same upstream release. Nothing errored. Two dashboards had a region filter that silently lost its column and started aggregating nationally, and the regional managers spent a week comparing numbers that no longer meant what the axis label said.

Self-studyThe three questions a schema page has to answer3 min▶

A message that says "schema changed on orders_daily" sends the reader to go and look, which costs them 20 minutes before they know whether it matters. Three facts turn it into a decision:

  • What changed, by name. Not a count. "dropped ship_region; added promo_code" is the whole difference between a page that gets triaged and a page that gets starred for later.
  • Which class it belongs to. Addition only, removal, or a type change. The class is what decides the route, and the diff query can label it without any extra work.
  • Who reads the column. This is the lineage question and session 7 answers it properly. Until then, a hand-maintained list of the five columns that appear in dashboard filters gets you most of the value for an afternoon of work.
Part 1 · covers the three classes and which checks see each one

Not all schema changes are equal

9 min live

Three classes, and the ranking by cost is the opposite of the ranking by noise. The change that breaks loudly is the cheap one, because it is fixed by lunchtime. The expensive class is the one where every check you own passes and the numbers move anyway.

Same table, same night. Three shapes of change, three very different mornings. 1 · Purely additive EXAMPLE promo_code appears DOWNSTREAM Nothing breaks, unless a query said select star. WHO SEES IT The column diff, and nobody else needs to. Route: a channel. 2 · Breaking EXAMPLE ship_region dropped; bigint narrows to int DOWNSTREAM Queries error the same morning. Loud, and fast to trace. WHO SEES IT The diff, plus every broken build at once. Route: the rota. 3 · Silently breaking EXAMPLE int widens to numeric; status gains a value DOWNSTREAM Sums drift, a case statement drops rows, no error anywhere. WHO SEES IT Nothing you have on tonight. Route: a second diff. Class 3 is the expensive one precisely because every check passes.
🔍 Click to zoom - the loud class is the cheap one; the quiet class is the one that costs a week.
LiveAdditive: safe, and worth a page anyway3 min▶

A new column appears. Every existing query names its columns explicitly, so every existing query is unaffected. This is the class that a schema monitor is accused of being noisy about, and the accusation is fair right up to the moment somebody deploys a model that reads the table with select * and gains a column nobody validated.

An addition is still worth knowing about for a reason that has nothing to do with breakage: it is evidence that an upstream team shipped something. A new column is the only free notification you will ever get that a producer changed their mind about what this table contains. Route it to a channel, not to a person, and read it once a week.

The one case where additive is not additive. A column added with the same name as one you had computed downstream. Your model produces promo_code from a join, the source starts producing promo_code natively, and now two things with one name disagree. This is the additive change that has caused the most damage in practice.

LiveBreaking: loud, and therefore cheap3 min▶

A column is dropped, or renamed, which the warehouse reports as one drop plus one addition. Or a type narrows: bigint to int, numeric(18,4) to numeric(10,2), a wide varchar to a short one. Anything that can no longer hold the values that used to fit.

These fail fast and they fail visibly. Builds error, dashboards show a column-not-found message, and somebody complains within the hour. The monitor buys you the head start between the load finishing and the first human noticing, which on a nightly table is often the difference between fixing it before 09:00 and explaining it at 09:15.

Renames deserve one specific warning. A set difference reports a rename as dropped ship_region; added shipping_region, and a tired reader at 07:00 sees "dropped" and starts an incident. Worth teaching the message to say "possible rename" when a drop and an addition arrive on the same night, which costs one line of logic and saves a false escalation.

LiveSilently breaking: the widening class3 min▶

The class that costs real money. Nothing errors, no query fails, and the column list is unchanged, so a column-set diff is blind to all of it.

  • A type widens. An integer becomes a numeric. Every downstream sum still runs, and results that used to be whole numbers now carry fractions that a rounding step in a report may or may not handle the same way as before.
  • A timestamp loses or gains a timezone. timestamp to timestamptz is a widening in the type system and a change of meaning in the data. Every date-bucketed count shifts by the offset, which for a grocery delivery company means orders moving between days.
  • A code column starts carrying a new value. status gains held. Every case when status in ('placed','shipped','cancelled') in the estate now sends those rows to the else branch or drops them entirely, and the totals get quietly smaller.

The first two are catchable with a second diff on data_type, which is the same query shape and the same free metadata. The third is not a schema change at all in the strict sense: the column list and the types are identical. It is a distribution change, and it belongs to the segment-share statistic in session 5.

Self-studyNullability, defaults and the changes that only bite on backfill4 min▶

Three more shapes worth storing in the snapshot, all of them free and all of them invisible to a column-set diff.

ChangeWhat breaksWhen you find out
A column goes from not-null to nullableNothing today. Joins start dropping rows the first night a null actually arrivesDays or weeks later, as a volume drift nobody can explain
A default is added or removedRows that used to carry a value now carry null, or the reverseOn the next insert path that omits the column
Column order changesAnything positional: a copy command, an unload, a stored insert without a column listImmediately and confusingly, with values in the wrong columns

Nullability is the one to store first, because it is a single extra column in the snapshot and it is the leading indicator for a null-rate incident of exactly the kind session 5 measures. A column that became nullable on night 20 and jumped to 11 percent null on night 25 is not a mystery. It is a change you already recorded.

Part 2 · covers the diff, the revert, and where the column set comes from

The diff, and the revert problem

8 min live

The monitor is a set difference in both directions between tonight's column set and last night's, reported separately so the message can name the class. That is the whole implementation. The complication is not in the SQL, it is in what the diff means on the second night.

One row per table per night, snapshotted from information_schema.columns. Night 22 · quiet order_id customer_id order_ts amount currency status channel ship_region updated_at 9 columns Night 23 · the defect order_id customer_id order_ts amount currency status channel updated_at + promo_code ship_region gone Night 24 · reverted order_id customer_id order_ts amount currency status channel + ship_region updated_at promo_code gone Diff 1 dropped ship_region added promo_code a page you wanted Diff 2 dropped promo_code added ship_region a page nobody wanted Diff 2 is the revert. The query cannot tell it from a fresh break. Night 24 is one of the two false pages left at the top of the bench.
🔍 Click to zoom - the monitor compares tonight against last night, so a rollback is a change too.
LiveDeciding in advance whether a revert is a page4 min▶

Night 23 drops ship_region and adds promo_code. Night 24 puts it back. The diff on night 24 reads dropped promo_code; added ship_region, which is a true statement about a real difference between two consecutive nights, and it arrives as a page on a night when nothing was wrong.

There is no clever query that fixes this, because the monitor is not mistaken. It is answering the question it was asked. What has to change is the question, and there are three defensible answers:

  • Compare against last night. A revert pages. You accept one extra page per incident in exchange for a monitor with no state to maintain and no concept of "correct". This is what the bench runs, and it is why the top rung carries a false page it cannot remove.
  • Compare against a declared column set. A revert is silence, because the shape now matches the declaration again. The cost is that somebody has to maintain the declaration, and a declaration that drifts from reality is worse than no declaration.
  • Compare against last night, then suppress a diff that exactly inverts the previous one. Cheap, catches the common case, and quietly hides a genuine flap where a column appears and disappears nightly. If you take this route, count the suppressions and look at the count weekly.

Pick one before the first page arrives. The reason to decide in advance is that the decision made at 07:20 on the morning of a revert is always the same one, which is to mute the monitor.

LiveWhere the column set comes from3 min▶

information_schema.columns is in every warehouse that speaks SQL, and it carries more than the names: data_type, is_nullable, character_maximum_length, numeric_precision and ordinal_position. Reading it is free and instant, because it is catalogue metadata rather than data.

The important part is that a live read is not enough. information_schema tells you what the table looks like now, and a diff needs what it looked like last night, which nothing stores for you. So the monitor has two halves: a snapshot job that appends the current column set to the metrics table every night, and a diff query that reads two nights out of it. The snapshot is the half that has to run reliably, because a missing snapshot makes tomorrow's diff compare against the wrong night and report a change that never happened.

Snapshot the whole schema, not just the table you are monitoring. The row is nine values wide and there are maybe 400 tables. Storing all of them costs nothing and means that the next time somebody asks when a column disappeared, the answer is a query rather than an archaeology project.

Self-studyThe second diff: types and nullability4 min▶

Once the snapshot carries data_type and is_nullable, the second diff is a full outer join on column name rather than two set differences, and it catches the first two members of the silent class.

Two practical notes. First, warehouses report type names inconsistently across versions, so numeric can become decimal with no underlying change at all. Normalise the type string before comparing, or your first week is all false pages. Second, distinguish widening from narrowing in the message: int to bigint is almost always safe and worth a channel note, while bigint to int can truncate and belongs on the rota.

Precision is the one people skip and regret. numeric(18,4) to numeric(18,2) is neither an addition, a removal, nor a type change by name. It is a silent rounding of every amount in the table, and it shows up as a two-cent difference in a reconciliation three weeks later.

Self-studyWhy this monitor pays for itself on tables you do not own3 min▶

On a table you build, a schema change is something you did. It is in a pull request, it went through review, and the diff mostly tells you what you already know. The monitor is still worth having, because the deploy that changed the shape was not always the deploy you were watching, but its value is modest.

On a table another team produces, a schema change is an event you had no part in. There was no ticket on your board, no review you were invited to, and the first signal available to you is the shape of the data on the morning after. That is exactly the gap a metadata monitor exists to fill, and it is why the schema check earns its slot on ingested and third-party tables far more than on your own marts.

Build-time schema enforcement on tables you produce is a different mechanism with different tooling, and it belongs to the transform project's own contracts. learn-analytics-engineering-with-phoebe covers that side.

Build-along

The column-set diff on orders_daily

★ 20 min · everyone builds

Six steps. The first two are the monitor, the next two run it against nights 23 and 24 so you see both pages, and the last two are the parts that decide whether anybody keeps it switched on.

1 · Snapshot the column set nightly. One append per table per night into the metrics table. Run it after the load, not before, or you snapshot yesterday's shape with today's date.

★ Try it now insert into analytics.metrics_columns (load_date, table_name, column_name, data_type, is_nullable, ordinal_position) select current_date, table_schema || '.' || table_name, column_name, data_type, is_nullable, ordinal_position from information_schema.columns where table_schema = 'analytics' and table_name = 'orders_daily';

2 · Write the diff as two set differences. Both directions, reported separately, joined into one sentence. The except operator is the whole monitor.

★ Try it now with today as ( select column_name from analytics.metrics_columns where table_name = 'analytics.orders_daily' and load_date = current_date ), yday as ( select column_name from analytics.metrics_columns where table_name = 'analytics.orders_daily' and load_date = current_date - 1 ), dropped as (select column_name from yday except select column_name from today), added as (select column_name from today except select column_name from yday) select (select count(*) from dropped) as n_dropped, (select count(*) from added) as n_added, concat_ws('; ', case when (select count(*) from dropped) > 0 then 'dropped ' || (select listagg(column_name, ', ') from dropped) end, case when (select count(*) from added) > 0 then 'added ' || (select listagg(column_name, ', ') from added) end ) as message;

3 · Run it against night 23 and read the message. With load_date pinned to night 23 and its predecessor, the query returns n_dropped = 1, n_added = 1 and the message "dropped ship_region; added promo_code". Read that sentence out loud and decide whether you would page on it at 06:20. Then check whether any other monitor in your set fires on night 23. None of them do: the load was on time, the row count is normal, and the duplicate rate is at its baseline.

4 · Now run it against night 24. Same query, one night later. It returns n_dropped = 1, n_added = 1 and "dropped promo_code; added ship_region". Nothing is wrong on night 24. The upstream team fixed their release and the table is back to the shape it had on night 22. The monitor is correct and the page is unwanted, and that is the whole lesson of the session.

5 · Add the type and nullability diff. A full outer join on column name over the same two snapshots, catching the widening class that the set difference cannot see. Note the != rather than a null-unsafe comparison, and the coalesce that stops a new or removed column producing a spurious type change.

★ Try it now with y as ( select column_name, data_type, is_nullable from analytics.metrics_columns where table_name = 'analytics.orders_daily' and load_date = current_date - 1 ), t as ( select column_name, data_type, is_nullable from analytics.metrics_columns where table_name = 'analytics.orders_daily' and load_date = current_date ) select coalesce(t.column_name, y.column_name) as column_name, y.data_type as type_was, t.data_type as type_now, y.is_nullable as nullable_was, t.is_nullable as nullable_now from y full outer join t on t.column_name = y.column_name where y.column_name is not null and t.column_name is not null and (coalesce(t.data_type, '') != coalesce(y.data_type, '') or coalesce(t.is_nullable, '') != coalesce(y.is_nullable, ''));

6 · Write the routing rule. The class decides the destination, and the class is already in the two counts you computed. An addition on its own goes to a channel somebody reads on Monday. A removal wakes the rota. A drop and an addition on the same night is a possible rename and gets its own wording so nobody escalates a rename as a data loss.

★ Try it now select case when n_dropped > 0 and n_added > 0 then 'rota · possible rename' when n_dropped > 0 then 'rota · column removed' when n_added > 0 then 'channel · column added' end as route, 'analytics.orders_daily · ' || message as page_text from schema_diff where n_dropped + n_added > 0;

What the page should say. "orders_daily schema changed" makes the reader open a query editor. "orders_daily: dropped ship_region; added promo_code. ship_region appears in 2 dashboard filters and 1 export" makes the reader act, and the second clause is the one session 7 shows you how to generate.

Homework

Before session 5

30 min
  1. Get the nightly column snapshot running for every table in one schema, not just the one you are monitoring. It is the same query with the table filter removed, and it is the cheapest piece of history you will ever collect.
  2. Write down which of the three revert policies you are choosing, in one sentence, in the repo README. If you cannot decide, choose "a revert pages" and live with it for a month, because that policy costs nothing to maintain.
  3. Add data_type and is_nullable to the snapshot and run the type diff over whatever history you already have. Most teams find at least one type change from the last quarter that nobody remembers happening.
  4. Pick the three columns on your table that appear in a dashboard filter or a downstream join key and write them in the README. That list is the difference between a page that says "the shape changed" and a page that says who is about to be affected.
Check yourself

Three questions

The schema monitor pages on night 23 for a dropped column. On night 24 the upstream team reverts and the column set matches night 22 again. What does the monitor do?

The diff compares consecutive nights, not tonight against a declared shape, so the rollback is a real difference and reads "dropped promo_code; added ship_region". Night 24 is one of the two false pages that survive at the top of the bench, and the fix is a policy decision rather than a better query.

An integer column widens to numeric, and status starts carrying a value that no downstream case statement handles. Which of your checks sees it?

This is the silently breaking class. The widening needs a second diff on data_type, and the new code value is not a schema change at all: the column list and the types are unchanged, which makes it a distribution question.

You build one mart and you ingest three tables from another team. Where does a schema monitor earn the most?

On a table you build, the change is in a pull request you already saw. On a table somebody else produces, the shape of the data next morning is the first signal you get, and that is precisely the gap the monitor fills.

Sources covered

What tonight maps to

✓ Column-set diff as a monitor Two set differences over a nightly snapshot, with the message naming what was dropped and what was added. Working SQL for both halves.
✓ Additive, breaking and silently breaking All three classified with an example each, plus which checks see them. Type widening, timezone changes and a new code value are named as the expensive class.
✓ The revert as a second page Night 24 fires because a rollback is a change. Three revert policies with their costs, and the routing rule that splits additions from removals.
✓ information_schema.columns as the snapshot source Names, types, nullability and ordinal position, appended nightly to the metrics table so the diff has something to compare against.
◐ Model contracts and build-time schema enforcement Named here and handed to learn-analytics-engineering-with-phoebe, which owns assertions on tables you produce yourself.
◐ Which downstream assets read a dropped column The message needs it and session 7 generates it, from lineage and the impact query.
· A change-management policy for upstream producers Not covered here. Negotiating notice periods and deprecation windows with a producing team is an operating-model question that belongs to the governance course.

Session 4 cheat sheet

Three classesAdditive, breaking, silently breaking. The loud one is cheap; the quiet one costs a week.
The monitorTwo except queries over last night and tonight, reported separately so the message names the class.
The revert pages tooNight 24 reads "dropped promo_code; added ship_region". True, and unwanted. Decide the policy first.
Snapshot, do not read liveinformation_schema has no yesterday. Append the column set nightly or the diff has nothing to compare.
Store types toodata_type and is_nullable catch the widening class. Normalise the type name or week one is all noise.
Renames look like lossA drop plus an addition on one night is probably a rename. Say so in the message.
Route by classAddition to a channel, removal to the rota. The two counts you already computed decide it.
Point it outwardWorth most on tables another team produces, where the data next morning is your first notification.