Field guide · Nº 20

Where the Data Goes Next

A field guide to the analytics stack

Between your production database and the chart the CEO reads lies a chain of extractions, models, tests, and definitions — each one a decision somebody made. This guide walks that chain end to end on a real schema, with real SQL at every layer. When two dashboards disagree, you will know the answer is not in the dashboards — it is upstream, and you will know how to find it.

Module 01 Two workloads, one table

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.

Two kinds of questions

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.

Two-panel comparison: the wine-cellar OLTP database serving many tiny indexed transactions versus one analytical scan across five years of prices, with the damage each inflicts on the other annotatedOLTP — the application asksOLAP — the business asks~4,000 requests/min · each touches 1–40 rows~30 queries/day · each touches 42,000,000 rowsGET /cellars/8812INSERT bottlesUPDATE consumed_atSELECT r.name, avg(p.price_cents)FROM market_prices p JOIN wines w …WHERE observed_at > now() - 5 yearsGROUP BY r.nameIndex seek → 3 pages fetched4 ms · returns 38 rows · row layout is idealwhole record arrives in one fetchSequential scan → 6.72 GB read94 s · returns 12 rows · row layout is hostileneeds 1 column, pays for all 5Run both on one database and each damages the otherscan evicts the OLTP working set from cache;long snapshot holds back vacuum → p99 climbswriter traffic steals the I/O the scan needs;row layout makes the scan slow regardless
Figure 1.1 — Same tables, opposite access patterns. The application arrives at three pages through an index and leaves; the analytical query must visit all 42 million rows of 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.

What happens when BI points at prod

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.

Curve chart of p99 checkout latency across a morning, spiking from 40 milliseconds to 610 milliseconds during the shaded 9 a.m. dashboard refresh window09:00–09:18 dashboard refresh0200400600p99 latency (ms)08:2009:0009:1810:20SLO: 80 ms610 ms peak — 15× baselinecache evicted, vacuum blocked by long snapshotApplication traffic is flat across this window. The only change is a scheduled dashboard refresh.
Figure 1.3 — The collateral damage, on a timetable. Checkout p99 sits at 40 ms until the 9 a.m. dashboard refresh issues its five-year scan, then peaks at 610 ms and recovers when the scan completes. Application request volume is unchanged throughout — which is exactly why the on-call engineer looking at application metrics cannot find the cause.

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 read replica is a half-fix

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 load-bearing idea

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.

The physics: rows versus columns

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.

Byte-level comparison of the market_prices table stored row-wise in pages versus stored as columnar segments, with the read path of an average-price aggregate highlighted in each and the bytes-touched totals shownQuery: SELECT avg(price_cents) FROM market_prices — 42,000,000 rows, 160 bytes/rowRow storage — records packed into pagesidwine_idsourceobserved_atpricered = bytes the scan must read · blue = bytes it wantedPage fetch is atomic: reaching one price means reading the whole row around it.Bytes touched: 6.72 GB — of which 336 MB is useful (5%).Columnar storage — one segment per columnidwine_idsourceobs_atpriceonly the blue segment is openedSegments are independent files; unread columns cost nothing.Bytes touched: 336 MB — before compression.The arithmetic, in fullrow-wise: 42,000,000 × 160 B = 6.72 GB readcolumnar: 42,000,000 × 8 B = 336 MB read → 20× from layout+ compression: 336 MB ÷ 2 (delta+RLE) = 168 MB read → 40× totalThe ratio is a property of the query's column selectivity: a query needing all five columns gets no layout win at all.
Figure 1.2 — Where the bytes actually go. Row-wise, the eight bytes of 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.

Compression, the compounding bonus

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.

ColumnRaw size (42M rows)EncodingEncoded
source1,008 MBDictionary (4 values)11 MB
observed_at336 MBDelta + run-length9 MB
wine_id336 MBDelta, sorted runs42 MB
price_cents336 MBDelta, high variance168 MB
The split is symmetric, not a ranking

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.

Where this goes next

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 02 A database built inside-out

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.

