Field guide · Nº 20
A field guide to the analytics stack
Somewhere in every company there is a moment when someone connects a dashboard tool to the production database. It is a completely reasonable act: the data is right there, it is current, and nobody wants to build a pipeline to answer one question. For a few weeks it works. Then the dashboard gets a date filter widened to five years, the checkout path starts timing out at 9 a.m. every weekday, and two teams begin an argument that neither can win, because they are both right.
That argument is the beginning of the analytics stack. The rest of this course is machinery, but the machinery only makes sense if you first understand the thing it exists to route around: transactional work and analytical work are not two flavors of the same task. They are opposite access patterns with opposite optimal storage layouts, and the incompatibility runs all the way down to how bytes sit on disk. This module makes that argument concretely enough that you can win the meeting, then shows you the byte arithmetic that ends it.
Take one product and watch what it asks of its database over a single morning. The wine-cellar application — the running example for this entire course — stores collectors' bottles, their acquisition prices, and a nightly feed of market valuations. Its operational schema is the one from Guide Nº 06: cellars, wines, regions, bottles, and market_prices.
The application's traffic is thousands of requests per minute, each one touching a handful of rows: fetch this cellar and its 38 bottles, insert one bottle, set consumed_at on another, read one wine's most recent price. Every one of those queries is selective — it knows exactly which rows it wants, arrives at them through an index, and returns in single-digit milliseconds. This is OLTP: online transaction processing, optimized for latency and concurrency, where success means a p99 under 50 ms while 400 writers commit concurrently.
The business asks a different shape of question entirely. "Which regions appreciate fastest over five years?" touches no single row in particular. It scans 42 million rows of market_prices, joins them to wines and regions, groups, and returns twelve rows — one per region. It is unselective by nature: the answer is a property of the whole population, so the whole population must be visited. This is OLAP: online analytical processing, optimized for throughput per query, where success means the scan finishes in seconds rather than minutes and nobody cares about p99 concurrency because there are eleven analysts, not forty thousand users.
market_prices and reads all five columns to use one. Sharing a database does not average their needs — it subtracts from both, because the scan destroys the transactional cache while the transactional traffic starves the scan.Notice what is not the difference. It is not data volume, complexity, or importance. It is selectivity: the transactional query knows which rows it wants before it starts, and the analytical query cannot know, because the answer is a property of all of them. Every architectural decision in the rest of this course descends from that single asymmetry.
The anti-pattern has a name and a very reliable symptom curve. Pointing BI at the production database looks like this: a dashboard tool holds a connection to the primary Postgres instance, refreshes on a schedule, and each refresh issues the five-year scan. The symptom is that application latency degrades on a timetable — a p99 that is fine at 8:45 and terrible at 9:00, recovering by 9:20, every weekday. Engineers chase it as an application problem for weeks because it looks like traffic, and the dashboard is invisible from the application's telemetry.
Two separate damages are happening, and keeping them apart is what lets you argue correctly about fixes.
The first is contention. The scan pulls 6.72 GB through a buffer cache sized for the transactional working set, evicting the pages the checkout path depends on; those requests now go to disk. On Postgres specifically, the long-running query also holds an old snapshot open, which blocks vacuum from reclaiming dead tuples, so table bloat grows and index scans get slower for everyone. Meanwhile the writes commit fine, so nothing errors — it just gets slow, which is the hardest failure to diagnose.
The second is physics. Even alone on an idle machine, the scan is slow, because row storage forces it to read all five columns of every row to compute an average over one. That damage is not caused by sharing; it is caused by layout, and no amount of isolation repairs it.
The reflexive corrective — point the dashboard at a read replica — genuinely removes the contention. It removes nothing else. The replica is still row-storage, so the scan is still slow. The replica lags, so the number on the dashboard is the number from some unspecified moment ago. And crucially, the replica gives you the production schema: normalized for update safety, full of soft-delete columns and foreign keys, with no notion of what "active cellar" means. Every problem this course exists to solve — modeling, definitions, freshness, lineage — survives the replica untouched. Contention is the symptom people notice first and the least interesting of the three.
The analytical stack is not a performance workaround. It exists because analytical questions need a different storage layout, a different schema shape, and a different notion of correctness than the system of record can provide. If you only ever fix contention, you have moved the query and kept every real problem.
Now the byte arithmetic, because it is the argument that ends the meeting. Take market_prices, the nightly feed of valuations: five years of daily observations across the wine catalogue, 42 million rows. Its columns are id (8 bytes), wine_id (8), source (a text label, averaging 24 bytes stored), observed_at (8), and price_cents (8) — plus per-row overhead. Call the effective stored width 160 bytes per row once page headers, tuple headers, and alignment padding are counted. Total: 6.72 GB.
In row storage, the unit of storage is the record. Every field of row 1 sits contiguously, then every field of row 2, packed into 8 KB pages. This is exactly what an OLTP workload wants: SELECT * FROM bottles WHERE cellar_id = 8812 follows an index to three pages and gets 38 complete records out of three fetches. The layout is not a compromise — it is optimal for the access pattern that wants whole records.
It is catastrophic for the other one. SELECT avg(price_cents) FROM market_prices needs 8 bytes per row and must read 160 to get them. The storage engine has no choice: the bytes it wants are scattered every 160 bytes across 6.72 GB, and disks and page caches do not deal in 8-byte fragments. The query reads all 6.72 GB to use 336 MB of it. Twenty times more data crosses the wire than the query has any use for.
Columnar storage inverts the unit. All 42 million price_cents values sit contiguously in one segment; all 42 million source values in another. Now the same aggregate reads exactly one segment: 42,000,000 × 8 bytes = 336 MB, and nothing else is touched. That is the 20× from layout alone.
price_cents are trapped inside a 160-byte record, and the page fetch that reaches them drags the other 152 bytes along — 6.72 GB read to use 336 MB. Columnar, the price segment is a standalone run of 42 million 8-byte values; the other four segments are never opened. Compression halves the survivor, landing at roughly 40× less data moved.Columnar layout buys a second win almost for free, and it comes from homogeneity. A column is one type with one distribution, so the encoder can exploit structure that a row-wise page — a jumble of integers, text, and timestamps interleaved — never offers.
Columnar compression in the wine-cellar feed is concrete. The source column holds four distinct values across all 42 million rows: livex, wine-searcher, auction-house, retail-panel. Dictionary encoding replaces every occurrence with a 2-bit code and stores the four strings once: a 1 GB column becomes about 11 MB. The observed_at column is sorted and regular, so delta encoding stores the first timestamp and then mostly the integer 1 (one day later) — run-length encoding collapses those runs to almost nothing. Even price_cents, genuinely varying, delta-encodes well because a wine's price moves in small increments from observation to observation.
Compression matters more than a storage bill, because in the modern warehouse it is also a compute bill. Most warehouse pricing is a function of bytes scanned or compute-seconds burned, and both fall when the encoded segment is smaller. Better still, some predicates never decompress at all: with a dictionary, WHERE source = 'livex' becomes a comparison against a 2-bit code, and segment-level min/max metadata lets the engine skip whole segments whose range excludes the filter. That skipping is the mechanism behind partition pruning, which module 8 shows is the single biggest lever on a warehouse invoice.
| Column | Raw size (42M rows) | Encoding | Encoded |
|---|---|---|---|
source | 1,008 MB | Dictionary (4 values) | 11 MB |
observed_at | 336 MB | Delta + run-length | 9 MB |
wine_id | 336 MB | Delta, sorted runs | 42 MB |
price_cents | 336 MB | Delta, high variance | 168 MB |
Columnar storage is not "the better layout." Run the wine-cellar application on it and you get a system that is worse at everything the application does. Reading one bottle's full record now means seeking into eleven separate segments and reassembling the row. Updating one bottle's consumed_at means rewriting or shadowing a compressed, encoded segment where that value is packed among millions of neighbors — which is why columnar engines generally do not support efficient single-row updates at all, preferring append-and-merge. Point lookups by primary key, high-concurrency single-row writes, and row-level locking are all things row storage does well and columnar storage does badly. Neither layout wins; each is optimal for the access pattern it was designed around. That is precisely why you end up with two systems.
You now have the argument for a second system. Module 2 describes what that second system actually is architecturally — a database built inside-out, with its storage separated from its compute — and draws the master map of the whole stack that the remaining six modules zoom into, one segment at a time.
Module 1 established that analytical work needs a different storage layout. If that were the whole story, the answer would be a second database on a bigger machine, and the analytics industry would have stopped in about 2005 — which is roughly what it did, with appliances that cost a million dollars and had to be sized for the busiest hour of the busiest quarter forever.
What changed is not that someone invented columnar storage. It is that someone unbolted the storage from the compute. A classical database — including the Postgres from Guide Nº 06 — is a single process that owns its disk: the data lives with the engine, the engine is the only thing that may touch it, and scaling either means scaling both. The cloud warehouse breaks that binding, and nearly every property of the modern stack that seems arbitrary — why raw data is kept forever, why transformation moved after loading, why the invoice is shaped the way it is, why the data science team's monster query stops being everyone's problem — falls out of that one decision. This module explains it, positions the lakehouse honestly, and draws the map you will navigate for the rest of the course.
In the wine-cellar's Postgres, the 6.72 GB of market_prices lives on a disk attached to the instance running the query engine. You rent that instance by the hour whether it is answering queries or idle at 3 a.m., its disk must be large enough for all your data, and its CPU must be large enough for your worst query. Storage and compute are welded together and you buy the maximum of both, permanently.
Separation of storage and compute unwelds them. One copy of the data sits in object storage — durable, replicated, effectively unbounded, priced at pennies per gigabyte-month and charged whether or not anyone reads it. The query engines are stateless clusters that hold no data at all: a cluster starts, reads the segments it needs from object storage, computes, returns the answer, and can be destroyed. Its bill is compute-seconds, and if nothing is querying, that bill is zero.
Follow the consequences, because they explain the rest of the course. Retention becomes cheap. Keeping five years of raw event data costs storage, not compute, so the ELT instinct in module 3 — land everything raw, forever, transform later — goes from wasteful to obvious. Capacity stops being a plan. There is no "sizing the warehouse"; there is only how much compute you chose to run in a given hour. The bill's shape inverts. A classical database's cost is roughly fixed and its efficiency question is "will it fit?"; a warehouse's cost is roughly variable and its efficiency question is "how many bytes did that query scan?" — which is why module 8 is about queries and not about instances.
Object storage holds bytes and knows nothing about tables. Something must map fct_price_observations onto a set of files, track which files belong to which partition, hold the column statistics that let an engine skip segments, and coordinate concurrent writes so a reader never sees a half-written commit. That is the catalog or metadata service, and it is what makes a pile of files behave like a database. When you hear that an open table format "adds transactions to object storage," this is the layer being described.
Once compute is stateless and data is shared, two properties appear that a single-server database cannot offer at any price.
Elasticity means compute is sized to the query rather than to the peak. The nightly rebuild of the wine-cellar marts gets a large cluster for eleven minutes; the executive dashboard gets a small one that runs for four seconds an hour; an analyst's exploratory session gets a medium one that auto-suspends after five idle minutes. None of these is a capacity decision anyone had to make in advance, and none of them costs anything overnight.
Isolation is the more interesting one. Multiple independent clusters read the same storage simultaneously. The wine-cellar deployment runs three: an ingestion and transformation cluster, a BI cluster serving dashboards, and an ad-hoc cluster for analysts and data scientists. When someone in the ad-hoc cluster writes an accidental cross join that runs for forty minutes and consumes an entire cluster's memory, the dashboards do not notice. There is no shared buffer cache to evict, no shared lock manager, no shared CPU — the only shared thing is immutable storage, and reading the same object from two places does not create contention.
This is precisely the property that module 1 showed a single database cannot have. There, the analytical scan and the transactional workload fought because they shared one engine's memory and I/O. Here, workloads are separated by construction rather than by scheduling.
Doubling a cluster's size roughly halves a scan's wall-clock time and costs roughly the same, because you bought twice the compute for half the duration. What it does not do is reduce bytes scanned — the query reads the same segments either way. This is the single most common cost misunderstanding in the modern stack: teams respond to an expensive query by making it faster, feel relieved, and see no change on the invoice. Module 8 does this arithmetic properly. For now, hold the distinction: cluster size buys latency; scan reduction buys money.
A data lake came first and was a simpler idea: put files in object storage. Parquet, JSON, CSV, logs — cheap, schemaless until read, and able to hold anything. What it lacked was everything that makes a database a database. No transactions, so a reader could observe a half-finished write. No schema enforcement, so a producer could change a column's type on Tuesday and nobody learned until a query failed on Thursday. No efficient updates or deletes, which mattered enormously the day a privacy request required removing one user's rows from four years of files. The honest history is that many organizations built lakes, discovered they had built a swamp, and loaded the useful subset into a warehouse anyway.
An open table format is the fix, and it is a specification rather than a product: a metadata layout sitting over ordinary columnar files in object storage that tracks which files constitute a table at each version. That indirection is enough to deliver atomic commits, schema evolution, row-level updates and deletes, and time travel — read the table as of last Tuesday by reading the file list from last Tuesday. A lakehouse is that arrangement: warehouse semantics over lake storage, with the deliberate property that the table format is open, so multiple engines can read and write the same tables rather than one vendor's engine owning them.
The honest positioning, in two sentences: the lakehouse is convergence, not revolution — warehouses have been adding openness and lakes have been adding semantics until the categories now overlap substantially — and it matters when you have volume, variety, or engine-independence requirements that an integrated warehouse serves badly. Below that threshold it is an architecture you are maintaining instead of a product you are using.
Concretely, for the wine-cellar product: 4.1 TB, structured relational data plus one clickstream, one SQL engine, three analysts and two data engineers. The integrated warehouse is the correct choice, and adopting a lakehouse here would mean hand-assembling catalog, table format, engine, and governance to gain openness nobody has asked for. The threshold gets crossed by things like: petabyte volumes where storage economics dominate; genuinely heterogeneous data including images, audio, or model artifacts alongside tables; needing a SQL engine and a Spark job and an ML framework to read the same tables without copies; a hard requirement that the data not be locked inside one vendor's proprietary format. Those are real requirements and organizations really have them. Most organizations, at most stages, do not.
You now have the two foundations: analytical questions need a columnar layout, and the modern warehouse delivers it as separated storage and elastic compute. Everything remaining in this course is the machinery between the production database and the number on the screen. Here is that machinery in one picture — the map the rest of the course zooms into, one segment per module.
Read the map left to right and it is a supply chain: each stage takes an input, makes a decision, and hands on a product. Read it right to left and it is an audit trail: any number on the far right was produced by a specific path through those decisions, and the path is finite, inspectable, and usually short. The entire promise of this course is that the right-to-left walk is available to you — which is only true if every segment is built to be walked.
You have a warehouse. It is empty. Everything the business wants to know about lives in a Postgres instance that the application team is not going to let you scan, plus a stream of clicks that the database never sees, plus a nightly file from a price vendor. Getting those into the warehouse is the first segment of Figure 2.3, and it is where most of a stack's permanent, load-bearing mistakes are made — because ingestion decisions are the ones you cannot repair later. If a row was deleted from production and your extract never noticed, that history is gone; no amount of downstream modeling recovers it.
This module covers the three ingestion modes, what each structurally cannot deliver, and the economic argument that reversed the order of the letters in ETL.
The simplest way to get data out of a source system is to ask for all of it on a schedule. A batch extract connects at 02:00, runs SELECT * FROM cellars, and writes the result to the warehouse's raw layer. It is a photograph of the source at a moment.
Its virtues are real and underrated. It is trivially debuggable — you can run it by hand and read its output. It has no persistent state to corrupt, so recovering from a failure means running it again. It works against anything that will answer a query, including vendor APIs and systems whose internals you cannot touch. For the wine-cellar's market price feed, which publishes a daily file of the previous day's observations, a nightly batch pull is not a compromise; it is exactly right, because the source itself only changes once a day.
Full extracts stop scaling, so teams move to incremental extracts: SELECT * FROM cellars WHERE updated_at > :last_run. This is where the silent failures live, and there are three of them.
Hard deletes vanish silently. If a row is deleted from cellars, there is no row left to have an updated_at greater than anything. The warehouse keeps its copy forever. The wine-cellar counts would drift upward month over month against production, and nobody would have an event to investigate — the divergence has no timestamp.
Untouched timestamps miss updates. updated_at is maintained by application code or a trigger, and every path that writes without maintaining it is invisible to your extract. A data-fix script run by an engineer at 3 a.m., a bulk backfill, an ORM path that writes one column directly — each produces a row whose contents changed and whose timestamp did not.
Intra-day transitions collapse. A batch sees states, not transitions. If a cellar was created at 09:00, renamed at 11:00, and soft-deleted at 16:00, the 02:00 photograph shows one row in its final state. Any question of the form "how long do cellars typically stay active before deletion?" is unanswerable, and you will not discover that until someone asks it a year later.
Incremental extracts on updated_at also have a boundary problem. A transaction that began at 01:59:58 and committed at 02:00:03 carries a timestamp before your watermark but became visible after your query ran — so it is never picked up by either run. The standard mitigations are overlapping windows (re-pull the last hour every time and deduplicate on primary key) and watermarking on a monotonic commit position rather than wall-clock time. Both are more machinery than the naive version, which is part of why CDC exists.
Every durable database already maintains a perfect, ordered record of everything that has ever happened to it: the write-ahead log. Postgres writes each change to the WAL before applying it to the heap, because that is how it survives a crash. Replication reads the same log. Change data capture is the observation that if the log is good enough to rebuild the database, it is good enough to feed the warehouse.
A CDC connector registers as a logical replication consumer and receives every insert, update, and delete as a structured event with the row's before and after images and a log position that totally orders them. Its properties are the mirror image of batch's: nothing escapes it, because escaping it would mean escaping the log the database itself depends on; deletes arrive as first-class events; and every intermediate state is preserved, so the transition questions become answerable. Latency drops from a nightly cycle to seconds or minutes.
op=D event that no batch extract could observe, and the soft delete arrives as an ordinary UPDATE carrying a deleted_at value — indistinguishable from any other field change unless a model chooses to interpret it. Module 6's dispute is that interpretation being made in one place and not another.CDC's costs are operational rather than conceptual. The replication slot the connector holds is a promise the database must keep: if the consumer stops, Postgres retains WAL segments for it, and an unmonitored stalled connector will fill the primary's disk and take production down — the one way an analytics component can cause an outage in the system it reads. Schema drift needs handling, because a column added to production arrives mid-stream. Ordering guarantees are per-table or per-partition rather than global, so cross-table consistency at a point in time is not free. And initial snapshots — the historical rows that predate the connector — are a separate, usually large operation.
A CDC stream is closer to a docket than a photograph: every filing, in order, with its timestamp, never amended in place. That is why the warehouse's raw layer is append-only and why module 4 treats editing it as tampering with evidence rather than fixing data.
Both modes so far extract from a database, which bounds them absolutely: they can only deliver what the database stores. The wine-cellar's Postgres knows that cellar 8812 contains 38 bottles. It does not know that its owner opened the valuation page nine times last week, sorted by appreciation twice, and never clicked through to the sell flow. That behavior was never written anywhere, because the application had no reason to write it.
An instrumentation event is a record emitted at the edge — browser, mobile client, or server handler — at the moment something happens, sent to a collector, and landed in the warehouse as its own stream. The wine-cellar emits three: cellar_viewed, bottle_added, and valuation_checked, each carrying a timestamp, an anonymous or authenticated actor id, the cellar id, and a small typed property bag.
The distinction from CDC is worth stating precisely, because teams routinely conflate them: CDC records what the database became; events record what happened. There is overlap — bottle_added corresponds to an insert CDC will also see — and that overlap is a useful reconciliation check, not a redundancy to eliminate. But the non-overlapping parts are the interesting ones. CDC will never deliver a page view, a search with no results, a form abandoned before submit, or a feature someone looked at and declined. Every funnel, engagement, and retention question lives in exactly that gap.
Events also have the opposite failure profile from CDC. They are schema'd at write time by application code, so a client that ships a bug, an ad blocker, or a user on a flaky connection produces gaps and duplicates that no log guarantees against — delivery is at-least-once at best and lossy in practice. Bot traffic pollutes them. They are, in short, a less reliable and more expressive source, which is why they need their own quality tests rather than the ones you would write for a relational extract.
For thirty years the acronym was ETL: extract, transform, load. Data was pulled from the source, cleaned and reshaped on an intermediate machine, and only the finished result was loaded into the warehouse. That order was not a philosophy. It was arithmetic. Warehouse storage was expensive and warehouse compute was a fixed, scarce appliance you had sized for the year, so loading anything you did not need was waste, and transforming inside the warehouse meant spending the scarcest resource you owned.
Module 2's architecture deletes both premises. Object storage costs pennies per gigabyte-month, so landing everything is cheap. Compute is elastic and billed by the second, so transformation in the warehouse is no longer a raid on a fixed budget. When the constraints invert, the optimal order inverts with them, and you get ELT: extract, load, then transform — land the source data essentially unmodified, and do all reshaping in SQL against it.
Three consequences make this more than a reordering.
The raw record survives. This is the big one. When a definition changes — and it will; module 6 is largely about that — you recompute history from raw. Under ETL, history was whatever the old transform produced, and the inputs are gone. Under ELT you re-run the model over five years of retained raw data and get a corrected series in an afternoon.
Transformation becomes SQL. Which means it becomes text, which means it goes in git, gets reviewed, gets tested, and gets rebuilt on every change — module 4's entire subject. ETL transformations lived in proprietary tools' drag-and-drop canvases where diffing two versions was not a thing you could do.
Loading decouples from modeling. The ingestion team's contract becomes "land the source faithfully," full stop. They do not need to know what a cellar means to the business, and the analytics team does not have to file a ticket to get a new column — it is already in raw.
Symptom. The ingestion job for the price feed computes a daily average per wine and lands only that — about 6,000 rows a night instead of 23,000 — "to save storage." Everyone is pleased until the finance team asks for median rather than mean, or for the same series split by source, and the answer is that neither can be produced for any date before today.
Diagnosis. A pre-aggregation performed before landing is an irreversible transformation applied to the only copy. It trades an asset that costs almost nothing — raw storage, here roughly $0.15 a month for five years of price observations — for a liability that costs everything: unreplayable history. The instinct is a correct 2005 instinct executed in 2026 economics.
Corrective. Land the source record at its native grain and unmodified. Aggregate in a model downstream, where the aggregation is version-controlled, testable, and — crucially — re-runnable when the definition changes. The rule to hold: ingestion may rename and it may reformat; the moment it starts deciding what the data means, it has become a transformation and belongs in the warehouse.
Raw data is faithful and unusable. raw_pg.cellars holds one row per change event with CDC bookkeeping columns, timestamps as strings, deleted rows as tombstones, and no opinion about what a cellar is. Turning that into something a dashboard can consume is the transformation layer, and it is the segment of Figure 2.3 where the analytics stack either becomes an engineering artifact or becomes folklore.
The folklore version is easy to recognize because most companies have one: a SQL script of several hundred lines, authored by someone who has since left, scheduled by a cron entry nobody wants to touch, producing a table that the board deck depends on. It works. Nobody can say why it works, nobody can safely change it, and when its output looks wrong the only available response is to stare at it. This module is about the alternative — which is not better SQL, but SQL treated as software: decomposed into layers with contracts, versioned, reviewed, and tested.
Three layers, three rules. The discipline is worth more than any individual model, because it lets someone who has never seen your project predict where a given piece of logic lives.
Raw is immutable evidence. It is what the source said, when it said it, unmodified. Nothing writes to it but ingestion, and nothing edits it ever. Its contract to the layers above is fidelity, not quality: raw may contain duplicates, nulls, wrong types, and rows the business considers invalid, and all of that is correct behavior. The raw layer is the only artifact in the stack that can prove what a source actually sent.
A staging model is one model per source table, and its rule is: rename, cast, deduplicate, and nothing else. No joins across sources. No business filters. No aggregation. stg_cellars turns raw_pg.cellars into one row per cellar with real types and clean names, resolving the CDC change stream to current state. It does not decide whether a soft-deleted cellar is "active," because that is a business question and this layer does not answer those. The contract staging owes upward is one clean row per entity, with the source's meaning intact.
A data mart joins and aggregates staged models for consumption. This is where business logic lives, exclusively: filters that encode a definition, derived measures, the star schemas of module 5. The contract a mart owes is a table shaped for a class of questions, whose logic is written down in one place.
The layer boundaries exist so that the question "where did this number's definition come from?" has exactly one possible answer. Raw cannot contain a definition because it is unmodified. Staging must not contain one because its rule forbids it. Therefore every definition is in a mart or the metrics layer above it, and the search space for module 6's investigation is small and known in advance.
Once transformation is SQL against landed data, it is text — and everything Guide Nº 18 established about managing text applies without modification. The practice that made this mainstream is dbt, and having named it once, this module will teach the practice generically, because the ideas outlive any tool.
A model is one file containing one SELECT. It does not say CREATE TABLE, does not say INSERT, does not manage its own dependencies. It declares what it is; the framework decides how it exists.
Dependencies are inferred, not declared. When fct_price_observations references stg_bottles through a reference function rather than a hard-coded table name, the framework knows the edge and assembles the whole project into a directed acyclic graph. Nobody maintains a build order by hand, and nobody can create a cycle by accident — which matters more than it sounds, because hand-maintained ordering is the single most common source of "the numbers are wrong on Mondays."
Materialization is declared per model, not implemented in it. The same SELECT can be a view (cheap, always current, recomputed per query), a table (expensive to build, fast to read), or incremental (only new partitions processed). Changing that decision is a one-line configuration change, so the performance-versus-freshness trade-off becomes tunable rather than a rewrite.
Because models are files, the entire discipline transfers with no adaptation. Changes arrive as pull requests, so active_cellars is never redefined by someone editing production at 11 p.m. Review is possible because a diff shows exactly which clause changed. CI runs the project against a sample and executes every test before merge. History is queryable, so "when did this number's definition change, and who approved it?" is answered by git log rather than by memory. Analytics teams that skipped this step did not save time; they deferred it until an auditor asked the question.
A data test is an assertion about a model that runs on every build. Four kinds carry most of the weight, and each catches a specific failure.
Not-null on a key column catches an upstream schema or join change that started producing orphan rows. Unique on the entity key catches a broken deduplication or a CDC replay that landed a row twice — the failure that silently doubles a count. Accepted values on a low-cardinality column catches the day the price vendor adds a fifth source label your CASE statement does not handle and quietly buckets into an else. Relationship tests catch referential breakage: a bottle whose wine_id has no matching wine, which is exactly the row that vanishes in an inner join and takes its value out of a total with it.
Beyond those, the tests that earn their keep are business-rule tests — assertions that encode a domain invariant a schema cannot express. For the wine-cellar: no bottle has consumed_at earlier than acquired_at; no price observation is negative or above a sanity ceiling; every unconsumed bottle in fct_price_observations has at least one price observation in the trailing 30 days. That last one is the test that catches a dead vendor feed before a dashboard does.
Symptom. One SQL file, 1,100 lines, nine nested CTEs, no tests, referenced by the board deck. One person understands it and they are on leave. When a number looks wrong, the team's options are to stare at it or to ignore it. New requirements are implemented by appending another CTE, because modifying an existing one is unbounded risk. The reliable tell is behavioral rather than technical: a change that should take an hour is quoted at a week, and a quarter's roadmap has no line item touching it.
Diagnosis. Every intermediate result is invisible, so a wrong output at the bottom gives no signal about which of nine stages produced it. There are no assertions, so upstream drift is discovered by a human noticing a number looks odd — which means it is discovered late, or in a board meeting. And because the file computes everything at once, no part of it can be reused, so the next requirement produces a second thousand-line file that duplicates 60% of the first and drifts from it immediately.
Corrective. Decompose, do not rewrite. Lift each CTE into its own model in the layer where it belongs, keeping the SQL byte-identical so the change is provably behavior-preserving; assert equality between the old output and the new composed output on a full run; then add tests at each new boundary. Now a failure names the stage that produced it. The refactor is mechanical, takes days rather than the quarter a rewrite takes, and — importantly — can be paused halfway without leaving anything broken.
The opposite failure is a project with 500 tests, of which 480 are auto-generated not-null assertions on columns that have never been null and never could be. The suite is green, takes eleven minutes, and has never caught anything. Meanwhile the one rule that actually breaks — that a cellar's bottle count matches the operational system — is untested, because writing it required understanding the business. Test count is not a quality signal. The question for each test is: what specific failure does this catch, and how would we otherwise learn about it? A test with no answer to that is maintenance cost with no yield.
Here is the actual staging layer for the wine-cellar, against the raw tables module 3 landed. These four models are the inputs module 5 builds the star schema from, so the SQL that follows is not illustrative — it is the project.
The CDC-sourced models share one shape, and it is worth reading carefully: raw holds one row per change, so staging must resolve the change stream to current state. Ordering by the log sequence number rather than by a timestamp is deliberate, since two changes within the same millisecond are ordered by LSN and not by clock.
-- models/staging/stg_cellars.sql
-- Contract: one row per cellar, current state, source meaning intact.
-- Note: deleted_at is passed through, NOT filtered. Whether a
-- soft-deleted cellar counts is a business question (see marts).
with ranked as (
select
*,
row_number() over (
partition by id order by _cdc_lsn desc
) as rn
from raw_pg.cellars
)
select
id as cellar_id,
owner_id as owner_id,
name as cellar_name,
created_at::timestamptz as created_at,
deleted_at::timestamptz as deleted_at,
_loaded_at as _loaded_at
from ranked
where rn = 1
and _cdc_op <> 'D' -- hard-deleted rows drop out here; the
-- tombstone stays in raw as evidence
-- models/staging/stg_bottles.sql
-- Contract: one row per bottle, current state. Monetary values in
-- integer cents throughout the project — no floats touch money.
with ranked as (
select
*,
row_number() over (
partition by id order by _cdc_lsn desc
) as rn
from raw_pg.bottles
)
select
id as bottle_id,
cellar_id as cellar_id,
wine_id as wine_id,
acquired_at::date as acquired_date,
acquisition_price_cents::bigint as acquisition_price_cents,
consumed_at::date as consumed_date
from ranked
where rn = 1
and _cdc_op <> 'D'
-- models/staging/stg_wines.sql
-- Contract: one row per wine. region_id is passed through un-joined;
-- resolving it to a region name is a mart concern (see dim_wine).
with ranked as (
select
*,
row_number() over (
partition by id order by _cdc_lsn desc
) as rn
from raw_pg.wines
)
select
id as wine_id,
producer as producer,
label as label,
vintage::int as vintage,
region_id as region_id
from ranked
where rn = 1
and _cdc_op <> 'D'
The price feed is batch rather than CDC, so its staging model has a different job: the vendor occasionally re-publishes a day's file, which lands the same observation twice with different load timestamps. Deduplication here is on the natural key, keeping the most recently loaded copy — a correction, if one arrives, wins.
-- models/staging/stg_market_prices.sql
-- Contract: one row per wine per source per observation date.
-- Vendor re-publishes occasionally; last load wins.
with ranked as (
select
*,
row_number() over (
partition by wine_id, source, observed_at::date
order by _loaded_at desc
) as rn
from raw_api.market_prices
)
select
wine_id as wine_id,
source as price_source,
observed_at::date as observed_date,
price_cents::bigint as price_cents
from ranked
where rn = 1
and price_cents > 0
Note the one filter in that last model, price_cents > 0, and why it does not violate the staging rule. It is not a business definition; it discards observations the vendor's own contract says are invalid sentinel values. The test is whether a reasonable person could disagree about it: nobody argues that a price of zero cents is a real market observation, whereas people argue constantly about whether a soft-deleted cellar is active. The first is cleaning; the second is a definition, and definitions go upstairs.
The tests, declared alongside the models:
# models/staging/schema.yml
models:
- name: stg_cellars
columns:
- name: cellar_id
tests: [not_null, unique] # catches CDC replay double-load
- name: owner_id
tests: [not_null] # catches an orphaned cellar
- name: stg_bottles
columns:
- name: bottle_id
tests: [not_null, unique]
- name: wine_id
tests:
- not_null
- relationships: # catches a bottle whose wine
to: ref('stg_wines') # vanished — the row that would
field: wine_id # silently drop from a join
- name: stg_market_prices
columns:
- name: price_source
tests:
- accepted_values: # catches the vendor adding a
values: ['livex', # fifth source label
'wine-searcher',
'auction-house',
'retail-panel']
tests:
- dbt_utils.expression_is_true:
expression: "price_cents between 1 and 20000000"
Every one of them is short, does one thing, and contains no decision anyone would argue about. That is the point. All four together are shorter than one CTE of the thousand-line script they replace, and each has a name a person can say in a meeting. When module 6's investigation needs to know where the soft-delete filter is applied, the answer is provably "not here" for all four — which is half the search eliminated before it starts.
The staging models from module 4 are clean, typed, and faithful — and shaped exactly like the operational database, which is to say shaped for updating single records safely. Guide Nº 06 taught you why: normalization exists so that a fact is stored once, so that changing it requires one write, so that the database cannot hold two contradictory copies of the same truth. Every one of those arguments is correct, and every one of them is about writes.
Analytical modeling is what happens when you take the same data and optimize the other direction. Nothing is updated in place, because models are rebuilt from raw; the physics of module 1 rewards reading few wide rows over reassembling many narrow ones; and the consumer is a human asking questions rather than an application enforcing invariants. Under those conditions, the answers invert: redundancy becomes free, denormalization becomes correct, and the design decision that matters most is one sentence long. This module builds the wine-cellar's star, derives it from the operational schema, and answers the question the whole course has been driving at.
Dimensional modeling divides everything into two kinds of table, and the division is unusually clean.
A fact table holds measurements: numbers that occurred, each row tied to a moment and to the entities it concerns. It is tall and narrow — hundreds of millions of rows, a dozen columns, mostly foreign keys and numeric measures. In the wine-cellar it will hold what each bottle was worth on each valuation date.
A dimension holds the nouns you slice by: wine, cellar, region, date. Dimensions are short and wide — tens of thousands of rows, many descriptive columns — and they carry every attribute you might want to filter or group by. dim_wine holds the producer, the label, the vintage, and — deliberately — the region name, copied from the region table.
A star schema is one fact table joined directly to its dimensions, one hop each, with no dimension joining to another. Drawn out, it looks like a star, which is where the name comes from; the more important property is that any question is answerable with joins from the center outward and never a chain.
That last property is the whole point, and it is worth seeing arithmetically. In the operational schema, "average market price by region" requires market_prices → wines → regions: three tables, two joins, and the join to wines is 42 million rows against 61,000. In the star, region_name sits on dim_wine already, so it is one join from a fact to a small dimension. Add cellar owner and vintage decade to the question and the operational version grows another two joins while the star version grows none.
Normalization's core argument: if region_name is stored on every wine, renaming a region means updating 5,000 rows, and any row you miss is a permanent contradiction inside your own database. That argument is airtight — in a system where rows are updated in place.
The warehouse is not such a system. dim_wine is not updated; it is rebuilt from stg_wines and stg_regions on every run. If a region is renamed, the next build propagates the new name to all 5,000 rows because it recomputes all 5,000 rows. Consistency here is protected by derivation from a single source, not by storing in a single place — and once that is true, the update anomaly the normal forms exist to prevent cannot occur. The redundancy is free, and refusing it costs you a join on every query for a safety property you already have by other means. Both guides are right about their own physics; the mistake is carrying either verdict across the boundary.
Before a single column is chosen, one sentence must be written: what does one row of this fact table represent? That is the grain, and it is the most consequential decision in analytical modeling — more than the column list, more than the technology, more than the schema layout — because every measure, every join, and every aggregate is either valid at the grain or silently wrong.
The wine-cellar's fact table declares its grain as: one row per unconsumed bottle per valuation date. Read what that sentence forecloses. A bottle consumed on 3 March has rows through 2 March and none after — so "portfolio value" naturally excludes drunk wine without any filter needing to remember to. A bottle appears once per date, so counting rows for one date counts bottles. And because the grain is per-bottle rather than per-wine, a cellar holding six bottles of the same Barolo contributes six rows, which is what makes summing values correct.
The test for whether a measure belongs at a grain is additivity: does summing it across any slice produce a true number? market_price_cents at bottle-day grain is additive — sum across a cellar and you get that cellar's value; across a region and you get the region's. unrealized_gain_cents is additive for the same reason. An appreciation percentage stored per row is not additive, and no aggregate over it is meaningful; averaging stored percentages weights a $40 bottle equally with a $4,000 one. Percentages are computed at query time from summed components, never stored as measures and never averaged.
Symptom. Totals that are too large by a suspiciously round-ish factor, and that get worse as you add filters. A regional total is 7× the number anyone expects; drilling into one cellar gives the right number; the discrepancy has no consistent multiplier. Analysts start adding distinct in places where it does not belong, which fixes the symptom on one dashboard and moves it to another.
Diagnosis. Two grains coexist in one table. The usual origin is entirely well-meant: a fact table at bottle-day grain, into which someone appended pre-computed cellar-level subtotal rows so a slow dashboard would load faster. Now sum(market_price_cents) counts every bottle once as itself and again inside its cellar's subtotal. The table has no way to signal this, because a grain is a fact about meaning and not about schema — nothing in the DDL is violated, every constraint passes, and every test that checks structure rather than semantics stays green.
Corrective. One grain per fact table, declared in one sentence at the top of the model file and enforced by a uniqueness test on the grain's key columns — for the wine-cellar, unique(bottle_id, valuation_date). That test is the single highest-value assertion in the project: it makes a grain violation a build failure instead of a board-meeting discovery. Subtotals that need to be fast become their own aggregate model at their own declared grain, not extra rows in someone else's table.
Now the derivation. On the left is Guide Nº 06's normalized schema, arrived at by asking "how do I store this safely?" On the right is the star, arrived at by asking "how do I answer questions about this?" Every difference between them is a deliberate trade.
region_name has been copied onto dim_wine so the question resolves in one join. The redundancy is safe because every dimension row is rebuilt from staging on each run rather than updated in place.The fact table itself, built from module 4's staging models:
-- models/marts/fct_price_observations.sql
--
-- GRAIN: one row per unconsumed bottle per valuation date.
--
-- A bottle appears from its acquisition date until the day before it
-- is consumed. Measures are all additive at this grain; ratios are
-- NOT stored — compute them from summed components at query time.
with valuation_dates as (
select date_day as valuation_date
from dim_date
where date_day between date '2021-07-12' and current_date
and is_week_start -- weekly snapshot cadence
),
latest_price as (
select
wine_id,
observed_date,
price_cents,
row_number() over (
partition by wine_id, observed_date
order by case price_source
when 'livex' then 1
when 'auction-house' then 2
when 'wine-searcher' then 3
else 4
end
) as source_rank
from stg_market_prices
)
select
b.bottle_id,
b.wine_id,
b.cellar_id,
v.valuation_date,
p.price_cents as market_price_cents,
b.acquisition_price_cents,
p.price_cents - b.acquisition_price_cents
as unrealized_gain_cents
from stg_bottles b
cross join valuation_dates v
left join latest_price p
on p.wine_id = b.wine_id
and p.observed_date = v.valuation_date
and p.source_rank = 1
where b.acquired_date <= v.valuation_date
and (b.consumed_date is null or b.consumed_date > v.valuation_date)
Three details in that model are load-bearing. The left join to prices is deliberate: a bottle with no observation on a given date should still exist in the fact with a null market price, because dropping it would silently shrink the portfolio — an inner join here is the join-path bug module 6 diagnoses. The consumed-date predicate implements the grain sentence directly, so "unconsumed" is enforced by the model rather than remembered by every consumer. And no percentage is stored, only cents, because percentages are not additive.
The dimensions are straightforward, with one deliberate denormalization:
-- models/marts/dim_wine.sql
-- One row per wine. region_name is copied in from stg_regions so the
-- most common analytical question needs one join, not two. Safe
-- because this table is rebuilt from source on every run.
select
w.wine_id,
w.producer,
w.label,
w.vintage,
(w.vintage / 10) * 10 as vintage_decade,
w.region_id,
r.region_name, -- denormalized on purpose
r.country
from stg_wines w
left join stg_regions r on r.region_id = w.region_id
Here is the payoff. The question the course opened with — which regions appreciate fastest? — against the star:
select
w.region_name,
count(*) as bottles,
sum(f.acquisition_price_cents) / 100.0 as acquisition_value,
sum(f.market_price_cents) / 100.0 as market_value,
round(
100.0 * sum(f.market_price_cents)
/ nullif(sum(f.acquisition_price_cents), 0) - 100.0
, 1) as appreciation_pct
from fct_price_observations f
join dim_wine w on w.wine_id = f.wine_id
where f.valuation_date = date '2026-07-12'
and f.market_price_cents is not null
group by w.region_name
order by appreciation_pct desc
limit 5;
One join. One GROUP BY. The result:
| Region | Bottles | Acquisition value | Market value | Appreciation |
|---|---|---|---|---|
| Northern Rhône | 41,208 | $4,884,000 | $8,209,000 | +68.1% |
| Barolo | 38,412 | $5,120,000 | $8,192,000 | +60.0% |
| Napa Valley | 62,940 | $11,340,000 | $15,309,000 | +35.0% |
| Rioja | 29,116 | $2,180,000 | $2,616,000 | +20.0% |
| Mosel | 18,330 | $1,466,000 | $1,671,000 | +14.0% |
Compare that to the operational path. On Postgres, the same question requires joining bottles → wines → regions and correlating each bottle to the most recent price per wine as of a date, which is a window function over 42 million rows plus a four-table join, and then remembering — every single time, in every analyst's hand-written version — to exclude consumed bottles and soft-deleted cellars. The star answers it in one join because module 4's staging models did the cleaning and this module's fact table did the shaping, once, in a file with a name.
Note the appreciation column: sum(market) / sum(acquisition), not avg(per-bottle appreciation). On this data the difference is not subtle. Northern Rhône's collection includes 11,400 bottles of an inexpensive Crozes-Hermitage that has roughly doubled and 340 bottles of a Hermitage that has appreciated 22%. The average of per-bottle percentages returns +94.6%, weighting a $28 bottle equally with a $6,200 one; the ratio of sums returns +68.1%, which is the actual change in what the region's holdings are worth. Only the second answers the question anyone was asking, and only the second is stable when you slice it differently.
A star schema is not a performance trick. It is a written-down answer to "what does one row mean, and what may be summed?" — the two questions that make an aggregate either true or silently wrong. Physics is why it is shaped this way; grain is why it is trustworthy.
Two weeks before the board meeting, the CEO's dashboard shows 12,400 active cellars. The board deck, assembled by the finance team from their own query, shows 11,900. Both were produced by competent people from the same warehouse on the same afternoon. The meeting that follows will be about which number is right, and it will not resolve, because the question is malformed: both numbers are correct computations of different things, and nobody in the room can say what either one counts.
This module is the heart of the course. Everything before it built the supply chain; this is where you learn that a supply chain without a specification produces two different products from the same inputs and calls both of them by the same name. The mechanics of disagreement are surprisingly finite — three causes, all of them findable — and the structural fix is a layer that most stacks skip.
"Data quality issue" is not a diagnosis. It is what people say when they have not looked. Two numbers computed from the same warehouse differ for exactly three mechanical reasons, and each one leaves a distinctive fingerprint in the size and shape of the gap.
Different filters. One query applies a predicate the other does not. This is the most common cause by a wide margin and produces a gap that is stable in absolute terms: the difference is a fixed set of rows. In the wine-cellar, one path counts all cellars with recent activity and the other adds deleted_at is null. The gap is 500 cellars — 12,400 against 11,900 — and it stays 500 whether you slice by month, by region, or by owner cohort, because it is a population difference rather than a computation difference. Fingerprint: a constant, unexplained offset.
Different grains. One query counts rows at one grain, another at a different one. The gap here is multiplicative rather than additive. A dashboard reporting "wines held" that counts bottles returns 434,000; one that counts distinct wines returns 61,000. The ratio, 7.1, is the average number of bottles per wine — a meaningful number that looks nothing like an error. Fingerprint: a ratio that is stable across slices and happens to equal an average multiplicity.
Different join paths. Same tables, same filters, different route between them — most often an inner join where the other used a left join. Total portfolio value computed through fct → dim_wine → dim_region with inner joins is $182.9M; through the star's intended single hop with a left join it is $186.2M. The missing $3.3M is the value of bottles whose wines — 1,840 of them, imported from a vendor catalogue three years ago and never backfilled — have a null region_id. The inner join dropped them silently. Fingerprint: a gap that appears only in some slices — specifically the ones containing the orphaned rows — and vanishes in others.
| Cause | Wine-cellar example | Gap | Fingerprint |
|---|---|---|---|
| Different filters | deleted_at is null present on one path | 12,400 vs 11,900 | Constant absolute offset across every slice |
| Different grains | Bottles counted vs distinct wines counted | 434,000 vs 61,000 | Stable ratio equal to an average multiplicity |
| Different join paths | Inner vs left join over 1,840 region-less wines | $182.9M vs $186.2M | Gap present in some slices, absent in others |
Learn the fingerprints and the first hour of an investigation collapses into a few minutes. A constant 500 is not a rounding problem, not a refresh timing problem, and not a "data quality issue" — it is a filter, and you can go find it.
Take the real investigation. The gap is 500, constant, and identical in every slice the analyst tries — so by the fingerprints above it is a filter, and the work is now to find which one and where. The technique is a lineage walk: start at each number and move upstream, one artifact at a time, until the paths converge; the divergence is at the last node they do not share.
deleted_at is null and the other does not. That single clause accounts for exactly 500 soft-deleted cellars. The investigation takes minutes when lineage exists and days when it does not — and the answer is never found by looking at the dashboards.The two queries, side by side. The diff is one line:
-- CEO dashboard tile (saved inside the BI tool)
select count(distinct c.cellar_id) as active_cellars
from stg_cellars c
join stg_app_events e on e.cellar_id = c.cellar_id
where e.event_at >= current_date - interval '90 days';
-- → 12,400
-- Finance snippet (pasted into the board-deck spreadsheet)
select count(distinct c.cellar_id) as active_cellars
from stg_cellars c
join stg_app_events e on e.cellar_id = c.cellar_id
where e.event_at >= current_date - interval '90 days'
and c.deleted_at is null; -- ← the entire dispute
-- → 11,900
Five hundred cellars were soft-deleted by their owners and continued generating events for ninety days afterward — which sounds impossible until you look: the deletion sets deleted_at and hides the cellar in the owner's list, but shared read-only links keep working, and each visit emits cellar_viewed. The two numbers are precise answers to two different questions, and the finance number is the one a board should see.
You have run this investigation before in a different vocabulary. When two parties produce conflicting versions of a document, nobody resolves it by comparing the exhibits and picking the more persuasive one; you go to the custodian, the production log, and the metadata to establish which is authoritative and how each was produced. The lineage walk is exactly that move. The dashboards are the exhibits, and arguing about them is the error — the answer is upstream, in the record of how each was made. What is missing in this stack is what a defined-terms clause provides in a contract: a place where "Active Cellar" means one thing that both instruments incorporate by reference.
The tempting resolution is to declare the finance number correct and correct the dashboard. Do that and you have fixed one instance and preserved the machine: the next metric, the next analyst, the next quarter, and the divergence recurs with different numbers. The structural fix is a metrics layer — a place where a metric is defined once, in version control, and every consumer obtains the number by requesting the metric rather than by writing SQL that computes it.
A definition is complete when it specifies five things: the measure (what is counted or summed), the grain (at what level of detail), the filters (which population), the join path (which route through the models), and the owner (who decides when it changes). Missing any one of them leaves room for two implementations to differ.
# metrics/active_cellars.yml
metric: active_cellars
label: Active cellars
owner: growth-analytics # who approves changes
description: >
Cellars that exist (not soft-deleted) and show owner activity in the
trailing 90 days. Counts the customer relationship, not the account
record — a cellar the owner deleted is not active even if shared
read-only links keep generating view events.
measure: count_distinct(cellar_id)
grain: one row per cellar
model: mart_cellar_activity
filters:
- deleted_at is null # excludes 500 soft-deleted
- last_event_at >= current_date - 90 # trailing-window activity
join_path: stg_cellars → stg_app_events (left join on cellar_id)
caveats:
- Event delivery is at-least-once and lossy (ad blockers, offline
sessions), so this is a floor, not a census. Do not reconcile it
to the operational cellar count.
- Trailing 90 days is measured against the run date, so historical
values are not restatable from a snapshot alone.
dimensions: [region, owner_cohort, created_month, plan_tier]
Two properties of that file do the work. First, it is executable: the BI tool, the notebook, the board-deck query, and the AI assistant all resolve active_cellars through it and receive generated SQL, so no consumer is in a position to compute it differently. Second, it is reviewable: it lives in git, changes arrive as pull requests, and the question "who changed what active means, and when?" is answered by git log rather than by asking around.
Note that the caveats section is not decoration. It records that the metric depends on a lossy source and must not be reconciled against the operational count — a fact that would otherwise be rediscovered, at cost, by every new analyst who notices the numbers do not match and opens an investigation.
A definition file is not self-maintaining. Metric governance is the small set of practices that keep it true: an owner, a change process, and a deprecation path.
Ownership means a named team, not "data." The owner of active_cellars is the team that will be asked to defend the number in a board meeting, which is what makes them the right approver for a change to it.
Change review means a definition change is a pull request with an impact statement: which dashboards move, by how much, and whether history is restated or the series breaks at a date. A definition change that silently shifts a reported series is worse than a wrong number, because the wrong number is at least stable enough to notice.
Deprecation means metrics are retired explicitly. When active_cellars is superseded by engaged_cellars, the old definition remains, marked deprecated, resolving to its old value and emitting a warning to consumers — because a metric that disappears takes six dashboards with it and gets reimplemented by hand within a week, which is how you acquire an eighth copy of a definition you were trying to consolidate.
You already know this instrument. A contract's defined-terms section exists because "Confidential Information" appearing in nine clauses must mean one thing, and the way to guarantee that is to define it once and have every clause incorporate the definition by reference rather than restate it. Nobody drafts by pasting the definition into each clause and hoping the copies stay aligned — the profession learned that lesson expensively enough to make it structural.
The parallel is exact, including the failure mode. Change "Active Cellar" and every instrument that uses it changes meaning simultaneously, which is a feature when the definition is centralized and a catastrophe when it is copied. The amendment process is the pull request; the recitals are the caveats section; and a metric with no owner is a defined term with no drafter, which is to say a term that will be interpreted differently by every party who reads it.
Symptom. The definition of a metric exists as SQL inside a BI tool's saved query, and new dashboards are built by duplicating an existing tile and editing it. Six months later there are nine tiles named some variant of "Active Cellars," four distinct filter sets among them, and no way to enumerate which is which without opening each one. The reliable tell: asking "what does this count?" produces a pause, then a click, then someone reading SQL aloud from a modal.
Diagnosis. A copy-paste definition drifts by construction, not by carelessness. Each copy is edited under local pressure — a filter someone needed for their view, a window someone widened for a specific analysis — and no copy can see the others, so there is no moment at which the drift becomes visible to anyone. Meanwhile the BI tool's saved queries are usually outside version control, so there is no diff, no review, and no history: the question "when did this change and who approved it?" has no answer at all.
Corrective. Move the definition into the metrics layer in git and convert the tiles to reference it. Where a tile genuinely needs a variant, that variant becomes its own named metric with its own definition — active_cellars and active_cellars_60d are two metrics, honestly named, rather than one metric with two implementations. And write the governance rule that makes it stick: a number that appears in an external-facing document must resolve through a defined metric. Anything else is someone's query.
The near-miss version is a metric dictionary on a wiki: a beautifully maintained page listing every metric and its definition, which nothing compiles against. It is documentation of intent, and it drifts from the implementation the first time someone changes a query without updating the page — an event with a half-life measured in weeks. Unenforced documentation does not fail loudly; it degrades into a confident, obsolete artifact that new analysts trust. The distinction that matters is not written-versus-unwritten but executed-versus-described: a definition consumers must run through cannot drift from itself.
The stack now works. Data lands, models build, the star answers questions, and a metrics layer says what the numbers mean. What it does not yet have is any capacity to tell you when it has stopped working — and the defining property of an analytics failure is that it renders normally. A broken web page is obviously broken. A dashboard fed by a pipeline that died on Tuesday looks exactly like a dashboard fed by a pipeline that ran forty seconds ago: same layout, same colors, same confident number.
This module is the apparatus that closes that gap: tests that assert content, freshness monitoring that asserts recency, lineage that answers blast-radius and root-cause questions, and orchestration that makes failure stop rather than propagate. If module 6 was about what numbers mean, this is about whether they are still true.
Module 4 established what a test is. The operational question is where to put them, and the answer follows one rule: a test belongs at the boundary where the failure it catches first becomes detectable. Testing earlier is impossible; testing later means the bad data has already propagated into models that will need rebuilding.
That yields two families with genuinely different jobs.
Ingestion boundary tests assert that the source behaved. Volume: the price feed landed 23,000 ± 15% rows, so a nightly file with 900 rows fails loudly instead of quietly halving a series. Schema: the columns and types the contract promised are present, so a vendor's silent addition of a column is a notification rather than a discovery. Distribution: the share of rows carrying each price_source value is within a few points of its trailing average, which catches the vendor switching a supplier without telling anyone. These tests do not know what a cellar is. They know what the pipe should look like.
Mart contract tests assert that the business rules hold. Grain uniqueness on (bottle_id, valuation_date). No bottle consumed before it was acquired. Portfolio value within a plausible band of the prior run. Reconciliation of the cellar count against the operational system to within 0.5%. These tests require domain knowledge and are the ones that catch a wrong meaning rather than a wrong shape.
The GRC frame transfers exactly, and it is worth using deliberately because it imports a vocabulary that is more precise than the one data teams usually reach for. A control has a design — what risk it addresses and what condition would constitute failure — an execution — it runs on a defined trigger, not when someone remembers — and evidence — a retained record that it ran and what it found. A data test that exists but is skipped when the build is slow is a control that is designed and not operating, which any auditor will tell you is worth nothing at all and is arguably worse than its absence, because it produces the appearance of coverage.
The frame also brings the right question for pruning a bloated suite: what risk does this control address, and how would that risk otherwise be detected? Four hundred and eighty not-null assertions on columns that cannot be null address no risk. One reconciliation against the operational count addresses the risk that quietly cost the team a disputed board deck. Coverage is not a count of controls; it is a map of risks to controls, with the gaps visible.
Every other failure mode in this course announces itself somehow. A broken model fails a build. A wrong join produces a number someone eventually questions. Staleness announces nothing, because stale data renders identically to fresh data. The chart draws, the total displays, the sparkline slopes upward — and the entire picture is six days old.
What makes this more than an annoyance is that stale data is often plausible. A portfolio value that has not updated since Tuesday still looks like a portfolio value; it is in the right range, moves in the right direction historically, and passes every intuition an executive can apply in the eight seconds they look at it. The failure is only detectable by comparison against a clock, and no dashboard shows a clock unless someone built one.
Freshness is therefore a per-consumer SLA, not a global property, because different consumers genuinely need different things and pretending otherwise produces either wasted engineering or false confidence:
| Consumer | Freshness SLA | Why | On breach |
|---|---|---|---|
| Support tool (customer portfolio value) | 4 hours | An agent quoting a stale figure to a customer on the phone creates a written commitment about a number that has moved | Page on-call; surface a banner in the tool |
| Executive dashboard | 24 hours | Daily decision cadence; yesterday's numbers are fine, last week's are not | Alert the data team; show data age on the tile |
| Board deck source | 7 days, hard-pinned | Assembled once a quarter and must be reproducible after the fact | Block the extract; a stale board number is an outward-facing statement |
| Model training set | 30 days | Retrained monthly; recency matters less than completeness | Warn only |
Symptom. In March, the price feed's vendor rotated an API credential and the nightly batch began failing. The failure was logged to a channel nobody watched. Every dashboard continued to render, and portfolio values continued to look reasonable because wine prices move slowly. Six days later a customer emailed support asking why their valuation had not changed since Tuesday. In the interim, a pricing decision was made in a meeting using those numbers.
Diagnosis. Two separate absences. The pipeline had no freshness monitoring, so nothing was watching the clock. And the dashboard had no way to display data age, so the human looking at it — who would absolutely have noticed "as of six days ago" — was given no signal to notice. The second absence is the more damaging, because the first one only needed one person to be paying attention and the second denied that possibility to everyone.
Corrective. Two changes, both small. Every dashboard displays its data's age as a first-class element, not in a tooltip and not on a settings page — "as of 2026-07-18 06:14 · 3h ago" beside the title — and turns amber past its SLA. And every pipeline runs a freshness assertion as a test, so max(observed_date) falling behind its threshold fails the build and pages someone. Note which one is a data-engineering fix and which is a design fix; teams reliably build the first and skip the second, and the second is what saved the six days.
Lineage is the provenance graph of the whole stack: which source columns feed which staging models feed which marts feed which metrics feed which dashboards. It is generated from the model DAG rather than maintained by hand, which is the property that makes it trustworthy — a hand-drawn lineage diagram is a description, and module 6 established what happens to descriptions.
Lineage answers two questions, and they run in opposite directions.
Forward, before a change: blast radius. An application engineer proposes dropping cellars.name from production Postgres. Without lineage, the honest answer is "probably fine" and the actual answer arrives as an incident. With lineage, the query takes seconds and returns: raw_pg.cellars.name → stg_cellars.cellar_name → dim_cellar.cellar_name → mart_cellar_portfolio → 4 dashboards, 1 metric dimension, and the support tool's customer view. Now the conversation is a schedule rather than a surprise, and the engineer gets a real answer instead of a shrug.
Backward, after a number goes wrong: root cause. This is the walk you already took in Figure 6.1. Starting from the disagreeing numbers and moving upstream until the paths converge is a lineage traversal, and it is fast in proportion to how much of the graph is machine-generated. In a stack where three of the nodes are saved queries inside a BI tool and one is a spreadsheet, the walk stalls at exactly those nodes — which is the practical argument for the metrics layer restated as an operational one.
Column-level lineage is worth the extra cost over table-level. Table-level tells you dim_cellar is affected; column-level tells you only consumers of cellar_name are affected and the eleven dashboards using cellar_id are not. That difference is what turns a change from a broad freeze into a targeted one.
The parallel that makes lineage click: it is a chain of custody for numbers. An evidentiary chain establishes that this item is the item collected, that every transfer is recorded, and that nobody had an unlogged opportunity to alter it. Lineage establishes the same three things for a figure on a slide — this number came from that mart, which came from these staging models, which came from that source, and every transformation between them is in version control with an author and a date. And the failure mode is identical: one undocumented hop breaks the chain, and everything downstream of it becomes an assertion rather than a demonstration. In both settings, the person who has to defend the number under adversarial questioning is the one who cares most about the gap.
The pipeline is a directed acyclic graph whose edges are the reference relationships module 4 established. Orchestration executes that graph on a schedule, and the single most important thing it does is not scheduling — it is failure semantics.
When stg_market_prices fails, its descendants must not run. Not run-and-warn, not run-with-yesterday's-data: not run. This feels harsh the first time a dashboard goes blank at 8 a.m., and it is the correct behavior, because the alternative is serving a portfolio value computed from a partial price load — a number that is wrong, plausible, and indistinguishable from a right one. A blank tile prompts a question; a wrong number prompts a decision.
stg_market_prices fails and its entire descendant subtree is skipped — the portfolio dashboard shows a blank tile and a build-failed notice rather than a plausible wrong number. Branches that do not depend on prices complete normally. After the fix, the backfill re-runs only the affected models for only the affected date range, and re-running it twice is safe because each partition is replaced rather than appended to.A backfill is recomputing history: the seven days of prices that never landed, or five years of a metric whose definition just changed. Backfills are ordinary and frequent, and they are where the pipeline's most expensive bug lives.
Consider a model materialized incrementally as insert into fct_price_observations select … where valuation_date = :run_date. It works perfectly every night. Run it twice for the same date — because a job was retried, because an operator re-ran a failed DAG, because a backfill overlapped a scheduled run — and that date now has every row twice. Portfolio value for that day doubles. No error, no constraint violation, no test failure unless someone wrote the grain uniqueness assertion.
Symptom. A single day in a time series is anomalously high — often exactly double, though partial overlaps produce arbitrary multiples that are harder to spot. It correlates with a day something went wrong operationally, which sends everyone hunting for a data problem on a day that had one, and the actual cause is the remediation rather than the incident.
Diagnosis. The model appends. Appending is not repeatable: running it once and running it twice produce different states, so "just re-run it" — the most natural operational response in existence — is a data-corrupting action. The pipeline has made retrying unsafe, and then handed the retry button to an operator at 3 a.m.
Corrective. Make every run converge. Delete-and-insert by partition is the workhorse: the run deletes the target partition and writes it fresh, so the state after N runs equals the state after one. Full refresh works for small models. Merge-on-key works when partitions are awkward. Whichever you choose, the property to assert is the same, and it should be tested rather than assumed — run the model twice in CI and check the row count is unchanged.
-- NOT idempotent: second run doubles the partition
insert into fct_price_observations
select ... where valuation_date = :run_date;
-- Idempotent: N runs converge to the same state
delete from fct_price_observations
where valuation_date = :run_date;
insert into fct_price_observations
select ... where valuation_date = :run_date;
-- (wrapped in one transaction, so a crash between the two
-- statements cannot leave the partition empty)This is the same argument as idempotency keys for API retries, moved from one request to one partition. There, the client cannot tell whether a timed-out POST executed, so the server makes the resend safe by recognizing it. Here, the operator cannot always tell whether a partially-failed run wrote its rows, so the model makes the rerun safe by replacing rather than appending. Both cases share the structure worth memorizing: you do not make the failure impossible, you make the response to it safe. A pipeline you cannot re-run confidently is a pipeline whose failures compound, because every remediation carries the risk of a second, quieter failure.
The far right of Figure 2.3, and the two subjects that determine whether any of the preceding seven modules survive contact with an organization: who consumes the numbers, and what the whole apparatus costs.
Both are places where good stacks fail. Consumption fails when access is confused with capability — when "anyone can query anything" is treated as the goal rather than the last step of a sequence. Cost fails quietly, in the gap between a query that returns in nine seconds and an invoice that arrives in thirty days, with nothing in between to connect them. This module closes both, then consolidates every anti-pattern the course has named into one field guide you can hold against a real stack.
BI tools consume marts and metrics and render them. The good ones are genuinely valuable and mostly interchangeable; the interesting question is not which tool but what has to be true before any of them helps.
Self-serve analytics — anyone in the company answering their own questions without filing a ticket — is a real and achievable outcome. It works exactly when the semantic work is already done: governed marts with declared grain, defined metrics that resolve one way, and dimensions that mean what their names say. Under those conditions a marketing manager slicing active_cellars by acquisition channel gets a correct number, because the definition is executed for them and the only thing they contributed was the question.
Absent those conditions, self-serve does not fail quietly. It manufactures disagreement at scale. Give forty people SQL access to raw and staging tables and you have not distributed insight — you have created forty opportunities for module 6's divergence, each one authored in good faith, each one defensible on its own terms, and now arriving in meetings simultaneously.
Symptom. A BI tool rollout is announced as the fix for a data-team ticket backlog. Six months later: 400 dashboards, of which 10 are viewed weekly; four different "active cellars" numbers circulating; and a data team whose ticket queue has been replaced by a slower, more corrosive queue of "why does my number not match yours?" investigations. The tell is that questions to the data team have shifted from requests to disputes.
Diagnosis. Access was granted where modeling was needed. The backlog was never a queue of query-writing tasks; it was a queue of semantic decisions — what counts, at what grain, joined how — and distributing SQL access distributes those decisions to people who lack the context to make them and no mechanism to record the ones they do make. Every new dashboard is a private, undocumented definition.
Corrective. Sequence it: model first, then open access. Concretely — identify the ten questions that actually recur, build governed marts and metric definitions for them, expose those through the tool, and only then widen access. Retire dashboards on a viewing threshold, since an unviewed dashboard is not neutral: it is a wrong number waiting to be cited by someone who found it in search. And route novel questions to analysts rather than to a self-serve interface, because the bottom-right quadrant is exactly where a non-expert's confident wrong answer is produced.
Dashboards are the most visible consumer and not the most demanding one. Two others are worth naming, because both assume this course's machinery was built correctly and neither can verify it.
Experimentation (Guide Nº 10) consumes assignment data and outcome data at declared grains. Every statistical method in that guide — power calculations, variance estimation, sequential testing — assumes its inputs are correct in ways it cannot check: that one row per user per experiment means exactly that, that the outcome metric means the same thing in treatment and control, that late-arriving events are handled identically on both arms. A grain violation in the assignment table does not make an experiment noisy; it makes it wrong in a direction nobody will notice, because the statistics remain internally consistent and simply describe a population that does not exist. The uncomfortable relationship is that the more sophisticated the method, the more thoroughly it launders a bad input into a confident conclusion.
AI features consume governed metrics and modeled tables. Point a natural-language assistant at raw tables and you have rebuilt module 6's disagreement machine with two aggravating properties: the assistant will infer a definition — it has to, since the question requires one and the tables do not supply one — and it will state the result in fluent, unhedged prose. A human analyst who guesses at a definition usually signals the guess; a language model's output carries no such tell, and the reader has no artifact to inspect. This makes governance matter more for AI consumers, not less: an assistant that resolves active_cellars through the metrics layer returns 11,900 and can cite the definition; one composing SQL against staging tables returns whichever of 12,400, 11,900, or 10,700 its inference happened to reach, with equal confidence in all three cases.
Machine-learning feature pipelines — feature stores, training/serving skew, point-in-time correctness — are a genuinely distinct discipline and belong to Guide Nº 13. They share this course's plumbing and add requirements it does not cover, most importantly that a feature must be computed as of a past moment without leaking information from after it. That constraint changes modeling decisions in ways worth learning separately rather than approximating here.
Warehouse pricing comes in two common shapes: bytes scanned — you pay per byte the query reads — or compute-seconds — you pay for cluster size × time. They differ in detail and agree on the thing that matters: cost tracks data volume touched, not result size. A query returning twelve rows can cost ninety dollars.
That single decoupling is the origin of nearly every surprise invoice, because every feedback signal a developer receives is about latency. The query returns in nine seconds; nine seconds feels fine; nothing in the interface mentions that it scanned 18.4 TB. The invoice arrives thirty days later as an aggregate with no attribution. Contrast that with the operational database of Guide Nº 06, where an expensive query announces itself immediately by making the application slow — the warehouse removed that feedback loop along with the contention.
Work the wine-cellar's example. The question is "weekly active cellars by region, last quarter," refreshed hourly, at $5 per terabyte scanned.
Three levers, in order of leverage. Partition pruning — physically organizing a table by date and filtering on that column so the engine skips segments entirely — is the largest and the least effort. Column selection is free and forgotten: select * in a warehouse is not a convenience, it is a bill. Pre-aggregation converts a per-query cost into a fixed nightly one, which is the right trade whenever a query runs more often than its inputs change; the hourly refresh here reads data that updates once a day, so 23 of every 24 runs recompute an unchanged answer.
And the two things that feel like fixes and are not. A bigger cluster buys latency, not money — module 2's arithmetic. Adding LIMIT 100 changes nothing at all: the scan happens first and the limit discards rows afterward, so the bytes are already paid for. That second one catches almost everyone once.
Symptom. A monthly bill that grows faster than data volume or headcount, with no one able to attribute the growth to specific queries. Investigation eventually finds a handful of scheduled queries — often a dashboard someone set to refresh every fifteen minutes for a demo two years ago — responsible for most of it.
Diagnosis. No feedback loop connects the person writing a query to the cost of running it. The developer sees latency; the invoice shows an aggregate; the two are never joined. This is a monitoring absence, not a discipline failure, and treating it as the latter — asking people to write cheaper SQL without showing them what anything costs — reliably fails.
Corrective. Attribute and surface. Tag queries by consumer so spend can be broken down by dashboard, model, and team; put the top twenty queries by bytes scanned on a weekly report that the data team actually reads; set per-query and per-cluster limits so a runaway cross join fails at a threshold instead of running to completion. And review refresh schedules against the underlying data's update cadence at least once a quarter — a nightly-loaded mart behind an hourly dashboard is 23 wasted runs a day, and every stack accumulates several of them.
Nine anti-patterns, consolidated. Each is named, given the symptom you will actually observe, the underlying diagnosis, and the corrective — with the module that teaches it.
| Anti-pattern | Symptom you observe | Corrective | Module |
|---|---|---|---|
| BI pointed at production | Application p99 degrades on a schedule; application traffic is flat across the window | Move the workload to a columnar warehouse; a read replica removes contention only, not layout or semantics | m1 |
| ETL habits in an ELT world | A definition change cannot be applied to history; the source's retention window has passed | Land the source at native grain, unmodified; aggregate in a versioned model downstream | m3 |
| The untested monolith | An hour's change is quoted at a week; one person understands the file and they are on leave | Decompose into layered models with byte-identical SQL, assert output equality, then test each boundary | m4 |
| Undeclared or mixed grain | Totals too large by an inconsistent factor; distinct appearing in places it does not belong | One grain per fact table, declared in a sentence and enforced by a uniqueness test on its key | m5 |
| Definitions in dashboard tools | Nine tiles with variants of one name; "what does this count?" answered by reading SQL aloud from a modal | Definitions move to a metrics layer in git; genuine variants become separate named metrics | m6 |
| Dashboards without freshness | A decision made on numbers that stopped updating days ago; nobody could have known | Freshness assertion that fails the build, plus a data-age indicator on every surface | m7 |
| Non-idempotent pipelines | One day of a series is a clean multiple of its neighbors, correlated with an operational incident | Delete-and-insert by partition in one transaction; prove it by running twice in CI | m7 |
| Self-serve as a modeling substitute | 400 dashboards, 10 viewed; the data team's queue shifts from requests to disputes | Model the recurring questions first, then open access; retire dashboards on a viewing threshold | m8 |
| Cost ignored until the invoice | Bill grows faster than data or headcount; growth cannot be attributed to anything specific | Tag and attribute spend, surface the top queries by bytes scanned weekly, set per-query limits | m8 |
Read the symptom column on its own and notice the shared property: not one of these announces itself as a data problem. They appear as a slow checkout page, a slow team, an argument in a meeting, a surprising invoice, a customer email. That is the practical reason the supply-chain frame is worth carrying around — the failures surface at the consumption end and originate somewhere upstream, and knowing the chain is what lets you walk from one to the other.
Every number on a dashboard is the end of a supply chain. It came from a source that was extracted one way rather than another, landed in a raw layer that either kept the evidence or discarded it, passed through staging models that either preserved the source's meaning or quietly encoded someone's definition, joined into a fact table at a grain that either supports the aggregate or silently breaks it, filtered by a metric definition that either exists in one governed place or in nine private copies, refreshed by a pipeline that either fails loudly or serves you Tuesday's numbers today.
Each of those is a decision a person made, each is recorded in an artifact you can open, and the walk from the number back to the decision is the skill this course exists to give you. "What does this metric actually count?" is not an unanswerable question. It is a lineage query with a finite answer — and now you know where to run it.
This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.