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.
★ 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.
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.
Not all schema changes are equal
9 min liveThree 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.
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.
timestamptotimestamptzis 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.
statusgainsheld. Everycase when status in ('placed','shipped','cancelled')in the estate now sends those rows to theelsebranch 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.
| Change | What breaks | When you find out |
|---|---|---|
| A column goes from not-null to nullable | Nothing today. Joins start dropping rows the first night a null actually arrives | Days or weeks later, as a volume drift nobody can explain |
| A default is added or removed | Rows that used to carry a value now carry null, or the reverse | On the next insert path that omits the column |
| Column order changes | Anything positional: a copy command, an unload, a stored insert without a column list | Immediately 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.
The diff, and the revert problem
8 min liveThe 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.
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.
The column-set diff on orders_daily
★ 20 min · everyone buildsSix 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.
2 · Write the diff as two set differences. Both directions, reported separately, joined into one sentence. The except operator is the whole monitor.
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.
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.
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.
Before session 5
30 min- 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.
- 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.
- Add
data_typeandis_nullableto 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. - 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.
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.
What tonight maps to
learn-analytics-engineering-with-phoebe, which owns assertions on tables you produce yourself.
Session 4 cheat sheet
except queries over last night and tonight, reported separately so the message names the class.