Separation of storage and compute

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.

The metadata plane is the quiet third piece

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.

Elasticity and isolation

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.

Layered diagram showing one copy of data in object storage beneath three independently sized compute clusters, bound by a catalog and metadata planeIngestion + transformlarge · 11 min nightlyidle 23.8 h/day → $0BI / dashboardssmall · 4 s per refreshlatency-sensitiveAd-hoc / data sciencemedium · auto-suspend 5 min40-min cross join runs hereCatalog / metadata planetable → files · partitions · column statistics · transactional commitsObject storage — one copy of the data, columnar segmentsraw · staging · marts — 4.1 TB, billed per GB-month whether or not anyone reads itimmutable segments: three readers create zero contentionCompute is stateless and disposable; the only durable thing in this picture is the bottom two layers.
Figure 2.1 — One copy of the data, three independent engines. Each cluster is sized for its own workload and billed only while running, so the nightly rebuild's large cluster costs nothing at noon. Because the shared layer is immutable object storage rather than a shared buffer cache and lock manager, the ad-hoc cluster's runaway query cannot degrade the dashboards — the isolation that module 1 showed a single database cannot provide.
Elastic does not mean free

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.

The lakehouse, honestly

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.

Side-by-side layered stacks comparing an integrated warehouse, where storage format and engine ship as one product, against a lakehouse of object storage plus open table format plus interchangeable engines, each annotated with what it buys and what it costsIntegrated warehouseLakehouseOne vendor's SQL engineProprietary storage format + catalogObject storage (managed for you)Buysone product, one bill, one support contracttuning, statistics, upgrades handled for youCostsdata readable only through that enginemigration means re-exporting everythingInterchangeable engines (SQL · Spark · ML)Open table format + open catalogYour object storage, your filesBuysmany engines on one copy; no vendor lock on bytesany data type beside the tablesCostsyou assemble and operate the piecesgovernance and tuning are yours to build
Figure 2.2 — The same three layers, bundled or unbundled. Both architectures put columnar files in object storage under a catalog under an engine; they differ in whether those layers ship as one product or as replaceable parts. The lakehouse trades operational surface for engine independence and data-type breadth — a good trade at petabyte scale or with genuinely heterogeneous data, and a self-inflicted project below that.

The map of the whole stack

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.

End-to-end stack map: operational Postgres via change data capture and an event stream feed ingestion, which lands the raw layer, transformed through staging into marts, surfaced through a metrics layer to dashboards and other consumers, each segment labeled with the module that teaches itSOURCESPostgrescellars · bottles · winesEvent streamapp instrumentationPrice feed APInightly batchIngestionModule 3WAREHOUSERawimmutable evidenceStagingModule 4Marts — Module 5Metrics layerModule 6CONSUMERSDashboardsModule 8ExperimentsGuide Nº 10AI featuresgoverned onlyOrchestration — schedules every arrow, blocks descendants on failure (Module 7)quality tests · freshness monitoring · lineage span the whole chainRead left to right for how data flows. Read right to left for how you debug a wrong number — the walk module 6 takes.Every segment is a decision someone made. The dashboard is the last one, and the least consequential.
Figure 2.3 — The stack, end to end. Three sources reach a warehouse through ingestion; raw becomes staging becomes marts; a metrics layer defines what the numbers mean; consumers read only through it. Orchestration schedules every arrow and tests span the chain. This is the guide's master map — modules 3 through 8 each zoom into one segment, and module 7 redraws it once with its trust apparatus attached.
The load-bearing idea

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.

Module 03 Getting the data in

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.

Batch extracts: the nightly photograph

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.

The clock skew you did not think about

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.

Change data capture: subscribing to the ledger

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.

