The Five Pillars: Freshness and Volume Monitors in SQL
Name the five observability pillars, then implement the freshness, volume, and distribution monitors as ordinary SQL over table metadata rather than over the data itself.
Five pillars, and why they are five
Data observability is usually framed as five pillars, and the framing is worth memorizing because interviewers use it as a checklist:
- Freshness. Is the data recent enough? A table that stopped loading at 2 a.m. looks perfectly correct and is completely wrong.
- Volume. Did the expected amount of data arrive? Half a load is the failure people notice last.
- Schema. Did the shape change? A dropped or retyped column breaks consumers that never touched the pipeline.
- Distribution. Are the values still shaped the way they were? A null rate that jumps from 1% to 6% is a broken join upstream.
- Lineage. What is upstream and downstream of this table? Without it you cannot answer "what else is affected".
The metric all five exist to shrink is data downtime: the total time your data was wrong, late, or missing. Framing your monitoring answer around reducing data downtime rather than around "adding alerts" is what separates an engineer from someone who has installed a tool.
Monitors read metadata, not data
Here is the design decision that makes always-on monitoring affordable. A monitor does not scan the table it watches. It reads the metadata a warehouse already keeps about that table: when it was last written, how many rows it holds, what its columns are. Scanning a billion-row fact table every fifteen minutes to ask "is it fresh" would cost more than the pipeline that built it.
So the monitors below run over two metadata tables. warehouse_tables is the catalog's own record of each table (owner, last write, agreed SLA). table_snapshots is the daily profile a monitoring job appends: row count and null rate per table per day. Both are tiny, and both can be queried every few minutes forever.
| pillar | what breaks when it fails | the SQL shape |
|---|---|---|
| freshness | the dashboard shows yesterday and nobody notices | age from last_loaded_at compared to an SLA in minutes |
| volume | half the rows arrived and the totals quietly drop | latest row_count against a trailing-window AVG, with a deviation threshold |
| schema | a dropped or retyped column breaks every consumer | two catalog snapshots diffed with a self-join |
| distribution | a broken join fills a column with NULLs | latest null rate or category share against its own trailing average |
| lineage | you cannot answer what else is affected | an edges table walked with a recursive CTE |
Freshness: age against an SLA
Freshness is subtraction. Take the evaluation time, subtract last_loaded_at, convert to minutes, and compare to the table's agreed freshness_sla_minutes. In SQLite that is strftime('%s', ...) on both sides, which gives epoch seconds:
(strftime('%s', '2026-03-02 09:00:00') - strftime('%s', last_loaded_at)) / 60
Two details decide whether the monitor is trustworthy. First, an SLA is per table, not global: a streaming staging table is late after 30 minutes, a slowly changing dimension is fine for a day. Second, a table sitting at exactly its SLA is not yet breaching, so the comparison is strictly greater than. Getting that wrong is how a monitor starts paging on healthy tables and gets muted.
This is not the same freshness Level 9 measured. de-l9-freshness-slas measures the age of the DATA: how far behind real time the newest event in the table is, derived from event timestamps. That is the pipeline-side question and it needs the table's contents. This lesson measures the age of the WRITE: how long since anything landed, read from catalog metadata. A table can be freshly written and still full of stale events (the source stopped producing), and it can hold very recent events while the write itself is hours late. Interviewers ask which one you mean, so say it: catalog freshness is the cheap always-on watcher, event-time freshness is the accurate one that costs a scan.
Volume: today against its own history
A row count means nothing on its own. It means something against a baseline, and the standard baseline is the same table's trailing window. A window function gives you it directly:
AVG(row_count) OVER (
PARTITION BY table_name ORDER BY snapshot_date
ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING
)
1 PRECEDING as the upper bound is the part people get wrong. Including today in its own baseline drags the average toward the anomaly and hides small breaks. Then compare with a percentage deviation and a threshold, and make the comparison two-sided: a load that doubles is as suspicious as a load that halves, and duplicate-producing bugs are at least as common as missing-data bugs.
Distribution: the same query on a different column
Once you have the trailing-window shape you get the distribution pillar almost free. Swap row_count for null_user_id_rate and you are monitoring whether a column's null rate suddenly doubled, which is what a silently broken join looks like from the outside. The same shape monitors a category's share of rows, an average order value, or a percentage of negative amounts.
Common mistake: including the current day inside its own trailing baseline. With a 7-day window that pulls the average about 12% toward whatever today did, which is exactly the direction that hides a real anomaly. Bound the window at 1 PRECEDING.
Interview nuance: "how would you monitor this pipeline in production" is scored on two things: naming the five pillars without hesitating, and being able to sketch the freshness and volume queries on a whiteboard. Freshness and volume are the pair an intern is expected to implement, and they catch most real incidents. Mentioning that the monitors read metadata rather than the tables themselves is the detail that shows you have thought about the cost.
On a real platform this differs. Monte Carlo, Soda, and Bigeye ship these monitors as configuration rather than SQL, and they learn the thresholds from history instead of taking a hardcoded 50%. dbt has
dbt source freshnessfor the freshness pillar specifically, withwarn_afteranderror_afterin the source config. The metadata is also already there in real warehouses: Snowflake'sINFORMATION_SCHEMA.TABLEScarriesLAST_ALTEREDandROW_COUNT, BigQuery's__TABLES__carrieslast_modified_timeandrow_count, and Glue keeps partition-level metadata. The queries you are about to write are the ones those products run for you.
CREATE TABLE warehouse_tables (
table_name TEXT,
owner TEXT,
last_loaded_at TEXT, -- catalog metadata: when the table last received a write
freshness_sla_minutes INTEGER -- how stale the table is allowed to get before it breaches
);
INSERT INTO warehouse_tables (table_name, owner, last_loaded_at, freshness_sla_minutes) VALUES
('fct_orders', 'analytics', '2026-03-02 08:45:00', 60),
('dim_customer', 'analytics', '2026-03-02 06:30:00', 180),
('fct_web_events', 'growth', '2026-03-02 02:10:00', 120),
('stg_clickstream', 'growth', '2026-03-02 08:10:00', 30),
('mart_marketing_spend', 'growth', '2026-03-02 04:45:00', 240),
('dim_channel', 'growth', '2026-03-02 06:00:00', 180),
('mart_daily_revenue', 'finance', '2026-03-01 23:00:00', 480),
('fct_payments', 'finance', '2026-03-01 18:20:00', 360),
('dim_product', 'merch', '2026-03-02 07:55:00', 90),
('dim_store', 'merch', '2026-02-28 21:00:00', 1440);-- The freshness monitor, evaluated at a fixed 2026-03-02 09:00. Note dim_channel: stale by
-- exactly its SLA, and therefore not yet breaching.
SELECT table_name,
owner,
(strftime('%s', '2026-03-02 09:00:00') - strftime('%s', last_loaded_at)) / 60 AS minutes_stale,
freshness_sla_minutes,
CASE
WHEN (strftime('%s', '2026-03-02 09:00:00') - strftime('%s', last_loaded_at)) / 60 > freshness_sla_minutes
THEN 'BREACHED'
ELSE 'fresh'
END AS freshness_status
FROM warehouse_tables
ORDER BY minutes_stale DESC;Apply
Your turn
The task this lesson builds to.
Write a query that returns every table breaching its freshness SLA as of '2026-03-02 09:00:00', as (table_name, minutes_late), most late first, over warehouse_tables(table_name, owner, last_loaded_at, freshness_sla_minutes).
A table breaches when its age in minutes is strictly greater than freshness_sla_minutes, and minutes_late is how many minutes past the SLA it is. Take the age from epoch seconds, the way the demo does, so whole minutes come from integer division rather than from rounding. Alias the computed column exactly minutes_late.
3 hints and 1 automated check are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, plus 3 bonus drills.
Write a query that returns every table whose latest row count deviates by more than 50% from its trailing 7-day average, as (table_name, latest_rows, trailing_avg, deviation_pct), biggest absolute deviation first, over table_snapshots(table_name, snapshot_date, row_count, null_user_id_rate).
The trailing average covers the 7 snapshot days before the latest one and must not include the latest day itself. deviation_pct is the signed percentage difference from that average, so a collapsed load comes back negative and a doubled load comes back positive. Round trailing_avg and deviation_pct to 1 decimal place and alias every column exactly.
3 hints and 1 automated check are waiting in the workspace.