Swimlane sequence showing an insert, a soft-delete update, and a hard delete on the cellars table flowing from the Postgres write-ahead log through a change data capture connector into the warehouse raw table as ordered change eventsPostgres WALCDC connectorraw_pg.cellarsLSN 0/4A2FINSERT cellars id=8812op=I after={id:8812, deleted_at:null}LSN 0/4B77UPDATE set deleted_atop=U after={deleted_at:2026-03-04}a soft delete is just another UPDATE — nothing marks it as removalLSN 0/4C01DELETE cellars id=7104op=D before={id:7104}the event a batch extract can never seeRaw is append-only: three events, three rows, ordered by LSN. Nothing is overwritten.
Figure 3.1 — Every change, in order, including the ones that erase things. The connector emits one append-only row per change with its log position, so the warehouse holds the full history rather than a final state. Two details matter downstream: the hard delete arrives as an explicit 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.

Chain of custody, again

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.

Event and instrumentation 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.

Three ingestion lanes converging on the warehouse raw layer: nightly batch from the price feed API, change data capture from Postgres, and near-real-time instrumentation events, each labeled with cadence, latency, and how it handles deletesBatch — price feed APInightly 02:00 · latency ~24 hdeletes: n/a, source is append-onlyCDC — operational Postgrescontinuous · latency ~90 sdeletes: explicit op=D eventsEvents — app instrumentationcontinuous · latency ~30 sdeletes: none — events are factsRaw layerappend-onlynever edited in placeretained indefinitelyraw_api.market_prices42,000,000 rows · 5 yearsone file per night, replayableraw_pg.cellars / bottles / winesone row per change · _cdc_op, _cdc_lsnfull history of every stateraw_events.app_eventscellar_viewed · bottle_addedvaluation_checked · at-least-onceThree sources, three delivery guarantees, one landing zone — and three different sets of quality tests, because the failure modes do not overlap.
Figure 3.3 — Three lanes into one landing zone. This is Figure 2.3's ingestion segment enlarged. The lanes differ in cadence, latency, and — most consequentially — in what they can say about removal: batch cannot see a delete, CDC reports it explicitly, and events have no concept of one because an event that happened cannot un-happen. Choosing a lane is choosing which of those guarantees the rest of your stack will be built on.

ETL becomes ELT: why the transform moved

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.

Two-panel flow comparing ETL, where transformation happens before loading and only the result is stored, with ELT, where raw data lands first and transformation runs as SQL inside the warehouse — annotated with what each preserves and what a transform bug costsETL — transform before the warehouseSourceTransformseparate machineWarehouseresult onlyPreserved: the aggregate onlyDefinition changes in March 2026?History before it cannot be recomputed —the inputs were never stored anywhere.ELT — land first, transform in placeSourceRawkept foreverModelsSQL, in gitPreserved: the source record, indefinitelyDefinition changes in March 2026?Re-run the model over five years of raw —corrected history in one afternoon.What a transform bug costs, in each worldETL: discovered Tuesday, present since January. Fix requires re-extracting nine months from the source —if the source still has it. Vendor API retains 90 days. Six months of history is now permanently wrong.ELT: discovered Tuesday, present since January. Fix the SQL, re-run the model over retained raw,history is corrected end to end. Cost: one afternoon and some compute-seconds.
Figure 3.2 — The asymmetry is about what you kept. Both orders produce the same table on a good day. They diverge the moment a transformation turns out to be wrong or a definition changes, because ETL discarded the inputs and ELT retained them. The raw layer is not a staging convenience; it is the only thing that makes history correctable.
Anti-pattern: ETL habits in an ELT world

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.

Module 04 Transformation as software

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.

Raw, staging, marts: the layering discipline

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.

Layered diagram of the raw, staging, and mart layers with each layer's single rule, naming convention, and contract to the layer aboveRAWRule: immutable. Ingestion writes; nothing edits, ever.raw_pg.* · raw_api.* · raw_events.* — contract: fidelity, not qualityduplicates, nulls, wrong types:all correct behavior hereSTAGINGRule: rename, cast, deduplicate. One model per source table. No joins, no business logic.stg_cellars · stg_bottles · stg_wines · stg_market_prices — contract: one clean row per entitya filter here is a hiddendefinition everyone inheritsMARTSRule: join and aggregate for consumption. Business logic lives here — and only here.fct_price_observations · dim_wine · dim_cellar · dim_region · dim_date — contract: one written definitionconsumers read only from this layer ↑
Figure 4.1 — Three layers, one rule each. The value of the discipline is predictability: given a new piece of logic, every engineer on the team places it in the same layer. Filters that encode a definition — which cellars count as active — belong in marts, because a filter in staging is a business decision applied invisibly to every consumer downstream.
The load-bearing idea

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.

SQL under version control

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.

Guide Nº 18, applied verbatim

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.

Tests as the analytics contract

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.

Anti-pattern: the thousand-line script nobody dares touch

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.

Two-panel comparison of the same transformation logic: one opaque thousand-line script with no tests, no history and a single owner, versus the same logic decomposed into small layered models with tests at every edge and version historyOne scriptportfolio_report.sql1,100 lines · 9 nested CTEsno intermediate outputsno tests at any edgeno version historyone owner, currently on leavewrong output → which of 9?one-hour change → quoted at a weekNew requirement? Append a tenth CTE — modifyingan existing one is unbounded risk. The next reportduplicates 60% of this file and drifts from it.decomposeSQL keptbyte-identicalSeven modelsstg_cellarsstg_bottlesstg_winesstg_market_pricesint_latest_priceone row per winemart_cellar_portfolio✓ tests✓ tests✓ tests at every edgeSame logic, different propertiesA failure names the model that produced it · each piece is reusable by the next reportgit log answers who changed what and when · the refactor is provably behavior-preservingand it can be paused halfway without leaving anything broken
Figure 4.2 — The refactor, drawn. Nothing about the SQL changes: each CTE is lifted into its own model with its text intact, so the composed output can be asserted equal to the original. What changes is everything around it — intermediate results become inspectable, edges become testable, history becomes queryable, and a wrong number at the end names the stage that produced it instead of pointing at 1,100 undifferentiated lines.
Test theater

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.

The wine-cellar models, for real

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"
What the shape of these models tells you

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.

Module 05 Modeling for questions

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.

Facts and dimensions

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.

Guide Nº 06, argued both directions

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.

Grain: the decision that rules them all

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.

Anti-pattern: no declared grain, or mixed grains

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.

Side-by-side tables showing the same price data at bottle-day grain and at a wine-month rollup grain, with one portfolio-value query correct on the first and silently double-counting on the secondGrain A — one row per unconsumed bottle per valuation datebottle_id wine_id val_date market_cents40213 118 2026-07-12 42,00040214 118 2026-07-12 42,00040215 118 2026-07-12 42,000sum = 126,000 cents — three bottles, three rows, correctGrain B — bottle-day rows plus wine-month rollupsbottle_id wine_id val_date market_cents40213 118 2026-07-12 42,00040214 118 2026-07-12 42,00040215 118 2026-07-12 42,000NULL 118 2026-07-01 126,000 ← rollupsum = 252,000 cents — exactly doubleevery bottle counted once as itself, once inside the rollupWhy nothing catches thisTypes are valid · no nulls where nulls are forbidden · no constraint violated · every structural test passes.Grain is a fact about meaning, not about schema — a database cannot enforce what a row represents.The one test that catches it: unique(bottle_id, valuation_date) — which the NULL rollup row breaks immediately.
Figure 5.2 — The same data, one grain apart. Adding pre-computed rollup rows to a bottle-day fact table doubles every sum, and nothing in the schema objects because grain is semantic rather than structural. The corrective is a uniqueness test on the grain's key columns, which turns a silent doubling into a failed build.

From the operational schema to the star

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.

The wine-cellar star schema: a price-observation fact table with its grain declared in the header, surrounded by wine, cellar, region, and date dimensions, with an inset of the normalized operational schema it derives from and arrows showing each derivationGuide Nº 06 — normalizedregions12 rowswines61,000cellars15,200bottles434,000market_prices42,000,000"average price by region"= 3 tables, 2 joins, one ofthem 42M × 61Kderivesfct_price_observationsGRAIN: one row per unconsumedbottle per valuation datebottle_id · wine_id · cellar_id · date_keymarket_price_centsacquisition_price_centsunrealized_gain_centsdim_wineproducer · label · vintageregion_name ← denormalized61,000 rowsdim_cellarcellar_name · owner_idcreated_date · deleted_date15,200 rowsdim_regionregion_name · country12 rowsdim_datedate · month · quarter · yearis_month_end · 3,650 rows"average price by region"= 1 join, fact → dim_wineSolid edges are the join paths a query uses. The dashed edge to dim_region exists for region attributesbeyond the name; region_name itself is already on dim_wine, so the common question never traverses it.Every dimension is one hop from the fact. No dimension joins to another — that is what makes it a star rather than a snowflake.
Figure 5.1 — The wine-cellar star, and the schema it came from. The inset is Guide Nº 06's normalized design, where the common analytical question crosses three tables. In the star, the grain is declared in the fact table's header, measures are all additive at that grain, and 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

Answering the question

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:

RegionBottlesAcquisition valueMarket valueAppreciation
Northern Rhône41,208$4,884,000$8,209,000+68.1%
Barolo38,412$5,120,000$8,192,000+60.0%
Napa Valley62,940$11,340,000$15,309,000+35.0%
Rioja29,116$2,180,000$2,616,000+20.0%
Mosel18,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.

Why the ratio is computed, not averaged

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.

The load-bearing idea

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.

Module 06 One metric, one definition

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.

Why two dashboards disagree

"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.

CauseWine-cellar exampleGapFingerprint
Different filtersdeleted_at is null present on one path12,400 vs 11,900Constant absolute offset across every slice
Different grainsBottles counted vs distinct wines counted434,000 vs 61,000Stable ratio equal to an average multiplicity
Different join pathsInner vs left join over 1,840 region-less wines$182.9M vs $186.2MGap 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.

The post-mortem: 12,400 versus 11,900

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.

Lineage trace walked right to left from two disagreeing dashboard numbers, 12,400 and 11,900 active cellars, back through their separate queries and models to the single divergent soft-delete filter node, and forward to the shared source they both derive from← walk this direction to debug · data flows this direction →CEO dashboard12,400Board deck11,900BI tile: "Active cellars"saved query, in the BI toolFinance SQL snippetpasted in a spreadsheetpredicate applied:event in last 90 days(no delete filter)predicate applied:event in last 90 daysAND deleted_at IS NULL↑ THE DIVERGENCE ↓one clause, present on one path — worth 500 cellarsstg_cellarsdeleted_at kept,not filteredBoth paths share every node up to the divergence. Neither number is a computation error.The dispute is not about arithmetic — it is that "active cellar" was never defined anywhere both paths could read it.
Figure 6.1 — The whole post-mortem in one walk. Starting from each number and moving upstream, the two paths are identical until the node where one applies 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.

A records dispute, resolved by provenance

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 metrics layer

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.

Before and after panels: three consumers each holding a private copy of a metric definition with two of them drifted, versus one governed definition that all four consumers resolve throughBefore — every consumer carries its own copyCEO dashboardno delete filter12,400Board deckdelete filter present11,900Growth notebook60-day window10,700martsno definition hereThree copies, three answers. Each drifted independently;none is wrong on its own terms; no copy knows the others exist.After — one definition, resolved by allactive_cellars.ymlmeasure · grain · filtersjoin path · owner · caveatsCEO dashboard11,900Board deck11,900Growth notebook11,900AI assistant11,900Consumers request the metric; SQL is generated. Divergence isnot corrected — it is made structurally unavailable.
Figure 6.2 — Correcting an instance versus removing the possibility. On the left, three consumers hold private copies that drifted independently; none is wrong on its own terms and none knows the others exist. On the right, all four resolve the same governed definition and receive generated SQL, so the only way to change the number is to change the definition — in a pull request, with an owner.

Metric governance

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.

The defined-terms clause

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.

Anti-pattern: definitions living in dashboard tools

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.

Governance theater

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.

Module 07 Trusting the pipeline

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.

Quality tests at the right boundaries

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.

Data tests are controls

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.

Freshness: the silent failure

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:

ConsumerFreshness SLAWhyOn breach
Support tool (customer portfolio value)4 hoursAn agent quoting a stale figure to a customer on the phone creates a written commitment about a number that has movedPage on-call; surface a banner in the tool
Executive dashboard24 hoursDaily decision cadence; yesterday's numbers are fine, last week's are notAlert the data team; show data age on the tile
Board deck source7 days, hard-pinnedAssembled once a quarter and must be reproducible after the factBlock the extract; a stale board number is an outward-facing statement
Model training set30 daysRetrained monthly; recency matters less than completenessWarn only
Anti-pattern: dashboards without freshness indicators

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

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.

Chain of custody

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.

Orchestration: DAGs, backfills, idempotency

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.

Transformation DAG from raw tables through staging models to marts and metrics, with one failed staging model shown blocking its descendants as a dashed danger-colored subtree while unaffected branches complete, and a backfill path annotated with its date rangeRAWSTAGINGMARTSCONSUMERSraw_pg.cellarsraw_pg.bottlesraw_pg.winesraw_api.pricesraw_events.appstg_cellars ✓stg_bottles ✓stg_wines ✓stg_market_pricesFAILED — freshness teststg_app_events ✓fct_price_observationsSKIPPEDupstream failed — not run at allmart_cellar_portfolio ⊘mart_cellar_activity ✓Portfolio dashboardblank tile + "build failed"Activity dashboardcurrent — unaffected branchBackfill path — after the credential is fixedre-run stg_market_prices → fct_price_observations → mart_cellar_portfolio for 2026-03-04 … 2026-03-10 only,partition by partition · other branches untouched · safe to re-run twice because each partition is replaced, not appended
Figure 7.1 — A failure that stops rather than spreads. The price feed's freshness test fails, so 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.

Anti-pattern: non-idempotent pipelines

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)
Guide Nº 17, one layer up

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.

Two-panel comparison with row-count tables: an append-only backfill run twice doubles the partition's rows, while a delete-and-insert backfill run twice converges to the same rowsAppend-only — INSERT … WHERE date = :run_datestate rows(2026-03-04) portfoliobefore backfill 0 $0after 1st run 434,000 $186.2Mafter 2nd run 868,000 $372.4Mafter 3rd run 1,302,000 $558.6MEvery retry adds a full copy. No error is raised.The operator's most natural action — "the job failed,let me re-run it" — is the action that corrupts the data.Detected only by unique(bottle_id, valuation_date).Delete-and-insert by partitionstate rows(2026-03-04) portfoliobefore backfill 0 $0after 1st run 434,000 $186.2Mafter 2nd run 434,000 $186.2Mafter 3rd run 434,000 $186.2MN runs converge to the same state.Re-running is now the correct response to any failure,which is what makes backfills routine instead of risky.Assert it: run twice in CI, check the row count is equal.
Figure 7.2 — The arithmetic of a retry. An append-only model turns the operator's most natural response to a failure into a data-corrupting action, and the corruption raises no error. Delete-and-insert by partition converges: after one run or five, the partition holds 434,000 rows and $186.2M, which is what makes a backfill a routine operation rather than a decision requiring senior approval.
The end-to-end stack map redrawn with trust apparatus attached: quality tests at each boundary, freshness checks at consumption points, and lineage spanning the whole chainFigure 2.3, armedSourcesIngestionRawStagingMartsMetrics→ usersvolume · schema · distributionunique · not-null · relationsgrain · business rulesfreshness SLAshown on every surfacefreshness assertionfails the build, pages on-callLineage — column-level, generated from the DAG — spans every box above: blast radius forward, root cause backwardOrchestration executes the arrows and enforces the rule that a failed node blocks its descendants rather than feeding them.This is the map you audit against: for each boundary, name the control; for each consumer, name the SLA.
Figure 7.3 — The master map as an audit map. The same chain as Figure 2.3, now with its controls attached: shape tests at ingestion, structural tests at staging, semantic tests at the marts, freshness assertions in the pipeline and freshness displays at every consumer, and column-level lineage spanning the whole. Read it as a coverage matrix — for each boundary, name the control; for each consumer, name the SLA; and treat any blank cell as an unmonitored risk rather than an omission.

Module 08 Consumption, cost, and how it fails

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.

Dashboards and the self-serve promise

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.

Quadrant chart plotting question novelty against data readiness, showing four regimes: self-serve thrives, analyst work, modeling backlog, and the danger quadrant where self-serve over raw data manufactures confident wrong answersQuestion novelty →routinenovelData readiness →modeledrawSelf-serve thrivesgoverned marts + defined metrics;the user contributes the question,the layer contributes the definition"active cellars by channel, last quarter"Analyst worknew question, trustworthy inputs —the analyst's judgment is thescarce input, not their SQL"does vintage predict retention?"Modeling backlogthe question repeats and the datais not ready — this is the queuethe data team should be workingevery ticket here is a mart waiting to existThe danger quadrantnovel question, unmodeled data,non-expert author — producesconfident wrong answersthis is where "just give everyone SQL" landsOpening access moves people rightward and downward. Modeling moves the boundary upward. Do the second first.
Figure 8.2 — Self-serve is a consequence of modeling, not a substitute for it. The top-left quadrant is where self-serve genuinely delivers: routine questions over modeled data, where the user supplies the question and the semantic layer supplies the definition. The bottom-right is where "just give everyone SQL access" lands people — novel questions against unmodeled tables, authored by someone without the context to know what they are joining — and it produces answers that are wrong and confident in equal measure.
Anti-pattern: self-serve as a substitute for modeling

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.

The stack as supplier

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.

Out of scope, named

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.

The accidentally expensive query

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.

Bar chart of bytes scanned for the same business question asked four ways — select star over raw events, column-selected, partition-pruned, and served from a pre-aggregated mart — annotated with cost per run and daily cost at hourly refresh"Weekly active cellars by region, last quarter" — hourly refresh, $5 per TB scannedselect * from raw_events.app_events (5 yrs)18.4 TB · $92/run$2,208/dayselect cellar_id, event_at, region — 3 columns of 192.1 TB · $10.50/run$252/day — 8.8× cheaper… where event_date >= current_date - 90 (partition pruned)96 GB · $0.48/run$11.52/day — 192× cheaper than baselineselect from mart_cellar_activity (pre-aggregated nightly)340 MB · $0.002/run$0.04/day + $0.90/day to build it once nightlyAll four return the same twelve rows in under ten seconds. Latency gave the author no signal at any step.
Figure 8.1 — Four ways to ask one question, and a 2,300× spread. Column selection removes the columns the query never used; partition pruning removes the four and a half years it never needed; the pre-aggregated mart removes the scan altogether and pays a fixed nightly cost instead. Every version returns the same twelve rows in comparable time, which is exactly why cost problems are invisible to the person creating them.

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.

Anti-pattern: ignoring cost until the invoice arrives

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.

The anti-pattern field guide

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-patternSymptom you observeCorrectiveModule
BI pointed at productionApplication p99 degrades on a schedule; application traffic is flat across the windowMove the workload to a columnar warehouse; a read replica removes contention only, not layout or semanticsm1
ETL habits in an ELT worldA definition change cannot be applied to history; the source's retention window has passedLand the source at native grain, unmodified; aggregate in a versioned model downstreamm3
The untested monolithAn hour's change is quoted at a week; one person understands the file and they are on leaveDecompose into layered models with byte-identical SQL, assert output equality, then test each boundarym4
Undeclared or mixed grainTotals too large by an inconsistent factor; distinct appearing in places it does not belongOne grain per fact table, declared in a sentence and enforced by a uniqueness test on its keym5
Definitions in dashboard toolsNine tiles with variants of one name; "what does this count?" answered by reading SQL aloud from a modalDefinitions move to a metrics layer in git; genuine variants become separate named metricsm6
Dashboards without freshnessA decision made on numbers that stopped updating days ago; nobody could have knownFreshness assertion that fails the build, plus a data-age indicator on every surfacem7
Non-idempotent pipelinesOne day of a series is a clean multiple of its neighbors, correlated with an operational incidentDelete-and-insert by partition in one transaction; prove it by running twice in CIm7
Self-serve as a modeling substitute400 dashboards, 10 viewed; the data team's queue shifts from requests to disputesModel the recurring questions first, then open access; retire dashboards on a viewing thresholdm8
Cost ignored until the invoiceBill grows faster than data or headcount; growth cannot be attributed to anything specificTag and attribute spend, surface the top queries by bytes scanned weekly, set per-query limitsm8

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.

Where this leaves you

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.

Concept index

OLTP
Transaction-processing workload: many small single-record reads and writes, optimized for latency and concurrency.
OLAP
Analytical workload: few large scans and aggregations across millions of rows, optimized for throughput per query.
Row storage
Layout storing each record's fields contiguously — one fetch retrieves a whole record; scans read everything.
Columnar storage
Layout storing each column contiguously — scans read only the columns a query touches.
Columnar compression
Dictionary and run-length encoding over homogeneous column data; multiplies the columnar scan advantage.
Data warehouse
Columnar analytical database with storage separated from elastic compute; the stack's center of gravity.
Separation of storage and compute
One copy of data in cheap object storage; stateless compute clusters rented per use — the warehouse's defining architecture.
Data lake
Files in object storage without database semantics; cheap and schemaless until read.
Lakehouse
Open table formats adding warehouse-like semantics (transactions, schema) over lake storage; convergence, not revolution.
Open table format
Spec (metadata plus file layout) that lets multiple engines read and write the same tables transactionally.
Batch extract
Scheduled snapshot pull from a source system; simple and robust; misses intra-day transitions and hard deletes.
Change data capture (CDC)
Reading a database's write-ahead log to emit every insert, update, and delete as ordered events.
Write-ahead log (WAL)
The database's append-only record of every change, written before the change applies; what CDC subscribes to.
Instrumentation event
A record of something that happened (view, click, action) emitted at the edge — data the database never stores.
ETL
Extract, transform, then load: transformation before the destination — history unreplayable when definitions change.
ELT
Extract, load, then transform: raw lands first; transformation is SQL in the warehouse — replayable and testable.
Raw layer
Immutable landed source data; the evidence layer nothing may edit in place.
Staging model
One-per-source-table model that renames, casts, and cleans — no business logic, no joins across sources.
Data mart
Consumption-facing model joining and aggregating staged data for a subject area.
Materialization
How a model persists: view, table, or incremental — declared per model, not hand-managed.
Data test
Executable assertion on a model (not-null, unique, relationship, business rule) run on every build.
Fact table
Table of measurements or events at one declared grain; the star's center.
Dimension
The nouns you slice facts by — wine, cellar, region, date — wide and denormalized on purpose.
Star schema
One fact table joined directly to its dimensions; denormalization chosen for scan-and-aggregate physics.
Grain
The one-sentence answer to what one fact row represents — every measure must be additive at it.
Additive measure
A measure that sums correctly across any dimension slice at the declared grain; ratios and percentages are not.
Metrics layer
Where a metric is defined once — measure, grain, filters, join path — and every consumer requests it rather than re-deriving it.
Metric governance
Ownership, change review, and deprecation for metric definitions; the defined-terms clause of the data contract.
Lineage
Column-level provenance from source to consumer; answers blast radius before a change and root cause after.
Freshness
How old a consumer's data is versus its SLA; the silent failure, because stale renders identically to fresh.
Backfill
Recomputing historical partitions through the pipeline; safe only when runs are idempotent.
Idempotent pipeline
A run that converges to the same state however many times it executes — delete-and-insert by partition, not append.
Partition pruning
Skipping storage segments a query's filter excludes; the primary lever on bytes scanned and therefore cost.

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.