<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nariman Baubekov</title>
    <description>The latest articles on DEV Community by Nariman Baubekov (@nbaubek).</description>
    <link>https://dev.to/nbaubek</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4071618%2F19d78393-df81-4aa3-8359-d12a808c56fb.png</url>
      <title>DEV Community: Nariman Baubekov</title>
      <link>https://dev.to/nbaubek</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nbaubek"/>
    <language>en</language>
    <item>
      <title>Parquet, Explained: How One File Format Quietly Won the Data World</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:39:57 +0000</pubDate>
      <link>https://dev.to/nbaubek/parquet-explained-how-one-file-format-quietly-won-the-data-world-2610</link>
      <guid>https://dev.to/nbaubek/parquet-explained-how-one-file-format-quietly-won-the-data-world-2610</guid>
      <description>&lt;p&gt;Snowflake queries it. Spark writes it. Trino scans it, Flink lands it, BigQuery federates it, and DuckDB treats a directory of it as a database. Your pandas &lt;code&gt;read_parquet&lt;/code&gt; pulls gigabytes per second through it. Cloud object storage is full of it. Recent versions of Excel can even open it.&lt;/p&gt;

&lt;p&gt;It's not a database. It's not a query engine. It's not a product anyone sells, and no company owns it. It's a file format — Apache Parquet — and it's the closest thing modern data engineering has to a universal standard. The three big "table format" layers — Delta Lake, Apache Iceberg, and Hudi — all store their data as Parquet files underneath. The entire lakehouse movement is, physically, an enormous pile of Parquet plus opinions.&lt;/p&gt;

&lt;p&gt;How does a file format nobody owns become the center of gravity for an entire industry? And why do practitioners who learn its inner workings keep discovering that their storage bills were three times bigger than they needed to be?&lt;/p&gt;

&lt;p&gt;This article is the full story: how Parquet works down to structures you can point at in a hex dump, why it became ubiquitous, where it genuinely hurts, and one physical-design trick — sort order — that routinely makes Parquet tables an order of magnitude smaller. That last one sounds like a stunt. By the time we get there, it'll just be arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It's 2013, and analytics has a reading problem&lt;/li&gt;
&lt;li&gt;Store it sideways&lt;/li&gt;
&lt;li&gt;Inside a Parquet file&lt;/li&gt;
&lt;li&gt;The life of a query&lt;/li&gt;
&lt;li&gt;How the compression sausage gets made&lt;/li&gt;
&lt;li&gt;Why it won&lt;/li&gt;
&lt;li&gt;The honest cons&lt;/li&gt;
&lt;li&gt;When to use it (and when not to)&lt;/li&gt;
&lt;li&gt;The trick that sounds fake: sort by cardinality, low to high&lt;/li&gt;
&lt;li&gt;The fine print on sort order&lt;/li&gt;
&lt;li&gt;More lifehacks in the same spirit&lt;/li&gt;
&lt;li&gt;Closing thought&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  It's 2013, and analytics has a reading problem
&lt;/h2&gt;

&lt;p&gt;It's 2013. Storage has never been cheaper — Hadoop clusters built from commodity disks can hold everything — but &lt;em&gt;reading&lt;/em&gt; it hasn't caught up. An analytical query like "average revenue by country" needs three columns out of forty, and yet the formats of the day (CSV, JSON, Avro, SequenceFiles) all store data row by row. To touch the three columns you want, you pay to read all forty.&lt;/p&gt;

&lt;p&gt;Meanwhile, the queries are getting bigger. Twitter's analytics team is scanning billions of rows across a cluster and watching half the cluster's throughput carry bytes nobody asked for: usernames and device strings and JSON blobs, streamed off disk only to be immediately discarded.&lt;/p&gt;

&lt;p&gt;The row-versus-column idea wasn't new — data warehouses and C-store-style research systems had exploited it for years, and Google's 2010 Dremel paper had just demonstrated columnar storage working at web scale, including a scheme ("repetition and definition levels") for storing &lt;em&gt;nested&lt;/em&gt; data — structs, arrays, maps — column by column without shredding it into mush. What was missing was an open, general-purpose file format that brought all of it to the Hadoop ecosystem.&lt;/p&gt;

&lt;p&gt;So in 2013, engineers from Twitter and Cloudera built one. The lore says the name comes from &lt;em&gt;parquetry&lt;/em&gt; — the wood-floor pattern where slats lie side by side — which is either a beautiful coincidence or exactly the right metaphor, because that's precisely how it stores your columns. Parquet joined the Apache Software Foundation, and the rest is a very quiet, very complete victory.&lt;/p&gt;

&lt;p&gt;One framing note before we go deeper: Parquet is a &lt;em&gt;file format&lt;/em&gt;, and nothing more. Not a database, not an engine, not a query planner, not a table. It's a meticulously specified way to lay bytes down on disk so that &lt;em&gt;any&lt;/em&gt; engine — Spark, Trino, Snowflake, DuckDB, pandas — can read them back efficiently. That humility turns out to be a big part of the answer to "why did it win," and we'll return to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store it sideways
&lt;/h2&gt;

&lt;p&gt;Everything about Parquet starts from one decision: store data &lt;strong&gt;by column instead of by row&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Take this tiny events table — our running example for the article:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;name&lt;/th&gt;
&lt;th&gt;os&lt;/th&gt;
&lt;th&gt;country&lt;/th&gt;
&lt;th&gt;amount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Alice&lt;/td&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;DE&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bob&lt;/td&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;US&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cara&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;DE&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dan&lt;/td&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;DE&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A row-oriented format (CSV, JSON, the heap files of your favorite OLTP database) stores it exactly as printed: one row after another, all columns of a row adjacent. That's great when you want &lt;em&gt;Dan's entire row&lt;/em&gt; — it's one contiguous read. It's terrible when you want &lt;em&gt;the average of &lt;code&gt;amount&lt;/code&gt;&lt;/em&gt;, because you have to wade past every &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;os&lt;/code&gt;, and &lt;code&gt;country&lt;/code&gt; byte to fish out the values you care about.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftg4q1ybxno2ci3dyhh9f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftg4q1ybxno2ci3dyhh9f.png" alt="Row-oriented storage keeps each record's fields adjacent; column-oriented storage groups every record's value for one field together instead" width="800" height="2650"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;Analytical queries almost always touch &lt;em&gt;some&lt;/em&gt; columns of &lt;em&gt;lots of rows&lt;/em&gt; — &lt;code&gt;SELECT country, AVG(amount) ... GROUP BY country&lt;/code&gt; — so flipping the layout pays twice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You read less.&lt;/strong&gt; Want two columns out of forty? Read two column chunks. The other thirty-eight never leave disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You compress better.&lt;/strong&gt; Values of the same type and same domain now sit next to each other, and "things that look alike compress alike" is the oldest trick in the information-theory book. A column of country codes is a sea of repetition; the same codes scattered between names and timestamps are noise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Column-orientation is the idea. The next two sections are the engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside a Parquet file
&lt;/h2&gt;

&lt;p&gt;Parquet files are built from four nested structures, and their names come up constantly in tuning guides, so they're worth knowing cold:&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcxpz8a4m9hv6qqk30ej5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcxpz8a4m9hv6qqk30ej5.png" alt="events.parquet breaking down into row groups, each row group into column chunks, and each column chunk into a dictionary page plus data pages" width="800" height="355"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Row group&lt;/strong&gt; — a horizontal slice of the table (all columns, a batch of rows). This is the unit of parallelism and of I/O: one worker per row group, and — this matters for later — the &lt;em&gt;entire row group is typically processed by one task&lt;/em&gt;. Most writers default to something around 128 MB or a million rows per group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Column chunk&lt;/strong&gt; — within a row group, each column's values are stored contiguously. This is the unit of column pruning: skip a column, skip its whole chunk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page&lt;/strong&gt; — column chunks are sliced into pages of roughly 1 MB. This is the unit of encoding and compression, and the smallest thing a reader decodes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Footer&lt;/strong&gt; — the metadata mother lode: the full schema, the offsets of every row group and column chunk, which encodings and compression each chunk uses, and per-chunk statistics (min, max, null count, distinct count).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here's the layout of the file itself, byte by byte:&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqk8qowu68qjy3x5lhlh5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqk8qowu68qjy3x5lhlh5.png" alt="File byte layout: PAR1 magic bytes, then row groups, then column chunk metadata, then Thrift file metadata, then a 4-byte footer length, then PAR1 again" width="796" height="67"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;The file starts and ends with the magic bytes &lt;code&gt;PAR1&lt;/code&gt;, so you can identify one from a mile away. Everything important is at the &lt;em&gt;end&lt;/em&gt;: to open a Parquet file, a reader grabs the last 8 bytes (footer length + magic), then range-requests exactly the metadata it needs, then range-requests exactly the column chunks it needs. On object storage like S3, where a ranged GET is cheap and a full scan is not, this layout is native-level friendly.&lt;/p&gt;

&lt;p&gt;Two more structural superpowers worth flagging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Splittability.&lt;/strong&gt; Row-group boundaries are recorded in the footer, so a big file can be split across many workers with zero coordination — no "unzip the whole 40 GB gzipped CSV on one node" era nostalgia.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-description.&lt;/strong&gt; The schema, the types, the encodings, the statistics — it's all inside the file. Hand a Parquet file to a tool that has never seen your data stack, and it knows what it's looking at. (This is also what the table formats build on — more on that in a minute.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The life of a query
&lt;/h2&gt;

&lt;p&gt;Here's the payoff diagram — the life of an analytical query against a Parquet-based lake, using our events table:&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ohj0jkb29vyaaknwvro.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ohj0jkb29vyaaknwvro.png" alt="The five-step life of a query: read the footer, prune columns, prune row groups by min/max stats, push predicates down while decoding, and aggregate only the surviving rows" width="800" height="2614"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;Every one of those steps is the format doing work your query engine would otherwise have to do expensively, with data it would otherwise have to load. Engines pair this with vectorized execution — decoding columns straight into dense, SIMD-friendly batches — which is why "just point DuckDB at a directory of Parquet files" is a legitimate analytics strategy in 2026.&lt;/p&gt;

&lt;p&gt;Notice how much of this diagram runs on those footer &lt;strong&gt;statistics&lt;/strong&gt;. Remember that; it's about to become load-bearing, twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the compression sausage gets made
&lt;/h2&gt;

&lt;p&gt;Parquet's size reductions come from two layers working in series — specialized &lt;strong&gt;encodings&lt;/strong&gt; first, general-purpose &lt;strong&gt;compression&lt;/strong&gt; second:&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffnwel523n7eclcmqlxb9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffnwel523n7eclcmqlxb9.png" alt="Compression pipeline: raw values through dictionary encoding, to an index stream, through run-length and bit-packing, through general-purpose compression, to bytes on disk" width="797" height="60"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dictionary encoding&lt;/strong&gt;: each page's distinct values go into a small dictionary, and the column becomes a stream of tiny integer &lt;em&gt;indices&lt;/em&gt; into it. Your 2-byte country strings become 1-byte numbers before any "compression" has even happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run-length encoding (RLE)&lt;/strong&gt;: consecutive &lt;em&gt;repeats&lt;/em&gt; collapse into &lt;code&gt;(value, count)&lt;/code&gt; pairs. This is the encoding that cares about sort order, and therefore the encoding doing the heavy lifting in the trick at the end of this article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bit-packing&lt;/strong&gt;: when values have no runs, they're packed at minimum bit width (3 distinct OSes ≈ 2 bits each).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delta encodings&lt;/strong&gt; (for integers, dates, sorted keys): store differences between consecutive values instead of the values. Sorted IDs have small gaps; shuffled IDs have giant ones.&lt;/li&gt;
&lt;li&gt;Then &lt;strong&gt;zstd / snappy / gzip&lt;/strong&gt; squeezes whatever is left, finding repeated byte patterns across everything above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important mental model: these are pattern-finders. Dictionary encoding exploits &lt;em&gt;few distinct values&lt;/em&gt;. RLE exploits &lt;em&gt;adjacent equal values&lt;/em&gt;. Delta exploits &lt;em&gt;monotonic sequences&lt;/em&gt;. zstd exploits &lt;em&gt;repeated byte patterns&lt;/em&gt;. A column only gets compressed to the extent its physical arrangement exhibits one of those patterns — and &lt;strong&gt;sort order is the one lever you control that changes which patterns exist&lt;/strong&gt;, without changing the data's meaning at all.&lt;/p&gt;

&lt;p&gt;Hold onto that sentence. It's the key to the last third of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it won
&lt;/h2&gt;

&lt;p&gt;You'll notice this article hasn't claimed Parquet is objectively the best at any single thing. It doesn't have ORC's pedigree of Hive-native optimization; a bespoke binary format could be smaller or faster for any specific workload. Its dominance comes from a more interesting place: it's excellent at everything analytical workloads need, open, and — critically — &lt;em&gt;everywhere&lt;/em&gt;.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fykiia8sogfiv6dv3yy0m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fykiia8sogfiv6dv3yy0m.png" alt="Every major writer (Spark, Flink, pandas/Polars/PyArrow, DuckDB, Kafka pipelines) feeding into cheap object storage with Parquet plus a table format, which every major reader (Trino, Snowflake, BigQuery, DuckDB, BI tools) then reads from" width="800" height="236"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;The properties that got it there:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's open and neutral.&lt;/strong&gt; Apache-licensed, vendor-owned by no one. In a market terrified of lock-in, "your bytes are yours" is a strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It decouples storage from compute.&lt;/strong&gt; Because it reads beautifully over cheap object storage with ranged GETs, your data can live on S3 while Snowflake, Trino, and DuckDB all take turns querying it. The lakehouse architecture — the dominant pattern of the last decade — is basically unthinkable without a format like this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network effects.&lt;/strong&gt; Every engine reads it because every other engine reads it. New tools ship Parquet support on day one to be relevant. That flywheel, not technical superiority, is the moat — but it's a very real moat. (Just ask ORC, which is excellent and still lives mostly in Hive-land.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's the substrate of the table formats.&lt;/strong&gt; Delta Lake, Apache Iceberg, and Hudi — the three big "SQL table" layers over lakes — all store their data &lt;em&gt;as Parquet files&lt;/em&gt;, adding ACID transactions, schema evolution, and time travel on top. The "new standard" is literally built out of the old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's cheap to keep.&lt;/strong&gt; Three-to-ten-times smaller than the equivalent CSV/JSON is the typical range, before you've applied a single trick from this article. Storage is a recurring cost; good layout is a one-time effort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's fast to query&lt;/strong&gt; for the reasons in the query-path diagram: column pruning, row-group pruning, predicate pushdown, splittable parallelism.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A quick aside for the Arrow-curious: Apache Arrow is Parquet's in-memory cousin — same columnar religion, designed for zero-copy in-process work rather than on-disk storage. The two are designed to round-trip each other cheaply, and the pairing is why "read Parquet into Arrow, compute vectorized, write Parquet back" is the default circulation of the modern data stack. (If you've never watched a pandas &lt;code&gt;read_parquet&lt;/code&gt; hit multiple GB/s via Arrow, it's worth trying.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest cons
&lt;/h2&gt;

&lt;p&gt;A dominant format with no downsides would be suspicious. Here's where Parquet genuinely hurts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Point lookups and OLTP are not its job.&lt;/strong&gt; Fetching one full row means seeking into &lt;em&gt;every&lt;/em&gt; column chunk to reassemble it. Row formats exist precisely because transactional workloads want rows. Use Postgres for your orders table; use Parquet for your analytics over the orders table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writes are expensive and files are immutable.&lt;/strong&gt; A writer must buffer an entire row group, encode it, compress it, and write it with its footer. There's no "update row 4,077" — Parquet files are write-once. Updates and deletes are why Delta/Iceberg/Hudi exist: they rewrite files for you and keep a transaction log straight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The small-files problem.&lt;/strong&gt; Thousands of tiny Parquet files (the natural byproduct of frequent streaming writes) wreck performance: metadata overhead per file, footer round-trips per file, task scheduling chaos, S3 request charges. This is such a consistent foot-gun that "compaction" is a core feature of every table format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not human-readable.&lt;/strong&gt; You can't &lt;code&gt;cat&lt;/code&gt; it, can't eyeball it in a text editor, can't email it to a business analyst who opens it in Notepad. (The world is softening — recent Excel can import Parquet, and a quick &lt;code&gt;duckdb -c "select * from file.parquet limit 5"&lt;/code&gt; gets you a peek in seconds, not minutes — but CSV's universal readability remains unmatched.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overkill for small data.&lt;/strong&gt; A 5,000-row lookup table shipped with your app? A config file? Just use CSV or JSON. Parquet's machinery pays off at scale and is pure ceremony below it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Randomness doesn't compress.&lt;/strong&gt; GUID columns, uniformly random floats, already-compressed blobs (images, encrypted data): columnar layout and dictionaries have nothing to say about entropy. Don't expect magic, and don't bother compressing binary blobs a second time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deeply nested, repeated data can be slow to read back.&lt;/strong&gt; Repetition/definition levels are elegant for &lt;em&gt;storage&lt;/em&gt;, but reconstructing a forest of nested structs row by row costs CPU. Very nested Parquet read into very row-oriented code is where the format's reputation for "slowness" actually comes from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema evolution is limited.&lt;/strong&gt; Adding nullable columns at the end: fine. Renaming, reordering, or changing types: prepare for pain (or a table format's versioning machinery).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are scandalous. They're the predictable trade-offs of a format optimized for &lt;em&gt;scanning billions of rows, touching a few columns, from cheap shared storage&lt;/em&gt;. It's a sprinter complaining about its swimming.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use it (and when not to)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Reach for it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Parquet&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Analytical data at any scale beyond "fits in a spreadsheet"; data lakes and lakehouses; interchange between engines; long-term storage of query-able data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CSV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Human-facing handoffs, tiny datasets, maximum tool compatibility&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON (lines)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;APIs, semi-structured streaming events, documents with wildly varying shape&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Avro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Row-oriented streaming pipelines with schema-registry needs (Kafka ecosystems)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ORC&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Deep Hive-land; otherwise Parquet's ecosystem gravity usually wins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A real database&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OLTP, concurrent transactions, point updates, enforceable constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The trick that sounds fake: sort by cardinality, low to high
&lt;/h2&gt;

&lt;p&gt;Here's a stunt. Take a wide events table — the kind with a few low-cardinality dimension columns (&lt;code&gt;device_operating_system&lt;/code&gt;: 3 values; &lt;code&gt;country&lt;/code&gt;: ~250 values; maybe &lt;code&gt;app_version&lt;/code&gt;, &lt;code&gt;event_name&lt;/code&gt;, &lt;code&gt;locale&lt;/code&gt;...) and some high-cardinality ones (&lt;code&gt;user_id&lt;/code&gt;: millions). Write it to Parquet in the order the events arrived. Note the file size — call it 30 GB.&lt;/p&gt;

&lt;p&gt;Now rewrite the exact same rows, sorted by &lt;code&gt;(device_operating_system, country, user_id)&lt;/code&gt;. Same schema, same row count, same everything — SQL results are byte-for-byte identical. The file lands under 3 GB.&lt;/p&gt;

&lt;p&gt;Nothing was deleted. Nothing was lossy-compressed. The data just &lt;em&gt;lies down differently&lt;/em&gt;. Reductions of this shape — 10x and sometimes much more — are real and reproducible, and this section will get you to the point where you can predict which of your tables has this hiding inside them. It comes down to two facts you already learned:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Parquet compresses a column by finding patterns among &lt;strong&gt;adjacent&lt;/strong&gt; values.&lt;/li&gt;
&lt;li&gt;Sort order is the one lever that changes which patterns exist.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 1: Meet your columns as Parquet sees them
&lt;/h3&gt;

&lt;p&gt;With dictionary encoding, each low-cardinality column becomes a stream of tiny integers — the dictionary indices. What does that stream look like?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unsorted data&lt;/strong&gt; (rows arrive in event order, i.e., chaos):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;os column, as dictionary indices:      1 0 1 2 0 0 1 2 1 0 0 2 1 0 1 1 2 0 ...
country column, as dictionary indices: 83 17 42 0 91 17 3 88 42 17 91 0 55 ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Equal values are almost never adjacent. RLE finds no runs, so each column falls back to bit-packing: roughly &lt;strong&gt;2 bits per row&lt;/strong&gt; for &lt;code&gt;os&lt;/code&gt;, roughly &lt;strong&gt;8 bits per row&lt;/strong&gt; for &lt;code&gt;country&lt;/code&gt; — &lt;em&gt;forever, no matter what&lt;/em&gt;. On a billion-row table, that's ~250 MB for the OS column and ~1 GB for the country column, before zstd shrugs at the noise. And &lt;code&gt;user_id&lt;/code&gt;, with millions of distinct values? Its dictionary overflows and the writer quietly falls back to storing raw 8-byte IDs — random, unordered, essentially incompressible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sorted by &lt;code&gt;(os, country, user_id)&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;os column:      0 0 0 0 0 0 ... 0 | 1 1 1 1 1 ... 1 | 2 2 2 ... 2
country column: 0 0 0 ... 0 | 1 1 1 ... 1 | ... (sorted runs inside every os block)
user_id column: locally sorted, ascending, inside every (os, country) block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every encoding in the pipeline has something to eat:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;os&lt;/code&gt; collapses to &lt;strong&gt;3 runs&lt;/strong&gt;. Three &lt;code&gt;(value, count)&lt;/code&gt; tuples. The column that cost 250 MB now costs bytes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;country&lt;/code&gt; becomes at most ~750 long runs (250 values inside each of 3 OS blocks). The 1 GB column now costs kilobytes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;user_id&lt;/code&gt; is locally sorted, so delta encoding turns it into a stream of small gaps instead of random 64-bit noise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then zstd, arriving last, finds every page monotonous instead of chaotic, and gets dramatically better matches on &lt;em&gt;everything&lt;/em&gt; — including the payload columns you didn't sort by, because rows that share &lt;code&gt;(os, country)&lt;/code&gt; tend to correlate on app version, event type, locale, and friends. That compounding across all columns at once is where order-of-magnitude reductions come from.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn6gbu8g0mxjsz70p6f4v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn6gbu8g0mxjsz70p6f4v.png" alt="Sorting by os, then country, then user_id gives os 3 giant runs, country about 750 runs, and user_id delta-friendly local ordering — all three feeding into a page-level win that compounds with zstd" width="800" height="509"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;h3&gt;
  
  
  Step 2: Why &lt;em&gt;lowest cardinality first&lt;/em&gt;, specifically?
&lt;/h3&gt;

&lt;p&gt;This is the part people memorize without deriving, so here's the derivation. It's short.&lt;/p&gt;

&lt;p&gt;When data is sorted by keys &lt;code&gt;(k1, k2, ..., kn)&lt;/code&gt;, a column can only collapse into long runs if the columns &lt;em&gt;before it&lt;/em&gt; in the sort have a small &lt;strong&gt;combined&lt;/strong&gt; number of distinct combinations. Formally: column &lt;code&gt;j&lt;/code&gt; ends up with at most&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;runs(column j)  ≤  C(k1) x C(k2) x ... x C(kj)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;distinct runs, where &lt;code&gt;C()&lt;/code&gt; is cardinality, capped by the row count. The total storage for the sort-key columns is roughly the &lt;strong&gt;sum of prefix products&lt;/strong&gt;, and the &lt;em&gt;last&lt;/em&gt; term is &lt;code&gt;C1 x C2 x ... x Cn&lt;/code&gt; — which is identical no matter how you permute the keys. So you minimize the sum by minimizing the &lt;em&gt;earlier&lt;/em&gt; terms, which means putting the &lt;strong&gt;smallest cardinalities first&lt;/strong&gt;. Ascending order is provably the best of the simple orderings.&lt;/p&gt;

&lt;p&gt;Concretely, with our three columns (cardinalities 3, 250, ~300M) on a billion rows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sort order&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;os&lt;/code&gt; runs&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;country&lt;/code&gt; runs&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;user_id&lt;/code&gt; state&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(os, country, user_id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≤ 750&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;locally sorted → delta-friendly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(country, os, user_id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;≤ 750&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;locally sorted → delta-friendly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(os, user_id, country)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~1 billion&lt;/strong&gt; (catastrophe)&lt;/td&gt;
&lt;td&gt;locally sorted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unsorted&lt;/td&gt;
&lt;td&gt;~1 billion&lt;/td&gt;
&lt;td&gt;~1 billion&lt;/td&gt;
&lt;td&gt;random noise&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jump out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The truly catastrophic ordering is the third one&lt;/strong&gt; — interleaving a high-cardinality column &lt;em&gt;between&lt;/em&gt; two low-cardinality ones. Once &lt;code&gt;user_id&lt;/code&gt; comes before &lt;code&gt;country&lt;/code&gt;, every &lt;code&gt;(os, user_id)&lt;/code&gt; pair is nearly unique, so &lt;code&gt;country&lt;/code&gt; degenerates back to one value per row and pays bits-per-row again. Keep your low-cardinality dimensions adjacent, at the front — that's the sharpest cliff in the whole landscape.&lt;/li&gt;
&lt;li&gt;The difference between the first two rows is real but modest: swapping &lt;code&gt;(os, country)&lt;/code&gt; for &lt;code&gt;(country, os)&lt;/code&gt; mostly moves a few hundred runs between two columns that are both nearly free either way. The &lt;strong&gt;big&lt;/strong&gt; wins are (a) sorting at all, and (b) keeping the low-cardinality cluster together at the front. Ascending order is the safe default that also wins the math, so use it — just don't expect a further 10x from re-swapping two adjacent low-card columns.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Honest calibration: the 30x-and-beyond stunts happen on wide, dimension-heavy tables — a dozen scattered low-cardinality string dimensions all collapsing at once. Typical gains from adding a thoughtful sort are more like 2x–10x on storage, which is still, conservatively, free money. And the trick does nothing for pure entropy: a table of GUIDs and random floats has no patterns to expose, no matter how you sort it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: There's a second, hidden prize — data skipping
&lt;/h3&gt;

&lt;p&gt;Remember the footer statistics? Sorted data doesn't just compress better; it &lt;strong&gt;prunes better&lt;/strong&gt;. If &lt;code&gt;os&lt;/code&gt; is the first sort key and the table has 100 row groups, each row group contains essentially one OS value, so its &lt;code&gt;min = max = 'iOS'&lt;/code&gt; (or &lt;code&gt;'Android'&lt;/code&gt;, or...). A &lt;code&gt;WHERE os = 'iOS'&lt;/code&gt; filter can now skip two-thirds of the file without reading a byte of it. DuckDB's Parquet tips call this out explicitly: sort by your frequently-filtered columns, and row-group min/max stats become a homemade index. Sort keys are the gift that keeps giving — once per compression, again per query.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Do it
&lt;/h3&gt;

&lt;p&gt;The canonical pattern — partition by time, sort by ascending cardinality within each partition — in DuckDB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="s1"&gt;'raw_events/'&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;device_operating_system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s1"&gt;'events/'&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FORMAT&lt;/span&gt; &lt;span class="n"&gt;parquet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PARTITION_BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_date&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;COMPRESSION&lt;/span&gt; &lt;span class="n"&gt;zstd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROW_GROUP_SIZE&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in Spark, one crucial refinement — you don't need a &lt;em&gt;global&lt;/em&gt; sort. Compression happens inside each file's row groups, so sorting &lt;em&gt;within&lt;/em&gt; each partition is sufficient, and &lt;code&gt;sortWithinPartitions&lt;/code&gt; gets you that without the cluster-wide shuffle of a full &lt;code&gt;orderBy&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sortWithinPartitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;device_operating_system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partitionBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compression&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zstd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://lake/events/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither operation changes the data. Every downstream &lt;code&gt;SELECT&lt;/code&gt; returns identical results. You've simply packed the suitcase better — same clothes, half the suitcase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fine print on sort order
&lt;/h2&gt;

&lt;p&gt;One honest caveat before you ascend-cardinality-sort every table you own: &lt;strong&gt;the compression-optimal sort is not always the query-optimal sort.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your hot queries are &lt;code&gt;WHERE event_date BETWEEN ...&lt;/code&gt; time-range scans, putting &lt;code&gt;event_date&lt;/code&gt; early in the sort (or partitioning by it — say, a directory per day) gives dramatically better pruning than burying it last. The canonical resolution of this tension is the pattern from Step 4: &lt;strong&gt;partition by the range-filtered column, sort by ascending cardinality within partitions.&lt;/strong&gt; Partitioning handles the time dimension; the sort handles compression and dimension filtering. If you can't partition, a pragmatic hybrid is &lt;code&gt;(your_most_filtered_column, lowest-card column, ..., highest-card column)&lt;/code&gt; — sacrifice a little compression for a lot of skipping.&lt;/p&gt;

&lt;p&gt;Rules of thumb, in descending priority:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sort by &lt;em&gt;something&lt;/em&gt;. Unsorted is the expensive state.&lt;/li&gt;
&lt;li&gt;Partition (or lead the sort with) the column your queries range-filter on most — usually time.&lt;/li&gt;
&lt;li&gt;Keep low-cardinality dimensions adjacent, at the front of the sort.&lt;/li&gt;
&lt;li&gt;Within that, ascending cardinality.&lt;/li&gt;
&lt;li&gt;Never interleave a high-cardinality column between low-cardinality ones.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  More lifehacks in the same spirit
&lt;/h2&gt;

&lt;p&gt;The sort trick has siblings. All of them are "arrange the bytes so the format's machinery has something to eat."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Switch from Snappy to Zstd.&lt;/strong&gt; Snappy became the default when CPU was scarcer than disk; in 2026, Zstd at a moderate level typically lands 20–30% smaller at comparable read speeds. One word in your writer options. It stacks multiplicatively with the sort trick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Kill your small files (and small row groups).&lt;/strong&gt; Every Parquet file pays footer metadata, and every row group truncates runs — a thousand 5 MB files hold a thousand tiny, badly-compressed fragments of what should be long runs. Target files in the 128 MB–1 GB range with row groups big enough for runs to actually form (DuckDB's default row-group size is 122,880 rows; their guidance is at least as many row groups per file as you have threads reading it, which biases toward somewhat larger files). If streaming ingestion gives you a swarm of small files nightly, run compaction — it's the sort trick's best friend, because compaction + re-sort is exactly the "rewrite the table neatly" operation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Use Parquet v2 data pages — but check your data shape first.&lt;/strong&gt; The format's newer encodings — &lt;code&gt;DELTA_BINARY_PACKED&lt;/code&gt; for integers and timestamps, &lt;code&gt;BYTE_STREAM_SPLIT&lt;/code&gt; for floats — live in v2 data pages, and on the right data they're dramatic: DuckDB's own best-case numbers show up to 99% size reduction on a cleanly monotonic integer sequence. That's the best case, though, not the typical one. DuckDB's own issue tracker documents the opposite result on medium-entropy data — values that repeat but aren't monotonically increasing — where &lt;code&gt;DELTA_BINARY_PACKED&lt;/code&gt; can make a file &lt;em&gt;~3x larger&lt;/em&gt; than v1, because it turns repeated values into effectively random-looking deltas that compress worse than the raw values would have. That's one of two real reasons DuckDB doesn't default to writing v2 yet — the other being that some reader engines still can't parse it.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ji25ygf8xxm01qwi0cj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ji25ygf8xxm01qwi0cj.png" alt="DELTA_BINARY_PACKED encoding: near-zero bytes per value on monotonic sequences like sorted IDs and timestamps, versus up to 3x larger files on medium-entropy, non-monotonic data" width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;The rule of thumb: v2 is close to a free win on sorted IDs, timestamps, and counters — test before flipping it on for anything else. In DuckDB it's &lt;code&gt;COPY ... TO 'f.parquet' (FORMAT parquet, PARQUET_VERSION v2)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Store real types.&lt;/strong&gt; Dates as &lt;code&gt;DATE&lt;/code&gt; instead of &lt;code&gt;'2026-08-27'&lt;/code&gt; strings, 3-value flags as dictionary-encoded booleans or tiny ints instead of &lt;code&gt;'YES'/'NO'&lt;/code&gt; strings, IDs as integers instead of zero-padded strings. Types are compression decisions: the string version of every value fights your dictionary, your bit-packing, and your min/max statistics all at once. (Strings-as-types also quietly breaks statistics pruning — &lt;code&gt;min&lt;/code&gt; over &lt;code&gt;'10'&lt;/code&gt; and &lt;code&gt;'9'&lt;/code&gt; is meaningless.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Turn on the skipping machinery for high-cardinality lookups.&lt;/strong&gt; Min/max statistics can't help with &lt;code&gt;WHERE user_id = 42&lt;/code&gt; on a shuffled billion-row table (42 is plausibly in every row group's range). Two features fix exactly this: &lt;strong&gt;bloom filters&lt;/strong&gt; (a compact "definitely not in this chunk" probabilistic summary — writers like Spark and Iceberg can emit them per column) and the &lt;strong&gt;page index&lt;/strong&gt; (page-level min/max stats, enabling skips &lt;em&gt;within&lt;/em&gt; chunks). Enable both for your favorite equality-filtered high-cardinality columns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Diagnose before you tune.&lt;/strong&gt; Don't guess where the bytes are — look. DuckDB will show you every column chunk's encodings, compression, and sizes straight from the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;path_in_schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encodings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;compression&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;total_uncompressed_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total_compressed_size&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;parquet_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'events/partition_date=2026-08-01.parquet'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;total_compressed_size&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the &lt;code&gt;user_id&lt;/code&gt; column dwarfs everything, think delta encodings and sort position. If a low-card column is huge, it wasn't sorted when written. If &lt;code&gt;encodings&lt;/code&gt; says &lt;code&gt;PLAIN&lt;/code&gt; on a column that should be dictionary-encoded, its dictionary overflowed — another sign sorting (or better typing) is needed:&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb9p99tbnivxq0m95v7du.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb9p99tbnivxq0m95v7du.png" alt="Column values check against the dictionary page size limit: if distinct values fit, they're dictionary-encoded as tiny indices; if the dictionary overflows, the writer falls back to PLAIN, storing raw values with no indices at all" width="800" height="204"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;Thirty seconds of this beats an afternoon of folklore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;Parquet won not by being magical but by being &lt;em&gt;well-mannered&lt;/em&gt;: open, self-describing, columnar, splittable, compressible, and readable by everyone. It stores your data sideways, tells readers what it knows, and gets out of the way.&lt;/p&gt;

&lt;p&gt;And once you see that its entire compression story is "find patterns among adjacent values," a whole shelf of lifehacks stops being folklore and becomes arithmetic. Sort low-to-high cardinality. Keep the little dimensions together. Partition by what you filter, sort by what you group. Use Zstd and v2 pages and real types. Don't drown in small files. None of these change your data — they change how it &lt;em&gt;lies down&lt;/em&gt; on disk, and Parquet is ferociously opinionated about lying down.&lt;/p&gt;

&lt;p&gt;Somewhere in your data lake is a table that's three times bigger than it needs to be, wearing a trench coat. You now know exactly how to take the coat off.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Further reading&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://parquet.apache.org/docs/" rel="noopener noreferrer"&gt;Apache Parquet format specification&lt;/a&gt; — readable, surprisingly short, and the encodings section will click now that you know why they exist.&lt;/li&gt;
&lt;li&gt;Google's &lt;a href="https://research.google/pubs/pub36632/" rel="noopener noreferrer"&gt;Dremel paper (2010)&lt;/a&gt; — where columnar-at-scale and nested-data-via-levels were proven out.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://duckdb.org/docs/lts/data/parquet/tips.html" rel="noopener noreferrer"&gt;DuckDB's Parquet tips&lt;/a&gt; — the practical writer-side guidance quoted in the lifehacks section. Their &lt;a href="https://duckdb.org/2025/01/22/parquet-encodings" rel="noopener noreferrer"&gt;Parquet encodings post&lt;/a&gt; and &lt;a href="https://duckdb.org/2025/03/07/parquet-bloom-filters-in-duckdb" rel="noopener noreferrer"&gt;bloom filters post&lt;/a&gt; are excellent companions.&lt;/li&gt;
&lt;li&gt;Your favorite table format's docs (Iceberg, Delta, Hudi) — note how each one is, at bottom, a very clever pile of Parquet files plus a log.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dataengineering</category>
      <category>bigdata</category>
      <category>parquet</category>
    </item>
    <item>
      <title>Kimball's Last Hard Problem: When There Is No Right Grain</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:29:05 +0000</pubDate>
      <link>https://dev.to/nbaubek/kimballs-last-hard-problem-when-there-is-no-right-grain-3lbk</link>
      <guid>https://dev.to/nbaubek/kimballs-last-hard-problem-when-there-is-no-right-grain-3lbk</guid>
      <description>&lt;p&gt;Three people walk into the same board meeting with three different numbers for the same deal, and all three are right.&lt;/p&gt;

&lt;p&gt;Sales says $648,000 — the full three-year contract Tabby just signed with PetCo Org, a vet chain rolling out cat trackers across 12 locations. Finance says $18,000 — the revenue actually recognized so far, because most of those locations haven't gone live yet and you can't book revenue for service you haven't delivered. Customer Success says 8 of 12 — not a dollar figure at all, but the fraction of locations actually onboarded and using the product, which is what predicts whether this account renews. Nobody in that room is lying, confused, or bad at their job. They're answering three different questions that happen to sound like the same question.&lt;/p&gt;

&lt;p&gt;Every article in this series so far has had a textbook answer once you found the right technique — grain, SCD2, accumulating snapshots, bridge tables. This one doesn't. It's the case the first four were building toward: what do you actually do when the "correct" grain depends entirely on who's asking, and picking one is itself a decision you have to be able to defend.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 60-second recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grain&lt;/strong&gt; = the exact definition of one fact row. Different real questions at different grains get different fact tables — a rule this series has invoked at every stop: &lt;a href="https://dev.to/nbaubek/kimball-dimensional-modeling-explained-through-a-coffee-shop-2cnl"&gt;the coffee shop's&lt;/a&gt; order lines vs. daily snapshots, Tabby's lifecycle vs. monthly MRR, &lt;a href="https://dev.to/nbaubek/kimball-for-order-fulfillment-milestones-split-shipments-and-facts-that-arrive-late-3llc"&gt;fulfillment's orders vs. shipments&lt;/a&gt;, &lt;a href="https://dev.to/nbaubek/kimball-for-many-to-many-bridge-tables-weighting-factors-and-the-diagnosis-code-problem-3ei7"&gt;claims vs. diagnosis bridges&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCD Type 2&lt;/strong&gt; preserves history so a fact always joins to the dimension row that was true when the event happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accumulating snapshots&lt;/strong&gt; update one row per entity across milestones; &lt;strong&gt;periodic snapshots&lt;/strong&gt; take regular photos; &lt;strong&gt;semi-additive measures&lt;/strong&gt; can be summed across some dimensions but not across time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bridge tables&lt;/strong&gt; resolve genuine many-to-many relationships without inventing numbers through a naive join.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What none of the last four articles said out loud: every fact table in this series has shared the same &lt;code&gt;dim_date&lt;/code&gt;, and most have shared the same &lt;code&gt;dim_account&lt;/code&gt;. That's not incidental — it's the thing that makes what happens in this article possible at all, and it finally gets a name below.&lt;/p&gt;




&lt;h2&gt;
  
  
  Meet the conflict
&lt;/h2&gt;

&lt;p&gt;Tabby (&lt;a href="https://dev.to/nbaubek/kimball-for-saas-subscriptions-mrr-and-churn-modeled-right-2min"&gt;Part 2&lt;/a&gt;'s cat-collar SaaS company) just closed PetCo Org — a 12-location vet chain, three-year contract, $648,000 total contract value, $18,000/month once every location is fully live. The contract was signed two months ago. Locations are onboarding in waves: 4 went live in month one, 4 more in month two, the remaining 4 are scheduled for month three.&lt;/p&gt;

&lt;p&gt;Three teams need a number for this deal, and none of them are asking for the same thing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stakeholder&lt;/th&gt;
&lt;th&gt;Question they're actually asking&lt;/th&gt;
&lt;th&gt;Their answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sales&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"How much did we sell, for commission and pipeline purposes?"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$648,000&lt;/strong&gt; — full contract value, credited at signature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Finance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"How much revenue have we actually earned so far?"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$18,000&lt;/strong&gt; — ratable recognition, only for location-months of service actually delivered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Customer Success&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Is this account actually succeeding, right now?"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;8 of 12 locations active&lt;/strong&gt; — not a dollar figure at all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the units before anything else: two dollar figures thirty-six times apart, and a fraction that isn't a dollar figure. Forcing these into one number isn't hard because the math is hard — it's a category error, the same way "what's the average of a distance and a color" is a category error. There is no arithmetic that turns $648,000, $18,000, and 67% into one honest figure, because they were never measuring the same thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why each one is correct, on its own terms
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Sales is correct&lt;/strong&gt; because commission and pipeline reporting exist to measure and reward the act of closing the deal — the moment the contract was signed is the event, and its full value is the relevant fact, regardless of how long delivery takes. Waiting three years to credit a three-year deal would break sales compensation entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finance is correct&lt;/strong&gt; because revenue recognition rules exist specifically to prevent booking revenue for service not yet delivered — recognizing $648,000 today would overstate the company's earnings by the entire undelivered two years and ten months of the contract, which is not a rounding error, it's the difference between real and fictional financial statements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customer Success is correct&lt;/strong&gt; because renewal risk tracks with product usage and onboarding health, not with contract value or accounting timing — a fully-recognized, fully-paid contract where nobody's using the product is a churn risk regardless of what Finance's ledger says.&lt;/p&gt;

&lt;p&gt;None of these is a rougher approximation of one true number. They're three different, equally precise answers to three different questions.&lt;/p&gt;




&lt;h2&gt;
  
  
  What breaks if you force one grain to answer all three
&lt;/h2&gt;

&lt;p&gt;It's worth actually trying each option, because the failure modes are the argument, not just an assertion:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Force everything to contract grain (Sales' shape).&lt;/strong&gt; One row per contract, $648,000. Finance now has no way to recognize revenue ratably — the number either overstates earned revenue on day one or requires bolting a second, contradictory recognition schedule onto a table whose whole point was "one clean number per deal." Customer Success has nothing at all — a contract-grain table has no concept of "location," so activation tracking doesn't exist in this model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Force everything to subscription-month grain (Finance's shape, and it already exists — &lt;code&gt;fact_subscription_month&lt;/code&gt; from Part 2).&lt;/strong&gt; Sales' $648,000 evaporates — a monthly snapshot only ever shows revenue for months that have already been recognized, so on day one of a three-year deal, this view of the world shows nothing, which is exactly backwards from what commission tracking needs. Customer Success again has no location-level detail, because the grain is subscription-month, not subscription-location-month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Force everything to location-activation grain (Customer Success's shape).&lt;/strong&gt; Now there's no way to attach a dollar figure at all without allocating the $648,000 across 12 locations and however many months — which reintroduces the exact allocation problem &lt;a href="https://dev.to/nbaubek/kimball-for-many-to-many-bridge-tables-weighting-factors-and-the-diagnosis-code-problem-3ei7"&gt;Part 4&lt;/a&gt; spent an entire article on, for a question (activation health) that never needed a dollar figure to begin with.&lt;/p&gt;

&lt;p&gt;Every single-grain option doesn't just make one team's job harder — it makes their question structurally unanswerable inside that model, not just inconvenient.&lt;/p&gt;




&lt;h2&gt;
  
  
  The resolution: three fact tables, one shared dimension
&lt;/h2&gt;

&lt;p&gt;The fix isn't a fourth, cleverer grain. It's building all three, and linking them through a dimension every one of them can reference — without any of them referencing &lt;em&gt;each other&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dim_contract&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;contract_sk&lt;/span&gt;           &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;contract_id&lt;/span&gt;           &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;account_sk&lt;/span&gt;            &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;signed_date&lt;/span&gt;           &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;term_months&lt;/span&gt;           &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total_contract_value&lt;/span&gt;  &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;location_count&lt;/span&gt;        &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sales_rep_sk&lt;/span&gt;          &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_employee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;employee_sk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Sales gets &lt;code&gt;fact_booking&lt;/code&gt;&lt;/strong&gt; — one row per signing event, referencing &lt;code&gt;dim_contract&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_booking&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;booking_sk&lt;/span&gt;    &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;contract_sk&lt;/span&gt;   &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_contract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contract_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;account_sk&lt;/span&gt;    &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;booked_date_sk&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;booking_type&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- NEW / RENEWAL / AMENDMENT / UPSELL&lt;/span&gt;
    &lt;span class="n"&gt;booked_value&lt;/span&gt;  &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Finance keeps using &lt;code&gt;fact_subscription_month&lt;/code&gt;&lt;/strong&gt; from Part 2 — no new table needed, just a new foreign key added so it can be tied back to the deal that produced it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_subscription_month&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;contract_sk&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_contract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contract_sk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Customer Success gets &lt;code&gt;fact_location_activation&lt;/code&gt;&lt;/strong&gt; — an accumulating snapshot in the same style as &lt;a href="https://dev.to/nbaubek/kimball-for-order-fulfillment-milestones-split-shipments-and-facts-that-arrive-late-3llc"&gt;Part 3&lt;/a&gt;'s fulfillment milestones, one row per location within the contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_location_activation&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;activation_sk&lt;/span&gt;               &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;contract_sk&lt;/span&gt;                 &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_contract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contract_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;location_sk&lt;/span&gt;                 &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_location&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;scheduled_activation_date_sk&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;actual_activation_date_sk&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;current_status&lt;/span&gt;               &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;   &lt;span class="c1"&gt;-- SCHEDULED / ACTIVE / DELAYED&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frsr765d1iylzlacdy7hs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frsr765d1iylzlacdy7hs.png" alt="dim_contract at the center, with fact_booking, fact_subscription_month, and fact_location_activation radiating out at three different grains, each producing its own labeled number: $648,000, $18,000, and 8 of 12" width="800" height="357"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;Notice what's &lt;em&gt;not&lt;/em&gt; here: no fact table has a foreign key into another fact table. &lt;code&gt;fact_location_activation&lt;/code&gt; doesn't reference &lt;code&gt;fact_booking&lt;/code&gt;; &lt;code&gt;fact_subscription_month&lt;/code&gt; doesn't reference &lt;code&gt;fact_location_activation&lt;/code&gt;. They're linked only through &lt;code&gt;dim_contract&lt;/code&gt; — a &lt;strong&gt;conformed dimension&lt;/strong&gt;, the formal name for exactly this pattern. It's the same idea &lt;code&gt;dim_date&lt;/code&gt; has been quietly doing since the first article's role-playing order-date/pickup-date trick: the same dimension, referenced by multiple fact tables at different grains, is what lets you query across them without ever needing them to share a grain, or even be joinable to each other directly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three "what's the number" queries
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Sales: bookings&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;booked_value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_booking&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_contract&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contract_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contract_sk&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contract_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PETCO_2026_01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- $648,000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Finance: recognized revenue to date&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;recognized_revenue_to_date&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_subscription_month&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_contract&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contract_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contract_sk&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contract_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PETCO_2026_01'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month_sk&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;20260101&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;20260228&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- $6,000 (month 1, 4 locations) + $12,000 (month 2, 8 locations) = $18,000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth a pause on that second query: summing &lt;code&gt;mrr&lt;/code&gt; across two consecutive months for the same subscription is only meaningful because it's being read as "revenue recognized," not as "MRR." The exact same column, summed the exact same way, would be nonsense if the question were "what's the account's MRR across January and February" — you wouldn't add two run-rates together and call it a run-rate. &lt;a href="https://dev.to/nbaubek/kimball-for-order-fulfillment-milestones-split-shipments-and-facts-that-arrive-late-3llc"&gt;Part 3&lt;/a&gt; named this problem for a count that couldn't be summed across time at all; here it's sharper still — the &lt;em&gt;same number&lt;/em&gt; is fully additive under one interpretation and meaningless under another, depending on which question you're actually asking of it. That's worth remembering any time a measure gets summed across a date range: check what you're claiming the sum means, not just whether the arithmetic runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Customer Success: activation&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;current_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACTIVE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;locations_active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;locations_total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_location_activation&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_contract&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contract_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contract_sk&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contract_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PETCO_2026_01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 8 of 12&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three queries, same &lt;code&gt;dim_contract&lt;/code&gt;, three genuinely different answers — none of them wrong, none of them reconcilable into each other by any transformation, because they were never the same measurement.&lt;/p&gt;




&lt;h2&gt;
  
  
  The governance move: name the metrics, don't blend them
&lt;/h2&gt;

&lt;p&gt;The technical fix is three fact tables. The organizational fix — the part that actually prevents this from becoming a recurring argument — is refusing to let anyone build a dashboard that presents these as one number. The way to do that concretely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- An executive summary view: three named, defined metrics, never blended&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'Bookings (Sales)'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="s1"&gt;'$648,000'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="s1"&gt;'Full 3-year contract value, credited at signature'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;definition&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'Recognized Revenue to Date (Finance)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="s1"&gt;'$18,000'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="s1"&gt;'Ratable revenue for location-months of service actually delivered'&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'Location Activation (Customer Success)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="s1"&gt;'8 of 12 (67%)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="s1"&gt;'Locations live and actively using the product as of today'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't a workaround for not having a single number — it &lt;em&gt;is&lt;/em&gt; the answer. "Bookings," "Recognized Revenue," and "Activation Rate" become three named, documented metrics in whatever metrics glossary or semantic layer the company uses, each with an owner and a definition, structurally incapable of being confused for one another because they're never presented as the same field. The failure mode this prevents isn't a technical one — it's someone building a dashboard that labels a column just "Revenue" and quietly picks whichever of the three numbers happens to be sitting in whatever table they joined to first.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note on the whole series: conformed dimensions were always the point
&lt;/h2&gt;

&lt;p&gt;Every fact table across all five articles in this series — &lt;code&gt;fact_order_line&lt;/code&gt;, &lt;code&gt;fact_subscription_month&lt;/code&gt;, &lt;code&gt;fact_order_lifecycle&lt;/code&gt;, &lt;code&gt;fact_claim_line&lt;/code&gt;, and the three built above — has shared the same &lt;code&gt;dim_date&lt;/code&gt;. Most have shared &lt;code&gt;dim_account&lt;/code&gt; or its equivalent. That repetition wasn't incidental; it's the single idea that makes a dimensional model more than a collection of unrelated tables.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmcqabnsgqnfx0k1gtoj9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmcqabnsgqnfx0k1gtoj9.png" alt="dim_date and dim_account as shared hubs, with fact_order_line, fact_subscription_month, fact_order_lifecycle, fact_claim_line, and this article's three new fact tables all radiating out from the same two conformed dimensions at completely different grains" width="800" height="102"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;A &lt;strong&gt;conformed dimension&lt;/strong&gt; is a dimension built once, with one consistent set of keys and attributes, and reused across every fact table that needs it. It's what lets &lt;a href="https://dev.to/nbaubek/kimball-dimensional-modeling-explained-through-a-coffee-shop-2cnl"&gt;a coffee shop's&lt;/a&gt; daily sales and a SaaS company's MRR waterfall both be sliced by "quarter" using the exact same &lt;code&gt;dim_date&lt;/code&gt;, and it's what let three fact tables at three irreconcilable grains sit next to each other in this article without contradiction. The grain of a fact table answers "what is one row." The conformed dimensions answer the question this whole series has actually been building toward: how do a dozen fact tables, at a dozen different grains, built at different times by different teams, still add up to one coherent model instead of a pile of disconnected spreadsheets with SQL in front of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treating "which number is right" as a technical question.&lt;/strong&gt; It's a scope question — right for what audience, right for what decision. The fix is naming the metric precisely enough that "right" stops being ambiguous, not searching harder for a formula that reconciles $648,000 and $18,000.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Building the dashboard leadership asked for instead of the one that's honest.&lt;/strong&gt; "Just give me one number for the deal" is a request that will get answered whether or not you push back — better to hand over three clearly labeled numbers than one blended, quietly wrong one that leadership will eventually catch and stop trusting.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Letting a fact table reference another fact table instead of a shared dimension.&lt;/strong&gt; It's tempting to just point &lt;code&gt;fact_location_activation.booking_sk&lt;/code&gt; at &lt;code&gt;fact_booking&lt;/code&gt; directly — it even works, mechanically. It also means the two tables' futures are now coupled for no reason: if bookings ever need a second grain (amendments as their own rows, say), every downstream reference into the old grain has to be found and fixed. Route through the dimension.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Summing a measure across time without checking what the sum means.&lt;/strong&gt; The &lt;code&gt;mrr&lt;/code&gt;-as-revenue example above: right when read as recognized revenue, meaningless when read as a run-rate. The column doesn't tell you which one you're doing — you have to know the question.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Resolving the conflict once, informally, in a meeting, and not writing it down.&lt;/strong&gt; Whatever gets agreed about which team owns which metric needs to live in a metrics glossary, not in the memory of whoever was in that board meeting — the alternative is having the same argument again in two quarters with different people in the room.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Exercises
&lt;/h2&gt;

&lt;p&gt;This is the last set in the series, and there's no answer key for the first one — that's the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1.&lt;/strong&gt; A professional services company bills clients by the hour but pays consultants a fixed salary. The PM wants project profitability tracked by &lt;strong&gt;project phase&lt;/strong&gt; (discovery, build, delivery). Finance wants it by &lt;strong&gt;invoice&lt;/strong&gt;, since that's what's actually billed and collectible. Resourcing wants it by &lt;strong&gt;consultant-week&lt;/strong&gt;, since that's what determines who's overbooked next month. Design the fact tables. Which dimension conforms all three?&lt;/p&gt;

&lt;p&gt;Hint — not a full solution, on purpose&lt;br&gt;
  Start by writing each stakeholder's actual question in one sentence, the way this article did for Sales/Finance/CS, before touching a schema. If you can't state the question precisely, you can't design the grain for it. A conformed &lt;code&gt;dim_project&lt;/code&gt; or &lt;code&gt;dim_engagement&lt;/code&gt; is a likely candidate — but defend it against at least one alternative before you commit, the way this article walked through what breaks under each single-grain option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.&lt;/strong&gt; Revisit Part 4's healthcare claims model. Sales credits an insurance broker for signing a new employer group; Finance recognizes premium revenue ratably over the policy period; a Care Management team tracks which specific members within the group have actually completed onboarding health screenings. Sketch the three fact tables and the dimension that conforms them, following the pattern in this article.&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  This is structurally the same shape as the PetCo Org case — a group-level commitment (the employer contract), a ratable financial recognition, and a member-level activity tracker — with &lt;code&gt;dim_employer_group&lt;/code&gt; or an equivalent doing the same job &lt;code&gt;dim_contract&lt;/code&gt; did here.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing: the whole series in one paragraph
&lt;/h2&gt;

&lt;p&gt;Five articles, one underlying argument: a dimensional model isn't a diagram of your data, it's a set of decisions about what one row means, made explicitly enough that two people looking at the same fact table agree on what it's telling them. Grain is the first and most important of those decisions. SCD2 handles the decision changing over time. Accumulating snapshots handle a decision that takes multiple steps to resolve. Bridge tables handle a decision that legitimately has more than one right answer &lt;em&gt;within a single fact row&lt;/em&gt;. And this article handles the case where the decision doesn't have one right answer &lt;em&gt;at all&lt;/em&gt; — where the correct move isn't picking, it's building enough separately-grained, honestly-labeled fact tables, conformed through shared dimensions, that nobody has to lie to get an answer. That's the actual skill underneath all the SQL: not knowing the four fact table types, but knowing which one — or which three — a real, messy, human question actually needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ralph Kimball &amp;amp; Margy Ross, *The Data Warehouse Toolkit&lt;/strong&gt;* — chapter 4 covers conformed dimensions and the bus matrix directly; it's the concept this entire series has been resting on since &lt;a href="https://dev.to/nbaubek/kimball-dimensional-modeling-explained-through-a-coffee-shop-2cnl"&gt;Part 1&lt;/a&gt;'s &lt;code&gt;dim_date&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/conformed-dimensions/" rel="noopener noreferrer"&gt;Kimball Group — Conformed Dimensions&lt;/a&gt;&lt;/strong&gt; — the canonical short reference.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;That's the series. If you read all five, you now have the fundamentals, the SaaS extensions, late-arriving facts, many-to-many relationships, and the judgment call none of the others could hand you a formula for. The companion repo has the full schema and exercises for all five parts, start to finish. Go build something someone else's team can actually trust.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>datamodeling</category>
      <category>datawarehouse</category>
      <category>kimball</category>
    </item>
    <item>
      <title>Kimball for Many-to-Many: Bridge Tables, Weighting Factors, and the Diagnosis Code Problem</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:27:28 +0000</pubDate>
      <link>https://dev.to/nbaubek/kimball-for-many-to-many-bridge-tables-weighting-factors-and-the-diagnosis-code-problem-3ei7</link>
      <guid>https://dev.to/nbaubek/kimball-for-many-to-many-bridge-tables-weighting-factors-and-the-diagnosis-code-problem-3ei7</guid>
      <description>&lt;p&gt;Everything so far in this series has been one-to-many, and cleanly so: one order to many order lines, one account to many months, one order to many shipments. Every fact row had exactly one of each dimension it referenced. That assumption holds until it doesn't — a single insurance claim can carry three diagnosis codes, a bank account can have two joint owners, a sales transaction can be attributed to more than one promotion at once. None of those are edge cases to shrug off; they're a genuinely different relationship shape, and modeling them like a one-to-many relationship produces numbers that are quietly, confidently wrong.&lt;/p&gt;

&lt;p&gt;This is the pattern Ralph Kimball himself used a healthcare example to introduce, for good reason — it's where the problem is most obvious and the fix is most instructive.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 60-second recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fact tables&lt;/strong&gt; hold events. &lt;em&gt;Transaction&lt;/em&gt;, &lt;em&gt;periodic snapshot&lt;/em&gt;, &lt;em&gt;accumulating snapshot&lt;/em&gt; (updated in place across milestones), &lt;em&gt;factless&lt;/em&gt; (no measures — row existence is the fact).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grain&lt;/strong&gt; = the exact definition of one fact row, stated as a sentence, before anything else. Different real questions at different grains get different fact tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCD Type 2&lt;/strong&gt; preserves history with &lt;code&gt;valid_from&lt;/code&gt;/&lt;code&gt;valid_to&lt;/code&gt;/&lt;code&gt;is_current&lt;/code&gt; rows, so a fact always joins to the dimension row that was true when the event happened.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/nbaubek/kimball-for-order-fulfillment-milestones-split-shipments-and-facts-that-arrive-late-3llc"&gt;Part 3&lt;/a&gt; added late-arriving fact handling and named &lt;strong&gt;semi-additive measures&lt;/strong&gt; — summable across some dimensions, not across time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;New for this article: dimensions that are legitimately &lt;em&gt;multivalued&lt;/em&gt; for a single fact row, and the specific trap that shows up the moment you try to sum a dollar amount through one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Meet Meadowlark Health
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Meadowlark Health&lt;/strong&gt; processes insurance claims. The detail that makes this article necessary: a single claim routinely carries &lt;strong&gt;more than one diagnosis code&lt;/strong&gt; (ICD-10 codes — &lt;code&gt;E11.9&lt;/code&gt; for Type 2 diabetes, &lt;code&gt;I10&lt;/code&gt; for hypertension, and so on), because patients frequently present with more than one condition at a visit. The fact table Meadowlark needs is &lt;code&gt;fact_claim_line&lt;/code&gt; — one row per billed service line on a claim — and every one of those rows can legitimately be &lt;em&gt;about&lt;/em&gt; two or three diagnoses at once, not one.&lt;/p&gt;

&lt;p&gt;That's the whole problem in one sentence: &lt;strong&gt;the fact-to-diagnosis relationship is many-to-many, and a standard dimensional model has no native way to express that.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The fixed-width hack, again
&lt;/h2&gt;

&lt;p&gt;The instinctive fix looks exactly like the one flagged as a mistake in the fulfillment article, just wearing a different column name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Don't do this&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_claim_line&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;claim_line_sk&lt;/span&gt;    &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;claim_id&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;member_sk&lt;/span&gt;        &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;member_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;provider_sk&lt;/span&gt;      &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;service_date_sk&lt;/span&gt;  &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;diagnosis_1_sk&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_diagnosis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diagnosis_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;diagnosis_2_sk&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_diagnosis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diagnosis_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;diagnosis_3_sk&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_diagnosis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diagnosis_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;billed_amount&lt;/span&gt;    &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This breaks for the same structural reason &lt;code&gt;shipment_2_carrier&lt;/code&gt; did in the last article: it's a fixed guess at a variable-length list. A claim with a fourth diagnosis has nowhere to go. &lt;code&gt;WHERE diagnosis_1_sk = :code OR diagnosis_2_sk = :code OR diagnosis_3_sk = :code&lt;/code&gt; has to be repeated at every column, forever, and gets silently wrong the day someone adds a &lt;code&gt;diagnosis_4_sk&lt;/code&gt; and forgets to update every query that predates it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bridge table
&lt;/h2&gt;

&lt;p&gt;Kimball's own name for the fix, when he first wrote it up using this exact example, was the &lt;strong&gt;Diagnosis Group table&lt;/strong&gt; — what's generally called a &lt;strong&gt;bridge table&lt;/strong&gt; today. Instead of the fact row pointing at diagnosis codes directly, it points at a &lt;em&gt;group&lt;/em&gt;, and the group resolves to however many diagnoses actually apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_claim_line&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;claim_line_sk&lt;/span&gt;       &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;claim_id&lt;/span&gt;            &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;member_sk&lt;/span&gt;           &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;member_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;provider_sk&lt;/span&gt;         &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;service_date_sk&lt;/span&gt;     &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;diagnosis_group_sk&lt;/span&gt;  &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;bridge_diagnosis_group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diagnosis_group_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;billed_amount&lt;/span&gt;       &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bridge_diagnosis_group&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;diagnosis_group_sk&lt;/span&gt;  &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;diagnosis_sk&lt;/span&gt;        &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_diagnosis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diagnosis_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;weighting_factor&lt;/span&gt;     &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- fractions of this claim attributable to this code, summing to 1.0 per group&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diagnosis_group_sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;diagnosis_sk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;diagnosis_group_sk&lt;/code&gt; can resolve to one row in the bridge (a claim with a single diagnosis) or several (a claim with three). Adding a fourth diagnosis to a future claim needs nothing more than another bridge row with the same group key — no schema change, no new column, no query that has to be found and patched.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F57h1qg97ifur6i1trgb8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F57h1qg97ifur6i1trgb8.png" alt="A claim line pointing at a diagnosis_group_sk, which resolves through the bridge table to three diagnosis codes with weighting factors 0.5, 0.3, and 0.2 that sum to 1.0" width="799" height="317"&gt;&lt;/a&gt;&lt;/p&gt;






&lt;h2&gt;
  
  
  Why the weighting factor exists: you cannot just join and sum
&lt;/h2&gt;

&lt;p&gt;Here's the trap the bridge table alone doesn't save you from. Say claim line &lt;code&gt;CL_1001&lt;/code&gt; has &lt;code&gt;billed_amount = 500.00&lt;/code&gt; and three diagnoses in its group. Join the fact straight to the bridge and group by diagnosis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Wrong: naive join, no weighting&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;billed_amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_billed&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_claim_line&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;bridge_diagnosis_group&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_group_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_group_sk&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_diagnosis&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;           &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_sk&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;claim_line_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;cl_1001&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- E11.9: $500.00&lt;/span&gt;
&lt;span class="c1"&gt;-- I10:   $500.00&lt;/span&gt;
&lt;span class="c1"&gt;-- Z79.4: $500.00   &amp;lt;- $1,500 conjured out of a $500 claim&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The join fans one fact row out into three rows — one per bridge match — and &lt;code&gt;billed_amount&lt;/code&gt; comes along unchanged on each of them. Sum across diagnoses and you've invented a thousand dollars that never existed. This is the exact failure mode every source on this pattern warns about, and it's not a hypothetical: it's what happens the first time anyone builds this query without knowing the bridge is there.&lt;/p&gt;

&lt;p&gt;The weighting factor exists to fix exactly this — multiply, don't just sum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Correct: multiply by the weighting factor before summing&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;billed_amount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weighting_factor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;allocated_billed&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_claim_line&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;bridge_diagnosis_group&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_group_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_group_sk&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_diagnosis&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;           &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_sk&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;claim_line_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;cl_1001&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- E11.9: $250.00  (0.5 * 500)&lt;/span&gt;
&lt;span class="c1"&gt;-- I10:   $150.00  (0.3 * 500)&lt;/span&gt;
&lt;span class="c1"&gt;-- Z79.4: $100.00  (0.2 * 500)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the three rows sum back to exactly $500. As long as every group's weighting factors sum to 1.0, this generalizes cleanly to &lt;code&gt;SUM(billed_amount * weighting_factor)&lt;/code&gt; &lt;strong&gt;across every claim at once&lt;/strong&gt;, grouped however you like by diagnosis attributes — the allocation math holds up in aggregate, not just for one claim examined by hand.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the weighting factor breaks
&lt;/h2&gt;

&lt;p&gt;This is worth stating plainly rather than leaving implicit, because it's the part that isn't obvious until it bites: &lt;strong&gt;the weighting factor correctly answers "how much is attributable to diagnosis X," but it does not correctly answer "how much is attributable to claims with both diagnosis X and diagnosis Y."&lt;/strong&gt; Ask for the combined total across two specific codes together, and the weighted sum double-counts any claim that carries both — because each code's allocated share was computed independently, not jointly. There's no clean fix inside the weighting-factor pattern itself for that specific question; it requires a different query shape (typically: find the claims meeting &lt;em&gt;both&lt;/em&gt; conditions first via &lt;code&gt;EXISTS&lt;/code&gt;/&lt;code&gt;INTERSECT&lt;/code&gt;, then sum their full &lt;code&gt;billed_amount&lt;/code&gt; once, unweighted). If your organization asks combination-of-codes questions often, that's a sign the weighting factor alone won't cover everything you need, not that it's implemented wrong.&lt;/p&gt;

&lt;p&gt;One more legitimate variant worth knowing: &lt;strong&gt;you can deliberately drop the weighting factor&lt;/strong&gt; if what you actually want is an &lt;em&gt;impact report&lt;/em&gt; — "total billed amount touched by any contagious diagnosis," where a claim with two contagious codes intentionally counting twice reflects "this much billing activity involved a contagious condition" rather than "this much money was caused by it." That's a real, valid report shape. It just has to be labeled as one, clearly, so nobody downstream mistakes an intentionally inflated total for a financial figure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Not every bridge needs a weighting factor
&lt;/h2&gt;

&lt;p&gt;Meadowlark also has group health plans where &lt;strong&gt;one policy covers multiple dependents&lt;/strong&gt; — another genuine many-to-many, member-to-policy this time instead of claim-to-diagnosis. But nobody's summing a dollar amount across dependents the way they sum billed amount across diagnoses; the question is usually just "who's covered under this policy," a membership list, not an allocation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bridge_policy_member&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;policy_sk&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;policy_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;member_sk&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;member_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;relationship&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;   &lt;span class="c1"&gt;-- SUBSCRIBER / SPOUSE / DEPENDENT&lt;/span&gt;
    &lt;span class="c1"&gt;-- no weighting_factor: nothing numeric fans out through this bridge&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distinction worth keeping straight: &lt;strong&gt;a weighting factor is only needed when a numeric measure from the fact table would otherwise double-count as it fans out through the bridge.&lt;/strong&gt; A bridge used purely to list or filter membership — no fact-table measure passing through it — doesn't need one. Don't add a weighting factor out of habit; add it when there's a &lt;code&gt;SUM()&lt;/code&gt; that would otherwise lie.&lt;/p&gt;

&lt;p&gt;Kimball's own bank-account example follows the same membership shape as the policy/dependent case — multiple customers jointly owning one account — and it comes with a related wrinkle worth flagging: &lt;strong&gt;a bridge table often needs to sit on top of Type 2 dimensions on both sides.&lt;/strong&gt; If a dependent is added to a policy mid-year, or a diagnosis code's own description gets revised by a coding-standard update, the bridge row needs to point at whichever dimension row (&lt;code&gt;policy_sk&lt;/code&gt;, &lt;code&gt;diagnosis_sk&lt;/code&gt;) was actually valid on the date the relationship applied — the same "as-of" logic from the SCD2 sections of Parts 1 and 2, just one join further away.&lt;/p&gt;




&lt;h2&gt;
  
  
  Factless facts, revisited: a bridge without any fact at all
&lt;/h2&gt;

&lt;p&gt;Two earlier articles used factless facts for coverage/eligibility — Bean &amp;amp; Stalk's drink availability, Tabby's feature entitlements. Meadowlark has a version that's also genuinely many-to-many: &lt;strong&gt;which providers are in-network for which plan, as of which date.&lt;/strong&gt; A provider can be in-network for several plans; a plan covers several providers. No dollar amount is attached to the relationship itself — it either holds on a given day or it doesn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_network_coverage&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;coverage_sk&lt;/span&gt;       &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;provider_sk&lt;/span&gt;        &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;plan_sk&lt;/span&gt;            &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;effective_date_sk&lt;/span&gt;  &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider_sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan_sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;effective_date_sk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;-- factless: the row's existence is the fact&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't a bridge table in the strict Kimball sense — it's a factless fact that happens to &lt;em&gt;resolve&lt;/em&gt; a many-to-many relationship on its own, because neither side needs to be the fact table's single grain-defining dimension. Worth noticing that "bridge table" and "factless fact" are answers to two different questions — &lt;em&gt;how do I represent a multivalued dimension attached to a fact&lt;/em&gt; versus &lt;em&gt;how do I record that something was true without a number attached&lt;/em&gt; — and they can combine, as they do here, without either one being a special case of the other.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4d02cp509qxjjepvva7c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4d02cp509qxjjepvva7c.png" alt="Two bridge patterns side by side: a claim fanning out through a weighted bridge to diagnosis codes with dollar amounts that must sum correctly, versus a policy fanning out through an unweighted bridge to member names with no numeric measure involved" width="800" height="1947"&gt;&lt;/a&gt;&lt;/p&gt;






&lt;h2&gt;
  
  
  A real query
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Total billed amount by diagnosis category, correctly allocated, for claims in the last quarter:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;billed_amount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weighting_factor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;allocated_billed&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_claim_line&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;bridge_diagnosis_group&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_group_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_group_sk&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_diagnosis&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;           &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_sk&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;                &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;service_date_sk&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quarter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;year&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnosis_category&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;allocated_billed&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Members covered under a given policy, as of today (membership bridge, no allocation needed):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;member_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relationship&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bridge_policy_member&lt;/span&gt; &lt;span class="n"&gt;bp&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_member&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;member_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;member_sk&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;bp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;policy_sk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the second query never touches a weighting factor at all — a plain join is correct here, because nothing numeric is fanning out. That contrast is the whole lesson of this article in two queries.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Joining fact to bridge and summing without multiplying by the weighting factor.&lt;/strong&gt; The single most common way this pattern gets implemented wrong — the join looks completely correct, the query runs without error, and the total is simply too large. Nothing about the SQL signals the bug; only the number does.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Adding a weighting factor to a bridge that doesn't need one.&lt;/strong&gt; A pure membership bridge with a &lt;code&gt;weighting_factor&lt;/code&gt; column invites someone to multiply by it out of habit, which — if it doesn't sum to something meaningful per group — introduces a new bug in the other direction.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Asking a combination-of-codes question against a weighted single-code answer.&lt;/strong&gt; Covered above: "billed for X" and "billed for X and Y together" are different questions, and the weighting factor only correctly answers the first one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Forgetting the fixed-width hack is the same mistake as &lt;code&gt;shipment_2_carrier&lt;/code&gt;.&lt;/strong&gt; Any time a schema has &lt;code&gt;_1&lt;/code&gt;, &lt;code&gt;_2&lt;/code&gt;, &lt;code&gt;_3&lt;/code&gt; suffixed columns for "as many as we've seen so far," that's a bridge table that hasn't been built yet.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pointing a bridge at a Type 1 dimension when history matters.&lt;/strong&gt; If policy membership or diagnosis descriptions change over time and the bridge points at the current row regardless of when the relationship applied, historical reports quietly use today's data for yesterday's events — the same SCD1-where-you-needed-SCD2 mistake from every earlier article in this series, one hop further away.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Exercises
&lt;/h2&gt;

&lt;p&gt;Hints hidden; full solutions in solutions.sql in the companion repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1.&lt;/strong&gt; A claim line has a single diagnosis. Does it still need a &lt;code&gt;diagnosis_group_sk&lt;/code&gt; pointing at the bridge table, or can it point directly at &lt;code&gt;dim_diagnosis&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  Consider what happens to every downstream query if some claim lines use one pattern and others use a different one. A single-diagnosis claim can be modeled as a group of size one — same bridge, same query shape, weighting_factor = 1.0. Consistency usually wins over the minor storage savings of a special case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.&lt;/strong&gt; Write the query for the "impact report" described above: total billed amount touched by any claim carrying a diagnosis in the "contagious" category, intentionally double-counting claims with more than one such diagnosis. Label the output so it can't be mistaken for the allocated total.&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  Join fact to bridge to dim_diagnosis, filter to the contagious category, and sum &lt;code&gt;billed_amount&lt;/code&gt; unweighted — same shape as the first "wrong" query in this article, except this time the double-counting is the intended output, not a bug. Alias the column something like &lt;code&gt;impact_billed_amount_do_not_reconcile_to_gl&lt;/code&gt; to make the intent unmissable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3.&lt;/strong&gt; Write the query that correctly answers "total billed for claims carrying both E11.9 and I10," avoiding the weighting-factor trap described in this article.&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  Find the set of &lt;code&gt;diagnosis_group_sk&lt;/code&gt; values present in the bridge for &lt;em&gt;both&lt;/em&gt; codes (an &lt;code&gt;INTERSECT&lt;/code&gt; or a self-join with a &lt;code&gt;HAVING COUNT(DISTINCT diagnosis_sk) = 2&lt;/code&gt;), then sum the fact table's &lt;code&gt;billed_amount&lt;/code&gt; unweighted for claim lines in that set — once per claim, not once per diagnosis.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Every pattern so far — transaction facts, periodic snapshots, accumulating snapshots, factless facts, bridge tables — has had a textbook-correct answer once you knew which tool fit. &lt;a href="https://dev.to/nbaubek/kimballs-last-hard-problem-when-there-is-no-right-grain-3lbk"&gt;Part 5&lt;/a&gt;, the last in this series, doesn't. It's a deliberately unresolved case: three stakeholders, three defensible grains, three different numbers, and no answer in any book. That's where the actual judgment this series has been building toward gets tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ralph Kimball &amp;amp; Margy Ross, *The Data Warehouse Toolkit&lt;/strong&gt;* — the original source for the Diagnosis Group / weighting-factor pattern used throughout this article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/multivalued-dimension-bridge-table/" rel="noopener noreferrer"&gt;Kimball Group — Multivalued Dimensions and Bridge Tables&lt;/a&gt;&lt;/strong&gt; — the canonical short reference, including the Type 2 dimension interaction noted above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.kimballgroup.com/2014/05/design-tip-166-potential-bridge-table-detours/" rel="noopener noreferrer"&gt;Kimball Group — Design Tip #166: Potential Bridge Table Detours&lt;/a&gt;&lt;/strong&gt; — the usability and over-counting caveats covered in this article, from the source.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dataengineering</category>
      <category>datawarehouse</category>
      <category>datamodeling</category>
      <category>kimball</category>
    </item>
    <item>
      <title>Kimball for Order Fulfillment: Milestones, Split Shipments, and Facts That Arrive Late</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:23:19 +0000</pubDate>
      <link>https://dev.to/nbaubek/kimball-for-order-fulfillment-milestones-split-shipments-and-facts-that-arrive-late-3llc</link>
      <guid>https://dev.to/nbaubek/kimball-for-order-fulfillment-milestones-split-shipments-and-facts-that-arrive-late-3llc</guid>
      <description>&lt;p&gt;An order looks like it has two dates: placed, and delivered. Model it that way and the first split shipment breaks the table, the first late webhook corrupts the status column, and the first "how many orders are in transit right now" question has no good answer. An order isn't an event. It's a process — and a process with branches, delays, and messages that don't always arrive in the order they were sent.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/nbaubek/kimball-dimensional-modeling-explained-through-a-coffee-shop-2cnl"&gt;first article in this series&lt;/a&gt; covered the fundamentals through a coffee shop; the &lt;a href="https://dev.to/nbaubek/kimball-for-saas-subscriptions-mrr-and-churn-modeled-right-2min"&gt;second&lt;/a&gt; applied them to SaaS subscriptions. Both dealt with processes that are, underneath the complexity, well-behaved: a loyalty journey has one milestone after another, a subscription has one plan at a time. This article is about the less well-behaved case — multi-stage processes where the stages can fork, arrive out of sequence, or simply take a while to all finish. Order fulfillment is the canonical example, and it's where accumulating snapshots earn their keep for real.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 60-second recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fact tables&lt;/strong&gt; hold events (verbs). &lt;em&gt;Transaction&lt;/em&gt; (atomic, append-only), &lt;em&gt;periodic snapshot&lt;/em&gt; (regular photos), &lt;em&gt;accumulating snapshot&lt;/em&gt; (multi-stage journeys, one row per entity, updated in place as milestones happen), &lt;em&gt;factless&lt;/em&gt; (coverage/eligibility, no measures).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dimension tables&lt;/strong&gt; hold context (nouns) — who/what/where/when.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grain&lt;/strong&gt; = the exact definition of one fact row. State it in a sentence before you build anything. If two real questions need two different grains, build two fact tables — don't force one table to answer both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCD Type 2&lt;/strong&gt; preserves history: a new row per change, with &lt;code&gt;valid_from&lt;/code&gt;/&lt;code&gt;valid_to&lt;/code&gt;/&lt;code&gt;is_current&lt;/code&gt;, so historical facts join to the dimension row that was true &lt;em&gt;then&lt;/em&gt;, not today.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;New for this article: what happens when the "multi-stage journey" doesn't stay linear, and what to do when a measure genuinely can't be summed across time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Meet Crate Expectations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Crate Expectations&lt;/strong&gt; sells furniture and home goods online. The fulfillment shape that makes this article worth writing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Orders often contain items stocked in &lt;strong&gt;different warehouses&lt;/strong&gt; — a couch from the East warehouse, a lamp from the West one. One order, two shipments, as a matter of routine, not exception.&lt;/li&gt;
&lt;li&gt;Some items are &lt;strong&gt;backordered&lt;/strong&gt; and picked days after the rest of the order.&lt;/li&gt;
&lt;li&gt;Shipping is handled by two carriers (&lt;strong&gt;FastFreight&lt;/strong&gt; and &lt;strong&gt;RoadRunner Parcel&lt;/strong&gt;), each with its own webhook API notifying Crate Expectations of pickup, transit, and delivery events.&lt;/li&gt;
&lt;li&gt;Carrier webhooks are not reliable messengers: they retry, they queue behind rate limits, and — the part that actually breaks a naive model — &lt;strong&gt;they don't always arrive in the order the underlying events happened.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the spine of this article.&lt;/p&gt;




&lt;h2&gt;
  
  
  The grain fight: order, shipment, or package?
&lt;/h2&gt;

&lt;p&gt;Before any table, the usual question: what's one row?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One row per order.&lt;/strong&gt; Matches how the customer thinks about it. Clean for "how many orders did we place last month," "average time from order to delivery." Breaks the moment an order becomes two shipments — which date goes in &lt;code&gt;shipped_date&lt;/code&gt;, the first one or the last one? What if one shipment is delivered and the other is still backordered — is the order "delivered"?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One row per shipment.&lt;/strong&gt; Matches how the &lt;em&gt;warehouse and carriers&lt;/em&gt; think about it. A shipment has one warehouse, one carrier, one tracking number, one ship date, one delivery date — no ambiguity. Breaks for order-level questions: "how many orders" now requires &lt;code&gt;COUNT(DISTINCT order_id)&lt;/code&gt;, and anything about the customer's experience of &lt;em&gt;the order&lt;/em&gt; (did they get everything, was anything late) needs to look across all of an order's shipments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One row per package.&lt;/strong&gt; Maximally granular — a shipment can itself split into multiple boxes. Overkill for almost every question Crate Expectations actually asks; the operational systems track it, the warehouse doesn't need it in the analytical layer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same lesson as the SaaS article, applied again: &lt;strong&gt;if order-level and shipment-level questions are both real and both common, build both fact tables.&lt;/strong&gt; Don't pick one grain and force the other question to contort around it.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpr612q4yajscl25y6ts3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpr612q4yajscl25y6ts3.png" alt="Order fanning out into two shipments across two warehouses, each shipment fanning out into its own packages" width="800" height="340"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;Crate Expectations builds the first two. Package-level detail stays in the operational system; nobody's asked a question that needs it in the warehouse.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;fact_order_lifecycle&lt;/code&gt;: the accumulating snapshot
&lt;/h2&gt;

&lt;p&gt;One row per order, milestone columns, updated in place as the order progresses — same pattern as the coffee shop's loyalty journey and Tabby's trial→paid→churn, just with more stages and (as you'll see shortly) a genuine reason those stages can misbehave.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_order_lifecycle&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lifecycle_sk&lt;/span&gt;           &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;order_sk&lt;/span&gt;                &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;customer_sk&lt;/span&gt;             &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_customer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;placed_date_sk&lt;/span&gt;          &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;payment_confirmed_date_sk&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;picked_date_sk&lt;/span&gt;          &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;packed_date_sk&lt;/span&gt;          &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;first_shipped_date_sk&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;all_delivered_date_sk&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;current_status&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- see the status ladder below&lt;/span&gt;
    &lt;span class="n"&gt;shipment_count&lt;/span&gt;          &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_split_shipment&lt;/span&gt;       &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice &lt;code&gt;first_shipped_date_sk&lt;/code&gt; and &lt;code&gt;all_delivered_date_sk&lt;/code&gt;, not &lt;code&gt;shipped_date_sk&lt;/code&gt; and &lt;code&gt;delivered_date_sk&lt;/code&gt;. That naming is doing real work: a split order doesn't have &lt;em&gt;a&lt;/em&gt; ship date, it has one per shipment, so the order-level fact can only honestly report the first and the last. Anything more precise than that belongs in &lt;code&gt;fact_shipment&lt;/code&gt;, not here — a good sign you're respecting the grain fight instead of quietly ignoring it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Late-arriving facts: when the webhook lies about order
&lt;/h2&gt;

&lt;p&gt;Here's the problem that doesn't show up in a well-behaved pipeline. Crate Expectations' carriers send webhook events — &lt;code&gt;PICKED&lt;/code&gt;, &lt;code&gt;PACKED&lt;/code&gt;, &lt;code&gt;SHIPPED&lt;/code&gt;, &lt;code&gt;DELIVERED&lt;/code&gt; — and those events are supposed to update &lt;code&gt;fact_order_lifecycle&lt;/code&gt; as they happen. Two things go wrong in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retries duplicate events.&lt;/strong&gt; A carrier's webhook fires, Crate Expectations' endpoint is briefly down, the carrier retries the same &lt;code&gt;SHIPPED&lt;/code&gt; event six hours later. Applying it twice should be harmless — but only if the update logic is written to expect it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Events arrive out of the order they occurred.&lt;/strong&gt; A &lt;code&gt;PICKED&lt;/code&gt; event queued behind a rate limit can land &lt;em&gt;after&lt;/em&gt; the &lt;code&gt;SHIPPED&lt;/code&gt; event for the same order, because the carrier's own systems processed and sent them out of sequence. If the update logic just does "set &lt;code&gt;current_status&lt;/code&gt; to whatever the latest webhook says," the order's status can visibly &lt;em&gt;regress&lt;/em&gt; from &lt;code&gt;SHIPPED&lt;/code&gt; back to &lt;code&gt;PICKED&lt;/code&gt; — which is not just wrong, it's wrong in a way that makes the dashboard look broken to whoever's watching it.&lt;/li&gt;
&lt;/ul&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvda3f72lazo4o3st0y3x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvda3f72lazo4o3st0y3x.png" alt="A SHIPPED event arrives first and sets status to SHIPPED; a delayed PICKED event for the same order arrives second even though it happened earlier — naive " width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;The fix isn't to compare arrival time, and it isn't quite enough to compare the event's own timestamp either — carrier clocks skew, and a stale retried event can carry an old timestamp that still looks superficially valid. The robust fix is to stop treating &lt;code&gt;current_status&lt;/code&gt; as "whatever the last message said" and start treating it as &lt;strong&gt;the furthest point reached in a known, ordered pipeline&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fulfillment_status_rank&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rank&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;fulfillment_status_rank&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PLACED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PAYMENT_CONFIRMED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PICKED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PACKED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SHIPPED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DELIVERED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then every incoming event does two separate things — fill in its own milestone date unconditionally, but only advance &lt;code&gt;current_status&lt;/code&gt; if the event represents genuine forward progress in the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Applying one incoming event: (order_sk, event_status, event_date_sk)&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;fact_order_lifecycle&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;
    &lt;span class="n"&gt;picked_date_sk&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;event_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PICKED'&lt;/span&gt;
                                  &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;picked_date_sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;event_date_sk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                                  &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;picked_date_sk&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;packed_date_sk&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;event_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PACKED'&lt;/span&gt;
                                  &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packed_date_sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;event_date_sk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                                  &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packed_date_sk&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;first_shipped_date_sk&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;event_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SHIPPED'&lt;/span&gt;
                                  &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;LEAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_shipped_date_sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;event_date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;event_date_sk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                                  &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_shipped_date_sk&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;current_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fulfillment_status_rank&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;event_status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fulfillment_status_rank&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;event_status&lt;/span&gt;
        &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_status&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;order_sk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design decisions worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Milestone dates fill in whenever they arrive, regardless of order.&lt;/strong&gt; A late &lt;code&gt;PICKED&lt;/code&gt; event still records &lt;em&gt;when picking actually happened&lt;/em&gt; — that's genuinely useful data (it's what a "time from pick to pack" report needs), even though it arrived after &lt;code&gt;SHIPPED&lt;/code&gt; already updated the status. &lt;code&gt;COALESCE&lt;/code&gt; means the first value to arrive for a given milestone wins and a duplicate retry can't overwrite it with a different date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;current_status&lt;/code&gt; only ever moves forward&lt;/strong&gt;, compared by pipeline rank, not arrival time or event timestamp. A duplicate or a late-arriving earlier-stage event can update its own milestone column without ever being able to drag the visible status backward.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the general shape of "late-arriving facts" in Kimball terms: the fix is almost never about buffering or re-ordering events before they land — it's about making the &lt;em&gt;update logic&lt;/em&gt; correct regardless of what order things arrive in, because in any system with retries, queues, or multiple upstream senders, you cannot actually guarantee delivery order.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;fact_shipment&lt;/code&gt;: the finer grain the order-level fact can't give you
&lt;/h2&gt;

&lt;p&gt;"Which carrier is slowest?" and "how many packages did warehouse East ship last month?" are shipment-grain questions that &lt;code&gt;fact_order_lifecycle&lt;/code&gt; structurally cannot answer once orders split. A second accumulating snapshot, one row per shipment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_shipment&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;shipment_sk&lt;/span&gt;       &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;order_sk&lt;/span&gt;          &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;warehouse_sk&lt;/span&gt;      &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_warehouse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;warehouse_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;carrier_sk&lt;/span&gt;        &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_carrier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;carrier_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;packed_date_sk&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;shipped_date_sk&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;delivered_date_sk&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;package_count&lt;/span&gt;     &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;current_status&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every shipment event updates &lt;code&gt;fact_shipment&lt;/code&gt; with the same rank-based logic above, unconditionally reliable regardless of arrival order. &lt;code&gt;fact_order_lifecycle&lt;/code&gt;'s &lt;code&gt;first_shipped_date_sk&lt;/code&gt;/&lt;code&gt;all_delivered_date_sk&lt;/code&gt; and &lt;code&gt;shipment_count&lt;/code&gt; are then derived — &lt;code&gt;MIN(shipped_date_sk)&lt;/code&gt;, &lt;code&gt;MAX(delivered_date_sk)&lt;/code&gt;, &lt;code&gt;COUNT(*)&lt;/code&gt; — from &lt;code&gt;fact_shipment&lt;/code&gt; grouped by &lt;code&gt;order_sk&lt;/code&gt;, kept in sync by whatever process applies shipment events. Two facts, two grains, one truth, each question answered at the grain that actually fits it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Semi-additive measures and the question a snapshot alone can't answer
&lt;/h2&gt;

&lt;p&gt;Here's a question in the same shape as "what was MRR on any given day" from the SaaS article, which a plain periodic snapshot can't cleanly answer either: &lt;strong&gt;how many orders were in transit — shipped but not yet fully delivered — at the end of each day?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This measure has a name worth knowing: it's &lt;strong&gt;semi-additive&lt;/strong&gt;. A count like "orders in transit" is meaningful to sum &lt;em&gt;across&lt;/em&gt; things that exist at the same instant (add up in-transit orders across every warehouse right now, and you get a real number: total orders currently moving). It is &lt;strong&gt;not&lt;/strong&gt; meaningful to sum &lt;em&gt;across time&lt;/em&gt; — Monday's in-transit count plus Tuesday's doesn't produce anything interpretable, because it's mostly the same orders being counted twice. Contrast with &lt;code&gt;daily_revenue&lt;/code&gt; in the coffee shop's snapshot fact, which is &lt;strong&gt;fully additive&lt;/strong&gt;: summing it across 30 days correctly gives you the month's revenue. Not every measure in a snapshot fact behaves the same way when you aggregate it, and treating a semi-additive measure as if it were fully additive is a quiet, easy-to-miss error — nothing throws an exception, the number just means something different than whoever's reading the dashboard assumes.&lt;/p&gt;

&lt;p&gt;There are two honest ways to answer "in transit as of day X":&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconstruct it from the accumulating snapshot&lt;/strong&gt;, the same "as-of" logic used for SCD2 attributes in the earlier articles, just applied to a milestone range instead of a validity range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;orders_in_transit&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_order_lifecycle&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;first_shipped_date_sk&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;as_of_date_sk&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all_delivered_date_sk&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;all_delivered_date_sk&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;as_of_date_sk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works, costs nothing to set up, and is exactly right for ad hoc questions or a handful of dates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or materialize a periodic snapshot&lt;/strong&gt; — &lt;code&gt;fact_fulfillment_daily&lt;/code&gt; with one row per &lt;code&gt;(date, warehouse, status)&lt;/code&gt; and a count — for the same three reasons the coffee shop and SaaS articles gave for building snapshots instead of always recomputing: a dashboard querying 5 years of daily in-transit counts across every warehouse shouldn't reconstruct all of it from milestone ranges on every load; a snapshot freezes what was true as of that day even if &lt;code&gt;fact_order_lifecycle&lt;/code&gt; keeps changing; and the query becomes &lt;code&gt;SELECT ... FROM fact_fulfillment_daily WHERE ...&lt;/code&gt; instead of a range-comparison scan. Same tradeoff as always — build the snapshot once the reconstruction query gets asked often enough to matter.&lt;/p&gt;

&lt;p&gt;Either way: &lt;strong&gt;when you build that snapshot, don't add a &lt;code&gt;SUM(daily_in_transit)&lt;/code&gt; chart across a date range and call it a meaningful total.&lt;/strong&gt; That's the single most common way a semi-additive measure gets misused once it exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  A real query
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Average days from order placed to fully delivered, split by whether the order shipped from one warehouse or split across multiple:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_split_shipment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                                    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;order_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d_delivered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_date&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;d_placed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;avg_days_to_deliver&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_order_lifecycle&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt; &lt;span class="n"&gt;d_placed&lt;/span&gt;    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d_placed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;placed_date_sk&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt; &lt;span class="n"&gt;d_delivered&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d_delivered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;all_delivered_date_sk&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;all_delivered_date_sk&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_split_shipment&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Slowest carrier by average shipment transit time, at the shipment grain where that question actually lives:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;carrier_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d_delivered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_date&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;d_shipped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;avg_transit_days&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                          &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;shipment_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_shipment&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_carrier&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;        &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;carrier_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;carrier_sk&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt; &lt;span class="n"&gt;d_shipped&lt;/span&gt;    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d_shipped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shipped_date_sk&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt; &lt;span class="n"&gt;d_delivered&lt;/span&gt;  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d_delivered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delivered_date_sk&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delivered_date_sk&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;carrier_name&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;avg_transit_days&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try either against a raw event log of carrier webhooks directly and you're reconstructing state from scratch every time you run it. Against these two facts, both are a handful of lines.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Modeling "shipped" and "delivered" as single dates on the order.&lt;/strong&gt; Fine until the first split shipment, then silently wrong for every split order afterward — usually discovered when someone notices the numbers don't match the carrier's own dashboard.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"Latest webhook wins" status logic.&lt;/strong&gt; The most natural-looking implementation and the one that lets status visibly move backward the first time an event arrives out of order. Rank the pipeline, compare ranks, not timestamps or arrival order.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Not guarding against duplicate events.&lt;/strong&gt; Carriers retry. If applying the same &lt;code&gt;SHIPPED&lt;/code&gt; event twice can push a date forward a second time or double-count something downstream, the update logic isn't idempotent yet.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Summing a semi-additive measure across time and presenting it as a total.&lt;/strong&gt; "Orders in transit" summed across 30 days is not "total orders shipped this month" — it's a number that looks plausible and means nothing. If a measure can't be summed across the fact's own grain-defining dimension, say so next to it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Forcing one fact table to serve both order-level and shipment-level questions.&lt;/strong&gt; Padding &lt;code&gt;fact_order_lifecycle&lt;/code&gt; with &lt;code&gt;shipment_2_carrier&lt;/code&gt;, &lt;code&gt;shipment_2_ship_date&lt;/code&gt; columns for the second shipment is a fixed-width hack that breaks the moment an order has three shipments. Build &lt;code&gt;fact_shipment&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Exercises
&lt;/h2&gt;

&lt;p&gt;Hints hidden; full solutions in solutions.sql in the companion repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1.&lt;/strong&gt; A &lt;code&gt;DELIVERED&lt;/code&gt; webhook arrives for a shipment that has no prior &lt;code&gt;SHIPPED&lt;/code&gt; event on file — the shipped notification appears to have been lost entirely, not just delayed. What should the update logic do, and why is this a different case from ordinary out-of-order arrival?&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  The rank-based status update still works (DELIVERED outranks whatever's currently on file). But &lt;code&gt;shipped_date_sk&lt;/code&gt; would stay NULL forever unless you backfill it — consider inferring a shipped date from context (e.g., the delivered date minus typical transit time) versus just leaving the gap and flagging the row for review. There's a real tradeoff between a clean-looking dataset and an honest one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.&lt;/strong&gt; Write the periodic snapshot version of &lt;code&gt;fact_fulfillment_daily&lt;/code&gt; and the query that populates one day's rows from &lt;code&gt;fact_order_lifecycle&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  One row per (date, warehouse, status), &lt;code&gt;COUNT(*)&lt;/code&gt; of orders matching the as-of reconstruction query from this article, grouped by warehouse. Populate it once per day as a scheduled job, the same way &lt;code&gt;fact_daily_sales&lt;/code&gt; gets populated in Part 1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3.&lt;/strong&gt; Why is &lt;code&gt;shipment_count&lt;/code&gt; stored directly on &lt;code&gt;fact_order_lifecycle&lt;/code&gt; instead of always being computed with &lt;code&gt;COUNT(*) FROM fact_shipment WHERE order_sk = ...&lt;/code&gt; at query time?&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  Same performance-vs-recompute tradeoff that motivated periodic snapshots in the first place — cheap to store, expensive to keep re-deriving on every query that touches order-level counts. The cost is that it can drift if the process updating &lt;code&gt;fact_shipment&lt;/code&gt; doesn't also update the count; consider what would keep them in sync.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Everything so far has been one-to-one or one-to-many in a clean, hierarchical way: one order to many shipments, one account to many months. &lt;a href="https://dev.to/nbaubek/kimball-for-many-to-many-bridge-tables-weighting-factors-and-the-diagnosis-code-problem-3ei7"&gt;Part 4&lt;/a&gt; breaks that assumption with &lt;strong&gt;bridge tables&lt;/strong&gt; — the pattern for genuine many-to-many relationships, using healthcare claims (one claim, several diagnosis codes) as the running example, and the double-counting trap that shows up the moment you fan a dollar amount out across a bridge without thinking about weights.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ralph Kimball &amp;amp; Margy Ross, *The Data Warehouse Toolkit&lt;/strong&gt;* — the source for late-arriving fact handling as a named, documented pattern, not something this article invented.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/late-arriving-fact/" rel="noopener noreferrer"&gt;Kimball Group — Late Arriving Fact&lt;/a&gt;&lt;/strong&gt; — the canonical short reference for the general pattern this article's out-of-order webhook handling is one instance of.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The companion repo has the schema, seed data, and exercises for this part. &lt;a href="https://dev.to/nbaubek/kimball-for-many-to-many-bridge-tables-weighting-factors-and-the-diagnosis-code-problem-3ei7"&gt;Part 4&lt;/a&gt; is next.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>datamodeling</category>
      <category>datawarehouse</category>
      <category>kimball</category>
    </item>
    <item>
      <title>Kimball for SaaS: Subscriptions, MRR, and Churn, Modeled Right</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:19:50 +0000</pubDate>
      <link>https://dev.to/nbaubek/kimball-for-saas-subscriptions-mrr-and-churn-modeled-right-2min</link>
      <guid>https://dev.to/nbaubek/kimball-for-saas-subscriptions-mrr-and-churn-modeled-right-2min</guid>
      <description>&lt;p&gt;SaaS data is weird.&lt;/p&gt;

&lt;p&gt;A customer pays you &lt;em&gt;before&lt;/em&gt; they use the thing. Then they use it some unpredictable amount. Then they maybe upgrade. Then they churn. Then — sometimes — they come back three months later and you have to decide whether that's "reactivation" or "new." Finance wants recognized revenue one way, customer success wants NRR another way, and your CEO wants a single number for "ARR" that nobody can quite agree on.&lt;/p&gt;

&lt;p&gt;The dimensional modeling fundamentals from &lt;a href="https://dev.to/nbaubek/kimball-dimensional-modeling-explained-through-a-coffee-shop-2cnl"&gt;the first article in this series&lt;/a&gt; (the coffee shop one) still apply. But SaaS breaks them in interesting ways. A transaction fact alone can't answer &lt;em&gt;"what was this account's MRR last March?"&lt;/em&gt; — because MRR isn't an event, it's a &lt;em&gt;state&lt;/em&gt; that changes over time.&lt;/p&gt;

&lt;p&gt;This article is about the patterns SaaS actually needs. We'll meet a fictional company, walk through each pattern, and by the end you'll have a dimensional model that can answer the hard SaaS questions without re-deriving them every time.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 60-second recap (if you skipped the coffee shop)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fact tables&lt;/strong&gt; hold measurable events (verbs). Four flavors: &lt;em&gt;transaction&lt;/em&gt; (atomic events), &lt;em&gt;periodic snapshot&lt;/em&gt; (regular photos), &lt;em&gt;accumulating snapshot&lt;/em&gt; (multi-stage journeys, updated in place), &lt;em&gt;factless&lt;/em&gt; (coverage/eligibility).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dimension tables&lt;/strong&gt; hold descriptive context (nouns): who/what/where/when.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grain&lt;/strong&gt; = the precise definition of what one fact row represents. State it out loud before building.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Star schema&lt;/strong&gt; (flat dims, one hop from fact) beats snowflake almost always.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCD Type 2&lt;/strong&gt; preserves history by inserting a new row with &lt;code&gt;valid_from&lt;/code&gt;/&lt;code&gt;valid_to&lt;/code&gt;/&lt;code&gt;is_current&lt;/code&gt; when an attribute changes. Type 1 overwrites. Type 2 is the workhorse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Got it? Good. Now let's apply it to SaaS.&lt;/p&gt;




&lt;h2&gt;
  
  
  Meet Tabby
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tabby&lt;/strong&gt; sells IoT collars that track cats' location, activity, and naps. Yes, this is a real product category. The business model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subscription plans&lt;/strong&gt;: Free (1 collar, basic stats), Pro ($12/mo, 3 collars, full history), Enterprise (custom, many collars, API access).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usage-based add-on&lt;/strong&gt;: beyond your plan's collar limit, each active collar is $4/mo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trial&lt;/strong&gt;: every account gets 14 days of Pro for free, then auto-downgrades to Free unless they add payment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accounts can have workspaces&lt;/strong&gt; (think: a multi-cat household, or a small vet clinic with several "rooms"), and workspaces have users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hierarchies&lt;/strong&gt;: some Enterprise customers are "organizations" with multiple sub-accounts (a vet chain with 12 locations).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dimensional model needs to handle all of that, plus reconstruct MRR as-of any historical date. Let's build it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The subscription grain problem
&lt;/h2&gt;

&lt;p&gt;Before any SQL, the most important decision: &lt;strong&gt;what is one row in the subscription fact?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three candidates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One row per subscription.&lt;/strong&gt; A subscription is a billing relationship between an account and a plan. Pros: tiny, matches the source system. Cons: can't represent plan changes over time without a second table — and where does MRR history live?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One row per subscription-month.&lt;/strong&gt; Each month a subscription is active, it gets a row with that month's MRR. Pros: trivial MRR queries — &lt;code&gt;SUM(mrr)&lt;/code&gt; per month. Cons: one row per sub per month means 12× the rows per year per customer, and you have to materialize new rows monthly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One row per invoice line.&lt;/strong&gt; Most granular billing event. Pros: ties directly to revenue recognition. Cons: a mid-month upgrade produces two invoice lines, and "what was the MRR?" becomes a rolling calculation.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;There is no universally right answer.&lt;/strong&gt; Tabby uses &lt;strong&gt;two of these&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;accumulating snapshot&lt;/strong&gt; at &lt;em&gt;one row per subscription&lt;/em&gt; for the lifecycle (trial → paid → churn — see below).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;periodic snapshot&lt;/strong&gt; at &lt;em&gt;one row per subscription-month&lt;/em&gt; for MRR history.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why both? Because they answer different questions. The lifecycle fact answers &lt;em&gt;"how long do trials take to convert?"&lt;/em&gt; The monthly snapshot answers &lt;em&gt;"what was MRR last March?"&lt;/em&gt; Trying to make one table answer both leads to grain mixing — the most common SaaS modeling sin.&lt;/p&gt;

&lt;p&gt;The rule: &lt;strong&gt;if two questions need different grains, build two fact tables.&lt;/strong&gt; Don't be a hero.&lt;/p&gt;




&lt;h2&gt;
  
  
  Account dimension &amp;amp; hierarchy
&lt;/h2&gt;

&lt;p&gt;SaaS entities usually nest. For Tabby: &lt;strong&gt;account → workspace → user&lt;/strong&gt;. Plus an optional &lt;strong&gt;parent account&lt;/strong&gt; for organizations that own multiple sub-accounts (the vet chain case).&lt;/p&gt;

&lt;p&gt;Two modeling choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flatten&lt;/strong&gt; the hierarchy onto &lt;code&gt;dim_account&lt;/code&gt; (&lt;code&gt;account_id&lt;/code&gt;, &lt;code&gt;parent_account_id&lt;/code&gt;, &lt;code&gt;workspace_count&lt;/code&gt;, &lt;code&gt;user_count&lt;/code&gt;). One table, denormalized. Easier queries, slight staleness on counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snowflake&lt;/strong&gt; into &lt;code&gt;dim_account&lt;/code&gt;, &lt;code&gt;dim_workspace&lt;/code&gt;, &lt;code&gt;dim_user&lt;/code&gt; with FKs. More normalized, more joins, but the counts are always live.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For Tabby we keep all three as separate Type 1 dimensions — they're genuinely different entities with their own attributes — but we also &lt;strong&gt;flatten the parent relationship&lt;/strong&gt; onto &lt;code&gt;dim_account&lt;/code&gt; so org-rollup queries are one hop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dim_account&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;account_sk&lt;/span&gt;       &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;account_id&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                  &lt;span class="c1"&gt;-- natural key&lt;/span&gt;
    &lt;span class="n"&gt;account_name&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parent_account_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                          &lt;span class="c1"&gt;-- for org rollups (NULL = top-level)&lt;/span&gt;
    &lt;span class="n"&gt;plan_id&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                  &lt;span class="c1"&gt;-- SCD2-tracked (see below)&lt;/span&gt;
    &lt;span class="n"&gt;plan_name&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;signup_date&lt;/span&gt;      &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;-- SCD Type 2 columns:&lt;/span&gt;
    &lt;span class="n"&gt;valid_from&lt;/span&gt;       &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valid_to&lt;/span&gt;         &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_current&lt;/span&gt;       &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;plan_id&lt;/code&gt; and &lt;code&gt;plan_name&lt;/code&gt; are &lt;em&gt;on&lt;/em&gt; the account dimension with SCD2 columns. This is the canonical SCD2 case — when an account upgrades Pro → Enterprise, we close out the Pro row and open an Enterprise row. More on that next.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dim_workspace&lt;/code&gt; and &lt;code&gt;dim_user&lt;/code&gt; stay Type 1 (they reference the account but don't carry plan info themselves):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dim_workspace&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;workspace_sk&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;account_id&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;-- denormalized for one-hop joins&lt;/span&gt;
    &lt;span class="n"&gt;workspace_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dim_user&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;user_sk&lt;/span&gt;      &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;role&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- OWNER / ADMIN / MEMBER&lt;/span&gt;
    &lt;span class="n"&gt;created_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;When to conflate, when to separate.&lt;/strong&gt; If workspaces were purely a UI grouping with no attributes of their own, you'd flatten them onto &lt;code&gt;dim_account&lt;/code&gt;. They have their own created-date, name, and (later) usage, so they earn their own dimension. The same logic applies to "do I need a &lt;code&gt;dim_plan&lt;/code&gt;?" — Tabby has only three plans and they rarely change, so plan attributes live denormalized on &lt;code&gt;dim_account&lt;/code&gt;. If plans had rich attributes (feature flags, rate limits, regional availability), they'd get their own SCD2 dimension.&lt;/p&gt;




&lt;h2&gt;
  
  
  SCD Type 2 in depth: plan changes are THE case
&lt;/h2&gt;

&lt;p&gt;Here's the central SaaS data problem. An account named "Whisker Labs" was on &lt;strong&gt;Pro&lt;/strong&gt; from January to June, then upgraded to &lt;strong&gt;Enterprise&lt;/strong&gt; in July. You need to answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"What was Whisker Labs' MRR in March?"&lt;/em&gt; → Pro price.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"What's their MRR now?"&lt;/em&gt; → Enterprise price.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"What plan were they on when they hit support ticket #4471?"&lt;/em&gt; → Depends on the date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If &lt;code&gt;dim_account&lt;/code&gt; overwrote &lt;code&gt;plan_id&lt;/code&gt; in place (SCD1), every historical query would silently use today's plan. That's the bug. SCD2 fixes it.&lt;/p&gt;

&lt;p&gt;The data looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;account_sk&lt;/th&gt;
&lt;th&gt;account_id&lt;/th&gt;
&lt;th&gt;account_name&lt;/th&gt;
&lt;th&gt;plan_id&lt;/th&gt;
&lt;th&gt;plan_name&lt;/th&gt;
&lt;th&gt;valid_from&lt;/th&gt;
&lt;th&gt;valid_to&lt;/th&gt;
&lt;th&gt;is_current&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;ACC_WL&lt;/td&gt;
&lt;td&gt;Whisker Labs&lt;/td&gt;
&lt;td&gt;PRO&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;2025-01-10&lt;/td&gt;
&lt;td&gt;2025-07-14&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;ACC_WL&lt;/td&gt;
&lt;td&gt;Whisker Labs&lt;/td&gt;
&lt;td&gt;ENT&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;2025-07-15&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows, same &lt;code&gt;account_id&lt;/code&gt;, different &lt;code&gt;account_sk&lt;/code&gt;. The fact tables carry the &lt;code&gt;account_sk&lt;/code&gt; that was valid &lt;em&gt;at the time of the event&lt;/em&gt;. So a March invoice references &lt;code&gt;account_sk = 101&lt;/code&gt; (Pro), and an August invoice references &lt;code&gt;account_sk = 102&lt;/code&gt; (Enterprise).&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7rryuet7okdl2ujv8m9u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7rryuet7okdl2ujv8m9u.png" alt="Whisker Labs SCD2 timeline: account_sk 101 (Pro) valid Jan 10 to Jul 14, account_sk 102 (Enterprise) valid Jul 15 onward, with a March invoice pointing at 101 and an August invoice pointing at 102" width="800" height="388"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;h3&gt;
  
  
  The "as-of" query
&lt;/h3&gt;

&lt;p&gt;The pattern you'll use constantly: &lt;em&gt;what plan was this account on as of date X?&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_mrr&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dim_account&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC_WL'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2025-03-15'&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;valid_from&lt;/span&gt;
                            &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid_to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'9999-12-31'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- Returns the Pro row (sk=101).&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;COALESCE(valid_to, '9999-12-31')&lt;/code&gt; handles the currently-valid row (where &lt;code&gt;valid_to IS NULL&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  The "current only" query
&lt;/h3&gt;

&lt;p&gt;When you only want the live state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dim_account&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;is_current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep both patterns in muscle memory. You'll write them weekly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Doing the SCD2 update
&lt;/h3&gt;

&lt;p&gt;When Whisker Labs upgrades, two statements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 1. Close out the old Pro row&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;dim_account&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;valid_to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2025-07-14'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACC_WL'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;is_current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Insert the new Enterprise row&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;dim_account&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parent_account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="n"&gt;signup_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ACC_WL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Whisker Labs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ENT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Enterprise'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="s1"&gt;'2025-01-10'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2025-07-15'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production you'd wrap this in a transaction and drive it from a staging table of "accounts whose plan changed today." With dbt, you'd rebuild &lt;code&gt;dim_account&lt;/code&gt; incrementally each day from the source, generating new SCD2 rows automatically. The shape is the same.&lt;/p&gt;




&lt;h2&gt;
  
  
  Accumulating snapshot: the trial → paid → churn lifecycle
&lt;/h2&gt;

&lt;p&gt;Remember from the coffee shop article: an &lt;strong&gt;accumulating snapshot&lt;/strong&gt; is one row per entity, with milestone date columns that get &lt;strong&gt;updated in place&lt;/strong&gt; as the entity progresses through a defined pipeline.&lt;/p&gt;

&lt;p&gt;For Tabby, the pipeline is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trial_started → first_paid → expanded → churned (→ maybe reactivated)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row per subscription. Updated as milestones happen.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1v6klr3xx20apbai987e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1v6klr3xx20apbai987e.png" alt="Subscription lifecycle pipeline: trial_started to first_paid to expanded to churned, with a reactivated branch looping back to active, each stage a milestone column updated in place on the same row" width="800" height="232"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_subscription_lifecycle&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lifecycle_sk&lt;/span&gt;          &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;account_sk&lt;/span&gt;            &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;subscription_id&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;trial_start_date_sk&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;trial_end_date_sk&lt;/span&gt;     &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;first_paid_date_sk&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;expanded_date_sk&lt;/span&gt;      &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;    &lt;span class="c1"&gt;-- plan upgrade&lt;/span&gt;
    &lt;span class="n"&gt;churned_date_sk&lt;/span&gt;       &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;reactivated_date_sk&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;current_status&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;-- TRIAL / ACTIVE / CHURNED / REACTIVATED&lt;/span&gt;
    &lt;span class="n"&gt;trial_to_paid_days&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;paid_to_churn_days&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lifetime_mrr&lt;/span&gt;          &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this works for SaaS: the funnel questions — &lt;em&gt;"how long do trials take to convert?"&lt;/em&gt;, &lt;em&gt;"what % of Pro accounts churn within 90 days?"&lt;/em&gt;, &lt;em&gt;"what's the median trial-to-paid interval by cohort?"&lt;/em&gt; — are all &lt;code&gt;SELECT ... FROM fact_subscription_lifecycle WHERE first_paid_date_sk IS NOT NULL&lt;/code&gt;. No joins to transaction facts, no re-deriving from invoices. The lifecycle is materialized once, queried forever.&lt;/p&gt;

&lt;p&gt;Contrast with a &lt;strong&gt;transaction fact&lt;/strong&gt; (which never updates — you only append) and a &lt;strong&gt;periodic snapshot&lt;/strong&gt; (which inserts a new row every period). The accumulating snapshot is the only one you &lt;em&gt;update&lt;/em&gt;. That's the tell: if you're &lt;code&gt;UPDATE&lt;/code&gt;-ing a fact row, it's almost certainly an accumulating snapshot.&lt;/p&gt;

&lt;h3&gt;
  
  
  The update pattern
&lt;/h3&gt;

&lt;p&gt;When Whisker Labs converts from trial to paid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;fact_subscription_lifecycle&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;first_paid_date_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20250124&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;-- they paid on Jan 24&lt;/span&gt;
    &lt;span class="n"&gt;current_status&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ACTIVE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;trial_to_paid_days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lifetime_mrr&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;               &lt;span class="c1"&gt;-- Pro monthly&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;subscription_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SUB_WL_001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When they later expand to Enterprise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;fact_subscription_lifecycle&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;expanded_date_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20250715&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lifetime_mrr&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;499&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;                &lt;span class="c1"&gt;-- Enterprise monthly&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;subscription_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SUB_WL_001'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same row, updated twice. That's the accumulating snapshot.&lt;/p&gt;




&lt;h2&gt;
  
  
  Periodic snapshot: monthly MRR
&lt;/h2&gt;

&lt;p&gt;Now the second grain. We also need &lt;em&gt;"what was every account's MRR at the end of each month?"&lt;/em&gt; — for MRR movement (the famous MRR waterfall: starting + new + expansion − contraction − churn = ending), for cohort retention curves, for the finance team's month-end close.&lt;/p&gt;

&lt;p&gt;We &lt;em&gt;could&lt;/em&gt; recompute this from invoices every time. We don't, for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Performance&lt;/strong&gt; — a 5-year MRR trend across 10,000 accounts shouldn't scan every invoice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot truth&lt;/strong&gt; — if a plan change gets backdated or an invoice is edited tomorrow, last month's reported MRR shouldn't silently change. The snapshot freezes "what we knew then."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simplicity&lt;/strong&gt; — the MRR waterfall query becomes a self-join on two adjacent months, not a temporal reconstruction.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_subscription_month&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;subscription_month_sk&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;account_sk&lt;/span&gt;            &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;subscription_id&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;month_sk&lt;/span&gt;              &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;-- first of month&lt;/span&gt;
    &lt;span class="n"&gt;plan_id&lt;/span&gt;               &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;mrr&lt;/span&gt;                   &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;quantity&lt;/span&gt;              &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;-- active collars&lt;/span&gt;
    &lt;span class="n"&gt;is_active&lt;/span&gt;             &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subscription_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;month_sk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row per subscription per month. The &lt;code&gt;month_sk&lt;/code&gt; points to the first day of the month in &lt;code&gt;dim_date&lt;/code&gt; (a common convention).&lt;/p&gt;

&lt;h3&gt;
  
  
  The MRR waterfall
&lt;/h3&gt;

&lt;p&gt;The naive way to find "last month" is &lt;code&gt;month_sk - 100&lt;/code&gt;, since &lt;code&gt;month_sk&lt;/code&gt; is &lt;code&gt;YYYYMMDD&lt;/code&gt;. &lt;strong&gt;Don't do this&lt;/strong&gt; — it's not just imprecise, it's flat-out broken every January. &lt;code&gt;20260101 - 100 = 20260001&lt;/code&gt;, which isn't December 2025 (&lt;code&gt;20251201&lt;/code&gt;), it isn't a valid date at all, and it matches nothing in &lt;code&gt;dim_date&lt;/code&gt;. Every account's &lt;code&gt;prev_mo&lt;/code&gt; join would silently come back &lt;code&gt;NULL&lt;/code&gt; for January specifically, once a year, right at the month finance cares about most for year-end close.&lt;/p&gt;

&lt;p&gt;The fix is to go through &lt;code&gt;dim_date&lt;/code&gt; itself rather than doing arithmetic on the encoded key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;this_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month_sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;this_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;ending_mrr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;prev_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_sk&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;this_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;new_mrr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;this_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;prev_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt;
             &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;this_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;prev_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;expansion_mrr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;this_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;prev_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt;
             &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;prev_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;this_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;contraction_mrr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;this_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;prev_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;churned_mrr&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_subscription_month&lt;/span&gt; &lt;span class="n"&gt;this_mo&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt; &lt;span class="n"&gt;d_this&lt;/span&gt;        &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d_this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;this_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month_sk&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt; &lt;span class="n"&gt;d_prev&lt;/span&gt;        &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d_prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d_this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_date&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 month'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;fact_subscription_month&lt;/span&gt; &lt;span class="n"&gt;prev_mo&lt;/span&gt;
       &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;prev_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subscription_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;this_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subscription_id&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;prev_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d_prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;this_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month_sk&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;this_mo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month_sk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This joins through &lt;code&gt;dim_date&lt;/code&gt; twice — once to get the current month's real calendar date, once to look up whatever &lt;code&gt;date_sk&lt;/code&gt; actually represents "one calendar month earlier" — so the previous-month lookup is correct at every year boundary, not just the eleven months where subtracting 100 happens to work.&lt;/p&gt;

&lt;p&gt;Try writing that against invoices. You can — but it's 5x the SQL and 50x the compute.&lt;/p&gt;




&lt;h2&gt;
  
  
  Usage / event fact: high-volume, separate from billing
&lt;/h2&gt;

&lt;p&gt;Tabby's collars emit a "ping" every 15 minutes: location, activity score, nap flag. That's millions of events per day. Don't put this in the billing fact — it'll drown your invoice queries.&lt;/p&gt;

&lt;p&gt;Separate &lt;strong&gt;transaction fact&lt;/strong&gt; for usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_usage_event&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;usage_event_sk&lt;/span&gt;  &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;event_ts&lt;/span&gt;        &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;event_date_sk&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;account_sk&lt;/span&gt;      &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;workspace_sk&lt;/span&gt;    &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_workspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workspace_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;collar_id&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;-- the device&lt;/span&gt;
    &lt;span class="n"&gt;event_type&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;-- LOCATION / ACTIVITY / NAP&lt;/span&gt;
    &lt;span class="n"&gt;activity_score&lt;/span&gt;  &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;nap_minutes&lt;/span&gt;     &lt;span class="nb"&gt;INT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is your classic high-volume event fact. Partition it by date in production. Aggregate it nightly into a &lt;code&gt;fact_daily_usage&lt;/code&gt; (periodic snapshot) for dashboards. Keep the raw event table for deep-dives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why separate from billing.&lt;/strong&gt; Mixing event-grain with invoice-grain in one fact is a Category 5 anti-pattern. Either you store one row per ping and bloat the billing columns with &lt;code&gt;NULL&lt;/code&gt;s on 99.9% of rows, or you aggregate pings and lose the raw event. Two tables, no compromise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Factless fact table: entitlements
&lt;/h2&gt;

&lt;p&gt;Final pattern. SaaS products gate features by plan: Pro gets "nap history," Enterprise gets "API access." The question &lt;em&gt;"which accounts had API access on June 1st?"&lt;/em&gt; is an entitlement lookup.&lt;/p&gt;

&lt;p&gt;Model it as a factless fact table — row presence means "this account had this feature as of this date":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_entitlement&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;entitlement_sk&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;account_sk&lt;/span&gt;     &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;feature_id&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                  &lt;span class="c1"&gt;-- 'API_ACCESS', 'NAP_HISTORY', etc.&lt;/span&gt;
    &lt;span class="n"&gt;effective_date_sk&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feature_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;effective_date_sk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;-- no measures: the row's existence IS the fact&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;em&gt;"how many accounts had API access in Q2?"&lt;/em&gt; is a &lt;code&gt;COUNT(DISTINCT account_sk)&lt;/code&gt; with a date filter. No need to reverse-engineer from the plan table — the entitlements are materialized, auditable, and snapshot-stable.&lt;/p&gt;

&lt;p&gt;(You could also model entitlements as attributes on &lt;code&gt;dim_plan&lt;/code&gt; and infer them from &lt;code&gt;dim_account.plan_id&lt;/code&gt;. That works for &lt;em&gt;current&lt;/em&gt; entitlements. It fails for &lt;em&gt;historical&lt;/em&gt; entitlements when plans or features change. SCD2 on entitlements directly is the robust answer.)&lt;/p&gt;




&lt;h2&gt;
  
  
  A real query: net revenue retention
&lt;/h2&gt;

&lt;p&gt;Let's put the pieces together. &lt;strong&gt;Net Revenue Retention (NRR)&lt;/strong&gt; — the SaaS north-star metric — compares a cohort's MRR now vs 12 months ago, including expansion and net of churn.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;cohort&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;-- Accounts that were active and paying 12 months ago&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;account_sk&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_subscription_month&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;month_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20250101&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;mrr&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;mrr_then&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;account_sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mrr_12mo_ago&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_subscription_month&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;month_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20250101&lt;/span&gt;
    &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;account_sk&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;mrr_now&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;account_sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mrr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mrr_current&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_subscription_month&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;month_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20260101&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
    &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;account_sk&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr_12mo_ago&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;starting_mrr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr_current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;ending_mrr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr_current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mrr_12mo_ago&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;nrr&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cohort&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;mrr_then&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_sk&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;mrr_now&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_sk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NRR &amp;gt; 1.0 means your existing customers are growing faster than they're churning. Anything above 1.1 (110%) is healthy; above 1.3 is elite.&lt;/p&gt;

&lt;p&gt;That query would be a horror show against invoices. Against the periodic snapshot, it's a two-CTE join. That's the model paying for itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common SaaS-specific mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Double-counting MRR after a mid-month upgrade.&lt;/strong&gt; If Pro is $12 and Enterprise is $499, and an account upgrades on the 15th, do you count $12 + $499 = $511 for that month? No. You prorate or snapshot once at month-end. Pick a convention (Tabby: month-end snapshot) and document it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Forgetting SCD2 on accounts.&lt;/strong&gt; If &lt;code&gt;dim_account.plan_id&lt;/code&gt; is Type 1, every historical MRR query is wrong. The plan-changing case is &lt;em&gt;the&lt;/em&gt; reason SCD2 exists.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mixing event-grain with invoice-grain in one fact.&lt;/strong&gt; Don't put collar pings and invoice lines in the same table. Two facts, two grains.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Storing "current MRR" on dim_account.&lt;/strong&gt; It'll be stale by the end of today. MRR lives in &lt;code&gt;fact_subscription_month&lt;/code&gt;, not on the dimension.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treating reactivation as a new account.&lt;/strong&gt; A churned customer who returns is the &lt;em&gt;same account&lt;/em&gt;. Track it via &lt;code&gt;reactivated_date_sk&lt;/code&gt; on the lifecycle fact, not by creating a new &lt;code&gt;account_id&lt;/code&gt;. Otherwise your "new business" metric is inflated.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Not handling org hierarchies.&lt;/strong&gt; If Whisker Labs is a subsidiary of "PetCo Org," and you query accounts individually, you'll double-count the org's MRR. Either roll up via &lt;code&gt;parent_account_id&lt;/code&gt; explicitly or build a dedicated org-rollup snapshot.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Exercises
&lt;/h2&gt;

&lt;p&gt;A few to test yourself. Hints are hidden; full solutions in solutions.sql in the companion repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1.&lt;/strong&gt; Tabby wants to add a "discount percentage" attribute that some accounts negotiate. Should this be SCD Type 1, 2, or 3 on &lt;code&gt;dim_account&lt;/code&gt;? Why?&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  Does anyone need to know the historical discounts, or just the current one? If finance needs to reconstruct past invoices at the negotiated rate, you need history → Type 2. If it's "what discount do they get today," Type 1 is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.&lt;/strong&gt; Write the "as-of" query: what plan was account &lt;code&gt;ACC_WL&lt;/code&gt; on as of &lt;code&gt;2025-05-01&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  &lt;code&gt;SELECT ... FROM dim_account WHERE account_id='ACC_WL' AND DATE '2025-05-01' BETWEEN valid_from AND COALESCE(valid_to, DATE '9999-12-31')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3.&lt;/strong&gt; An account upgrades Pro → Enterprise mid-month. Explain why the MRR snapshot and the invoice fact might disagree on that month's MRR, and which one finance usually prefers.&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  The snapshot shows month-end state ($499). The invoice shows what was actually billed (often a proration: a credit for unused Pro days + a charge for partial Enterprise). Finance usually prefers invoice truth for revenue recognition; the snapshot is for operations/CRM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4.&lt;/strong&gt; Write a query using &lt;code&gt;fact_subscription_lifecycle&lt;/code&gt; to compute the median number of days from trial start to first paid, for accounts that converted in 2025.&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  &lt;code&gt;SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY trial_to_paid_days)&lt;/code&gt; with a filter on &lt;code&gt;first_paid_date_sk&lt;/code&gt; being non-null and in 2025.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5.&lt;/strong&gt; Why should &lt;code&gt;fact_usage_event&lt;/code&gt; (collar pings) be a &lt;em&gt;separate&lt;/em&gt; fact table from &lt;code&gt;fact_invoice_line&lt;/code&gt;? Name two reasons.&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  Different grain (one row per ping vs one row per invoice line) and different volume (millions/day vs dozens/day). Mixing them bloats the smaller table and pollutes the larger with NULL billing columns.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Everything in this article has been well-behaved: a subscription's lifecycle moves through its milestones in a predictable order, and MRR snapshots update on a fixed monthly cadence. &lt;a href="https://dev.to/nbaubek/kimball-for-order-fulfillment-milestones-split-shipments-and-facts-that-arrive-late-3llc"&gt;Part 3&lt;/a&gt; is where that good behavior stops — orders that fork into multiple shipments, carrier webhooks that arrive out of order and can make a status silently regress if the update logic isn't written for it, and a measure (orders currently in transit) that genuinely cannot be summed across days the way MRR can be summed across accounts.&lt;/p&gt;

&lt;p&gt;Same fundamentals, messier processes. Onward to &lt;a href="https://dev.to/nbaubek/kimball-for-order-fulfillment-milestones-split-shipments-and-facts-that-arrive-late-3llc"&gt;Part 3&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ralph Kimball &amp;amp; Margy Ross, *The Data Warehouse Toolkit&lt;/strong&gt;* — chapter 14 (financial services) covers subscription/recurring-revenue patterns well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.getdbt.com/blog/modeling-subscription-revenue" rel="noopener noreferrer"&gt;dbt Labs — Modeling subscription revenue&lt;/a&gt;&lt;/strong&gt; — MRR, churn, upgrades/downgrades, directly on-topic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://docs.getdbt.com/best-practices" rel="noopener noreferrer"&gt;Subscriptions vs. usage modeling on the dbt blog&lt;/a&gt;&lt;/strong&gt; — recurring-revenue patterns.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is Part 2 of a five-part series — &lt;a href="https://dev.to/nbaubek/kimball-dimensional-modeling-explained-through-a-coffee-shop-2cnl"&gt;Part 1&lt;/a&gt; has the fundamentals this article builds on. The companion repo has the full schema, seed data, and exercises for this part. Onward to Part 3.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>kimball</category>
      <category>datamodeling</category>
      <category>database</category>
    </item>
    <item>
      <title>Kimball Dimensional Modeling, Explained Through a Coffee Shop</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:17:50 +0000</pubDate>
      <link>https://dev.to/nbaubek/kimball-dimensional-modeling-explained-through-a-coffee-shop-2cnl</link>
      <guid>https://dev.to/nbaubek/kimball-dimensional-modeling-explained-through-a-coffee-shop-2cnl</guid>
      <description>&lt;p&gt;If you've ever worked near a data warehouse, you've heard the word &lt;strong&gt;Kimball&lt;/strong&gt;. Maybe you nodded. Maybe you quietly googled it and got a 600-page textbook and closed the tab.&lt;/p&gt;

&lt;p&gt;Fair. Ralph Kimball's &lt;em&gt;The Data Warehouse Toolkit&lt;/em&gt; is a lot. But the actual ideas behind dimensional modeling are small, friendly, and once you see them in a concrete example, they stick. So we're going to learn them through a coffee shop.&lt;/p&gt;

&lt;p&gt;Specifically: &lt;strong&gt;Bean &amp;amp; Stalk&lt;/strong&gt;, a fictional cafe with two locations, a loyalty program, a chalkboard menu that changes seasonally, and — importantly for us — surprisingly messy data.&lt;/p&gt;

&lt;p&gt;This is Part 1 of a five-part series. Each part is its own self-contained business scenario, but they build on each other — the vocabulary this article establishes (grain, star schema, SCD2) is what every later part assumes you already have:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What it covers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1 — this one&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bean &amp;amp; Stalk, a coffee shop&lt;/td&gt;
&lt;td&gt;Fact vs. dimension tables, grain, star vs. snowflake, SCD Types 0–3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tabby, a SaaS cat-tracker company&lt;/td&gt;
&lt;td&gt;Subscription grain, account hierarchies, MRR, SCD2 on plan changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Crate Expectations, e-commerce fulfillment&lt;/td&gt;
&lt;td&gt;Accumulating snapshots that fork, late-arriving facts, semi-additive measures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Meadowlark Health, insurance claims&lt;/td&gt;
&lt;td&gt;Bridge tables, real many-to-many relationships, the weighting-factor trap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Back to Tabby, a contract dispute&lt;/td&gt;
&lt;td&gt;The capstone — a case with no textbook answer, three stakeholders, three defensible numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can read this one on its own; it's the fundamentals every later part leans on. Parts 2 through 5 each stand alone too, but they read best in order — Part 5 in particular assumes you've seen everything before it.&lt;/p&gt;

&lt;p&gt;By the end of &lt;em&gt;this&lt;/em&gt; article you'll understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What fact tables and dimension tables actually are (and all their sub-types)&lt;/li&gt;
&lt;li&gt;How to pick a &lt;strong&gt;grain&lt;/strong&gt; and why it's the most important decision you'll make&lt;/li&gt;
&lt;li&gt;Star vs. snowflake schemas, and why star almost always wins&lt;/li&gt;
&lt;li&gt;Slowly Changing Dimensions (SCD Types 0, 1, 2, and 3) without falling asleep&lt;/li&gt;
&lt;li&gt;How to write queries against a dimensional model that are actually pleasant to read&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a companion repo with the full schema, seed data, and exercises for every example below. We'll point at it as we go.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Kimball still matters in 2026
&lt;/h2&gt;

&lt;p&gt;You might be wondering if this is all dated. We have dbt. We have columnar cloud warehouses. We have the lakehouse. People keep declaring the death of the warehouse. Isn't dimensional modeling a relic?&lt;/p&gt;

&lt;p&gt;No. Here's why.&lt;/p&gt;

&lt;p&gt;The modern data stack changed &lt;em&gt;where&lt;/em&gt; and &lt;em&gt;how&lt;/em&gt; we store data, but it didn't change how humans think about business questions. When someone asks &lt;em&gt;"how did the oat-milk latte do in the Pacific Northwest stores last quarter, compared to the same quarter last year?"&lt;/em&gt; — they are asking for a &lt;strong&gt;fact&lt;/strong&gt; (sales) sliced by &lt;strong&gt;dimensions&lt;/strong&gt; (product, region, time). That question has not changed since 1996. It will not change in 2036.&lt;/p&gt;

&lt;p&gt;What dbt gave us is a cleaner way to &lt;em&gt;build&lt;/em&gt; those fact and dimension tables. It didn't replace the shape of the model. If anything, it made dimensional modeling more accessible, because now anyone with SQL can build a star schema in a few hours.&lt;/p&gt;

&lt;p&gt;So: Kimball isn't a legacy thing. It's a &lt;strong&gt;thinking tool&lt;/strong&gt; for making data legible to humans. Let's learn it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The core problem: operational DB ≠ analytical DB
&lt;/h2&gt;

&lt;p&gt;Meet Bean &amp;amp; Stalk. Their point-of-sale system runs on a transactional database. Tables like &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;order_items&lt;/code&gt;, &lt;code&gt;products&lt;/code&gt;, &lt;code&gt;customers&lt;/code&gt;, &lt;code&gt;payments&lt;/code&gt;, &lt;code&gt;inventory_adjustments&lt;/code&gt;. Beautifully normalized. Great for the cash register.&lt;/p&gt;

&lt;p&gt;Now the owner, Priya, wants a dashboard. &lt;em&gt;"Top drinks by month. Year-over-year growth. Which baristas upsell the most food. Loyalty members who've gone quiet."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You &lt;em&gt;could&lt;/em&gt; run those queries against the POS database directly. The first time. Maybe the second. By the fifth report you'll discover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The joins are six tables deep.&lt;/li&gt;
&lt;li&gt;A historical price change means today's &lt;code&gt;$5.25&lt;/code&gt; oat-milk latte and last year's &lt;code&gt;$4.75&lt;/code&gt; oat-milk latte look like two different products unless you're careful.&lt;/li&gt;
&lt;li&gt;Someone updated a customer's email and now you can't reconstruct what was emailed last quarter.&lt;/li&gt;
&lt;li&gt;The CEO's "monthly sales" query takes 40 seconds because it's aggregating across years of transaction rows every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The operational database is optimized for &lt;strong&gt;writing&lt;/strong&gt; (taking orders fast). The analytical database should be optimized for &lt;strong&gt;reading&lt;/strong&gt; (answering questions fast). Those two workloads want different shapes.&lt;/p&gt;

&lt;p&gt;Dimensional modeling is the shape for the analytical side.&lt;/p&gt;




&lt;h2&gt;
  
  
  Meet Bean &amp;amp; Stalk
&lt;/h2&gt;

&lt;p&gt;Let's set the scene so the schema makes sense.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two stores&lt;/strong&gt;: Mission St (the original) and Hayes Valley (the new one).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drinks and food&lt;/strong&gt;: espresso drinks, drip coffee, pastries, beans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loyalty program&lt;/strong&gt;: customers sign up, earn stamps, get a free drink after 10.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seasonal menu&lt;/strong&gt;: the pumpkin spice situation comes and goes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Baristas&lt;/strong&gt;: a small rotating cast.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now let's model it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fact tables are the verbs
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;fact table&lt;/strong&gt; holds measurable, quantitative events. Things that &lt;em&gt;happened&lt;/em&gt;. Each row is typically an event at a point in time, expressed as numeric measurements (called &lt;strong&gt;measures&lt;/strong&gt; or &lt;strong&gt;facts&lt;/strong&gt;) plus foreign keys pointing to surrounding dimensions.&lt;/p&gt;

&lt;p&gt;If dimensions are the nouns, facts are the verbs. &lt;em&gt;"We sold 2 oat-milk lattes at Mission St on Tuesday."&lt;/em&gt; — that's a verb (sold).&lt;/p&gt;

&lt;p&gt;There are several common flavors of fact table. Bean &amp;amp; Stalk will eventually have all of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Transaction fact table
&lt;/h3&gt;

&lt;p&gt;The workhorse. One row per &lt;strong&gt;event&lt;/strong&gt; at its most granular level. For us: one row per line item on an order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Simplified&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_order_line&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;order_line_sk&lt;/span&gt;    &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;receipt_number&lt;/span&gt;   &lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;-- degenerate dimension&lt;/span&gt;
    &lt;span class="n"&gt;order_date_sk&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;pickup_date_sk&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;product_sk&lt;/span&gt;       &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;customer_sk&lt;/span&gt;      &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_customer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;store_sk&lt;/span&gt;         &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;barista_sk&lt;/span&gt;       &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_barista&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;barista_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;junk_sk&lt;/span&gt;          &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_junk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;junk_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;quantity&lt;/span&gt;         &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;unit_price&lt;/span&gt;       &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;discount_amount&lt;/span&gt;  &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;line_total&lt;/span&gt;       &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every order line is a row. Two lattes on one receipt? That's one row with &lt;code&gt;quantity = 2&lt;/code&gt;. A latte and a muffin on the same receipt? Two rows. Simple.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Periodic snapshot fact table
&lt;/h3&gt;

&lt;p&gt;Some questions want a regular &lt;strong&gt;photo&lt;/strong&gt; of the world rather than a stream of events. A periodic snapshot takes a reading at a fixed interval — daily, weekly, monthly.&lt;/p&gt;

&lt;p&gt;For Bean &amp;amp; Stalk: a &lt;strong&gt;daily snapshot&lt;/strong&gt; of sales by drink by store. One row per &lt;code&gt;(date, store, product)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_daily_sales&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;daily_sales_sk&lt;/span&gt;   &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;snapshot_date_sk&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;store_sk&lt;/span&gt;         &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;product_sk&lt;/span&gt;       &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;daily_quantity&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;daily_revenue&lt;/span&gt;    &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;transaction_count&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why bother when we could just recompute from &lt;code&gt;fact_order_line&lt;/code&gt;? Three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Performance&lt;/strong&gt; — a year-over-year chart on transaction rows scans millions of rows; the snapshot scans thousands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;History of truth&lt;/strong&gt; — if someone deletes an order tomorrow, the snapshot from yesterday still tells you what was reported then.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simplicity&lt;/strong&gt; — the dashboard query becomes &lt;code&gt;SELECT ... FROM fact_daily_sales&lt;/code&gt; instead of a 12-way join.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Accumulating snapshot fact table
&lt;/h3&gt;

&lt;p&gt;This one is for &lt;strong&gt;multi-stage processes&lt;/strong&gt; that evolve over time. Think: order placed → shipped → delivered. Or for us: loyalty signup → first purchase → 5th purchase → 10th purchase (free drink earned).&lt;/p&gt;

&lt;p&gt;One row per entity (a customer's loyalty journey). Columns for each milestone date. &lt;strong&gt;You update the same row&lt;/strong&gt; as milestones are hit, rather than inserting new ones.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_loyalty_journey&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;journey_sk&lt;/span&gt;                &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_sk&lt;/span&gt;               &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_customer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;signup_date_sk&lt;/span&gt;            &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;first_purchase_date_sk&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;fifth_purchase_date_sk&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;tenth_purchase_date_sk&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;current_status&lt;/span&gt;            &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- 'signed_up', 'active', 'reward_earned', 'churned'&lt;/span&gt;
    &lt;span class="n"&gt;signup_to_first_days&lt;/span&gt;      &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;first_to_tenth_days&lt;/span&gt;       &lt;span class="nb"&gt;INT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note this is fundamentally different from a transaction fact (which never updates) or a periodic snapshot (which inserts new rows on a schedule). Accumulating snapshots are &lt;em&gt;updated in place&lt;/em&gt;. That's the tell.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Factless fact table
&lt;/h3&gt;

&lt;p&gt;Sounds like a Zen koan. It's actually simple: a fact table with &lt;strong&gt;no measures&lt;/strong&gt;, only foreign keys. It exists to record that something &lt;em&gt;was possible&lt;/em&gt; or &lt;em&gt;happened&lt;/em&gt; without a number attached.&lt;/p&gt;

&lt;p&gt;Bean &amp;amp; Stalk example: &lt;strong&gt;drink availability&lt;/strong&gt;. The pumpkin spice latte is available on certain dates and not others. That "this drink was offered on this day in this store" is a fact worth recording, even though it has no quantity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_drink_availability&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;drink_availability_sk&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;product_sk&lt;/span&gt;            &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;date_sk&lt;/span&gt;               &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;store_sk&lt;/span&gt;               &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store_sk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;-- no measures! the presence of the row IS the fact&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now "how many days was the PSL available at Mission St in 2025?" is a trivial &lt;code&gt;COUNT(*)&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quick mental model
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;When to use&lt;/th&gt;
&lt;th&gt;Bean &amp;amp; Stalk example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transaction&lt;/td&gt;
&lt;td&gt;Atomic events&lt;/td&gt;
&lt;td&gt;Each line on a receipt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Periodic snapshot&lt;/td&gt;
&lt;td&gt;Regular photos&lt;/td&gt;
&lt;td&gt;Daily sales by store/product&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accumulating snapshot&lt;/td&gt;
&lt;td&gt;Multi-stage journeys&lt;/td&gt;
&lt;td&gt;Loyalty signup → 10th drink&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Factless&lt;/td&gt;
&lt;td&gt;Coverage / eligibility&lt;/td&gt;
&lt;td&gt;Drink available on a day&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Dimension tables are the nouns
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;dimension table&lt;/strong&gt; holds the descriptive context around the facts — the &lt;strong&gt;who, what, where, when, how&lt;/strong&gt;. Dimensions are how you slice and filter. They tend to be wide (many columns) and short (far fewer rows than fact tables).&lt;/p&gt;

&lt;p&gt;Let's meet each kind through Bean &amp;amp; Stalk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Date dimension (the universal one)
&lt;/h3&gt;

&lt;p&gt;Every dimensional model needs a &lt;code&gt;dim_date&lt;/code&gt;. Yes, even in 2026. Yes, even though you could compute &lt;code&gt;EXTRACT(MONTH FROM date)&lt;/code&gt; on the fly.&lt;/p&gt;

&lt;p&gt;Two reasons. First, business calendar logic (fiscal quarters, holidays, "is this a weekend") is awful to compute and trivial to store. Second, joining on an integer &lt;code&gt;date_sk&lt;/code&gt; is faster and more compression-friendly than joining on a timestamp.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;date_sk&lt;/span&gt;       &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- e.g. 20260813 for 2026-08-13&lt;/span&gt;
    &lt;span class="n"&gt;full_date&lt;/span&gt;     &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;day_of_week&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;-- 'Monday'&lt;/span&gt;
    &lt;span class="n"&gt;day_number&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;month_number&lt;/span&gt;  &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;month_name&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;quarter&lt;/span&gt;       &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;year&lt;/span&gt;          &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_weekend&lt;/span&gt;    &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;holiday_name&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classic trick: &lt;strong&gt;role-playing dimensions&lt;/strong&gt;. Bean &amp;amp; Stalk has both an &lt;em&gt;order date&lt;/em&gt; and a &lt;em&gt;pickup date&lt;/em&gt; (mobile orders). Same &lt;code&gt;dim_date&lt;/code&gt; table, two foreign keys in the fact. In the query, you join &lt;code&gt;dim_date&lt;/code&gt; twice with different aliases (&lt;code&gt;order_date&lt;/code&gt; and &lt;code&gt;pickup_date&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Product dimension with Slowly Changing Dimensions
&lt;/h3&gt;

&lt;p&gt;Here's where it gets interesting. The &lt;code&gt;dim_product&lt;/code&gt; looks like a normal dimension:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dim_product&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;product_sk&lt;/span&gt;       &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;product_id&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;-- natural key, e.g. 'OAT_LATTE'&lt;/span&gt;
    &lt;span class="n"&gt;product_name&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;-- 'ESPRESSO_DRINK', 'DRIPO', 'PASTRY'&lt;/span&gt;
    &lt;span class="n"&gt;base_price&lt;/span&gt;       &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;recipe_notes&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;-- SCD Type 2 columns:&lt;/span&gt;
    &lt;span class="n"&gt;valid_from&lt;/span&gt;       &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valid_to&lt;/span&gt;         &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_current&lt;/span&gt;       &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But Bean &amp;amp; Stalk changes things over time. The oat-milk latte's recipe changed in March (new oat milk vendor). Its price went up in June. How do you model that?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the SCD problem.&lt;/strong&gt; Slowly Changing Dimensions. There are four common strategies. Let's walk through them with the same example.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SCD Type 0 — retain original.&lt;/strong&gt; Never change the value. The original row is the row. Useful for things that should be immutable, like the date a customer signed up. Bean &amp;amp; Stalk treats &lt;code&gt;product_id&lt;/code&gt; this way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SCD Type 1 — overwrite.&lt;/strong&gt; Just update the row. Old value is lost. Use this when history doesn't matter. For a typo fix in a product description (&lt;code&gt;Oat Milk Latte&lt;/code&gt; → &lt;code&gt;Oat-Milk Latte&lt;/code&gt;), Type 1 is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SCD Type 2 — add a new row.&lt;/strong&gt; Insert a new row with the new values, expire the old row by setting &lt;code&gt;valid_to&lt;/code&gt; and &lt;code&gt;is_current = false&lt;/code&gt;. This preserves full history. For price and recipe changes, Bean &amp;amp; Stalk uses Type 2.&lt;/p&gt;

&lt;p&gt;A Type 2 product history looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;product_sk&lt;/th&gt;
&lt;th&gt;product_id&lt;/th&gt;
&lt;th&gt;product_name&lt;/th&gt;
&lt;th&gt;base_price&lt;/th&gt;
&lt;th&gt;valid_from&lt;/th&gt;
&lt;th&gt;valid_to&lt;/th&gt;
&lt;th&gt;is_current&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;OAT_LATTE&lt;/td&gt;
&lt;td&gt;Oat-Milk Latte&lt;/td&gt;
&lt;td&gt;4.75&lt;/td&gt;
&lt;td&gt;2025-01-01&lt;/td&gt;
&lt;td&gt;2025-06-14&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;OAT_LATTE&lt;/td&gt;
&lt;td&gt;Oat-Milk Latte&lt;/td&gt;
&lt;td&gt;5.25&lt;/td&gt;
&lt;td&gt;2025-06-15&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now your historical reports use the price &lt;em&gt;that was actually charged at the time&lt;/em&gt;, not today's price. This is the whole point.&lt;/p&gt;

&lt;p&gt;Laid out on a timeline, the two rows look like this — the fact table always points at whichever &lt;code&gt;product_sk&lt;/code&gt; was valid on the day the sale happened, so a March query and an August query silently pick up different rows without needing any date logic of their own:&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0vsbyg6c9hhzfjtgxjmd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0vsbyg6c9hhzfjtgxjmd.png" alt="SCD Type 2 timeline: two product_sk rows covering non-overlapping date ranges, with fact rows pointing at whichever was valid on the sale date" width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;SCD Type 3 — add a column.&lt;/strong&gt; Keep the old value in a separate column (&lt;code&gt;previous_base_price&lt;/code&gt;, &lt;code&gt;previous_valid_until&lt;/code&gt;). Useful when you only care about the &lt;em&gt;previous&lt;/em&gt; state, not the full history. Bean &amp;amp; Stalk doesn't bother with Type 3 for products — Type 2 is strictly more powerful — but you'll see it in domains where people genuinely only care about "before vs after" (a plan's previous tier, an employee's previous role).&lt;/p&gt;

&lt;p&gt;The takeaway: &lt;strong&gt;Type 1 and Type 2 cover ~95% of real-world cases.&lt;/strong&gt; Don't reach for the others unless you have a specific reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  Customer dimension
&lt;/h3&gt;

&lt;p&gt;Standard Type 1-ish dimension for Bean &amp;amp; Stalk's loyalty members.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dim_customer&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;customer_sk&lt;/span&gt;    &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;           &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;loyalty_number&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;signup_date&lt;/span&gt;    &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;loyalty_tier&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt;                &lt;span class="c1"&gt;-- 'BRONZE', 'SILVER', 'GOLD'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If we cared about tracking tier changes over time (Bronze → Silver → Gold), we'd make this Type 2 too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Store and barista dimensions
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;dim_store&lt;/code&gt; is a small, stable dimension. &lt;code&gt;dim_barista&lt;/code&gt; holds employee attributes. Both Type 1.&lt;/p&gt;

&lt;h3&gt;
  
  
  Junk dimension
&lt;/h3&gt;

&lt;p&gt;This is a fun one. Bean &amp;amp; Stalk has several low-cardinality flags: &lt;code&gt;size&lt;/code&gt; (small/medium/large), &lt;code&gt;milk_type&lt;/code&gt; (whole/oat/almond/soy/none), &lt;code&gt;syrup_flavor&lt;/code&gt; (none/vanilla/caramel/hazelnut), &lt;code&gt;extra_shot&lt;/code&gt; (true/false). None of these deserve their own dimension. But putting each one as a column directly on the fact table is fine too — except it clutters things.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;junk dimension&lt;/strong&gt; combines them into one small table of all observed combinations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dim_junk&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;junk_sk&lt;/span&gt;       &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;size&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;-- SMALL / MEDIUM / LARGE&lt;/span&gt;
    &lt;span class="n"&gt;milk_type&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;-- WHOLE / OAT / ALMOND / SOY / NONE&lt;/span&gt;
    &lt;span class="n"&gt;syrup_flavor&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;-- NONE / VANILLA / CARAMEL / HAZELNUT&lt;/span&gt;
    &lt;span class="n"&gt;extra_shot&lt;/span&gt;    &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fact table holds a single &lt;code&gt;junk_sk&lt;/code&gt; foreign key. Now all those flags live in one tidy place and you can still slice by them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Degenerate dimension
&lt;/h3&gt;

&lt;p&gt;A &lt;strong&gt;degenerate dimension&lt;/strong&gt; is a dimension key that has no dimension table — it just lives in the fact. The classic case is a &lt;strong&gt;receipt number&lt;/strong&gt; or &lt;strong&gt;order number&lt;/strong&gt;. There's no &lt;code&gt;dim_receipt&lt;/code&gt;; the &lt;code&gt;receipt_number&lt;/code&gt; column sits directly on &lt;code&gt;fact_order_line&lt;/code&gt; so you can group all the lines of one receipt back together. Simple, useful, slightly weird name.&lt;/p&gt;




&lt;h2&gt;
  
  
  Choosing the grain
&lt;/h2&gt;

&lt;p&gt;If you take one thing from this article, take this: &lt;strong&gt;pick your grain first, before anything else.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;grain&lt;/strong&gt; is the precise definition of what one row in a fact table represents. Until you can state the grain in one plain sentence, you're not ready to build the table.&lt;/p&gt;

&lt;p&gt;For Bean &amp;amp; Stalk's transaction fact, three plausible grains:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One row per order.&lt;/strong&gt; Pros: tiny table. Cons: can't analyze individual drinks within an order. The oat-lattes-plus-muffin combo question becomes impossible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One row per order line.&lt;/strong&gt; Pros: fully flexible — you can always roll up to order level with &lt;code&gt;SUM(...) GROUP BY receipt_number&lt;/code&gt;. Cons: bigger table. &lt;em&gt;(This is what we chose.)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One row per drink modification.&lt;/strong&gt; Pros: maximally detailed. Cons: enormous table, and "how many lattes did we sell?" requires careful un-nesting. Overkill for a cafe.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The right grain is the &lt;strong&gt;most granular level at which a business event occurs that you'd want to analyze separately&lt;/strong&gt;. For Bean &amp;amp; Stalk that's the order line. Lower than that (sub-line modifications) is overkill; higher than that (whole order) loses detail.&lt;/p&gt;

&lt;p&gt;Same logic applies everywhere: an e-commerce fact is usually one row per order line; a payments fact might be one row per transaction; a web analytics fact might be one row per page view. State the grain out loud. If it sounds weird, reconsider.&lt;/p&gt;




&lt;h2&gt;
  
  
  Star vs. snowflake
&lt;/h2&gt;

&lt;p&gt;You have two layout choices for a dimensional model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Star schema&lt;/strong&gt;: the fact table sits in the middle, dimensions radiate out, and each dimension is a flat, denormalized table. One hop from fact to any dimension.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snowflake schema&lt;/strong&gt;: dimensions are themselves normalized. &lt;code&gt;dim_product&lt;/code&gt; references &lt;code&gt;dim_category&lt;/code&gt;, which references &lt;code&gt;dim_department&lt;/code&gt;. Three hops to get to department.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhy0ndaqbarejd0ivb49c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhy0ndaqbarejd0ivb49c.png" alt="Star schema: fact_order_line in the center with dim_date, dim_product, dim_customer, dim_store, dim_barista, and dim_junk radiating out one hop away, versus snowflake: dim_product further normalized into dim_category and dim_department" width="800" height="580"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;Snowflake &lt;em&gt;looks&lt;/em&gt; cleaner to a normalized-DB brain. It uses less storage. In the 90s, when storage was expensive, that mattered.&lt;/p&gt;

&lt;p&gt;In 2026, &lt;strong&gt;use star.&lt;/strong&gt; Almost always. Reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Query simplicity.&lt;/strong&gt; One join, not three. Your dashboard authors will thank you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Columnar warehouses love wide, flat dimensions.&lt;/strong&gt; Compression is excellent. Joins are cheap. Snowflake normalization actively hurts you here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human legibility.&lt;/strong&gt; A star schema can be read and understood by an analyst in 30 seconds. A snowflake requires tracing keys around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage is cheap.&lt;/strong&gt; The thing snowflake optimized for is no longer scarce.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Normalize your operational database. &lt;strong&gt;Denormalize&lt;/strong&gt; your analytical one. That's the whole game.&lt;/p&gt;




&lt;h2&gt;
  
  
  A real query
&lt;/h2&gt;

&lt;p&gt;Let's put it together. &lt;strong&gt;Top-selling drinks by month, with a barista leaderboard for the top drink.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Top drinks by month&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_sold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;line_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_order_line&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;   &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_date_sk&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_product&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_sk&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;year&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2025&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_name&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole query. Notice how readable it is. Five joins, all one-hop, clear aliases. That's the star schema paying off.&lt;/p&gt;

&lt;p&gt;Now the barista leaderboard for the top drink of the year:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;top_drink&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_name&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_order_line&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
    &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_product&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_sk&lt;/span&gt;
    &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_sk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_name&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;line_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
    &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;barista_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;drinks_made&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;line_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_order_line&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_barista&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;barista_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;barista_sk&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;top_drink&lt;/span&gt;    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_sk&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;barista_name&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try writing that against the normalized POS schema. It's possible, but it'll take three times the SQL and ten times the thinking.&lt;/p&gt;

&lt;p&gt;The full version (with role-playing date dimensions, SCD2-aware joins, and the "as-of" lookup pattern) is in queries.sql in the companion repo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;p&gt;These are common. Avoid them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Duplicate facts in different tables.&lt;/strong&gt; If &lt;code&gt;line_total&lt;/code&gt; lives in &lt;code&gt;fact_order_line&lt;/code&gt;, don't also store a precomputed &lt;code&gt;daily_total&lt;/code&gt; next to it in a different fact. Compute it at query time, or build a proper snapshot fact. Two sources of the same number always drift.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Storing aggregates next to base rows.&lt;/strong&gt; Adding a &lt;code&gt;monthly_total&lt;/code&gt; column to &lt;code&gt;fact_order_line&lt;/code&gt; is a Category 5 anti-pattern. It will be wrong by the second week.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Forgetting SCD2 on things that change.&lt;/strong&gt; If your product price changed and you overwrote the row, every historical report is now silently incorrect. Use Type 2.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Putting measures in dimension tables.&lt;/strong&gt; A "current price" column on &lt;code&gt;dim_product&lt;/code&gt; is fine. A "total units sold last quarter" column on &lt;code&gt;dim_product&lt;/code&gt; is a trap — it'll be stale and it muddies the dimension's purpose.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mixing grains in one fact table.&lt;/strong&gt; One row per order line &lt;em&gt;and&lt;/em&gt; one row per daily summary in the same table? No. Either split into two facts or pick the lower grain.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Using natural keys as surrogate keys.&lt;/strong&gt; &lt;code&gt;product_id&lt;/code&gt; is great as a natural key but don't make it the primary key of &lt;code&gt;dim_product&lt;/code&gt; — Type 2 means you'll have multiple rows per &lt;code&gt;product_id&lt;/code&gt;. Always use a surrogate &lt;code&gt;product_sk&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Exercises
&lt;/h2&gt;

&lt;p&gt;A few quick ones to test your understanding. Hints are hidden — click to expand. Full solutions and more practice are in exercises.sql in the companion repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1.&lt;/strong&gt; Bean &amp;amp; Stalk wants to track which baristas work which shifts. Would you model "shift" as its own fact table, a dimension, or an attribute on the barista? Why?&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  Think about whether a shift is an &lt;em&gt;event&lt;/em&gt; (verb → fact) or a &lt;em&gt;descriptor&lt;/em&gt; (noun → dimension). Could it be both? What questions would each shape let you answer?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.&lt;/strong&gt; Write a query that uses role-playing date dimensions to find orders where the &lt;strong&gt;pickup date&lt;/strong&gt; was a different day than the &lt;strong&gt;order date&lt;/strong&gt; (i.e., mobile pre-orders for tomorrow).&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  Join &lt;code&gt;dim_date&lt;/code&gt; twice with different aliases, once on &lt;code&gt;order_date_sk&lt;/code&gt; and once on &lt;code&gt;pickup_date_sk&lt;/code&gt;. Then filter where the two &lt;code&gt;full_date&lt;/code&gt; values differ.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3.&lt;/strong&gt; The oat-milk latte's price changed on 2025-06-15. Using the SCD2 product dimension, write a query to compute total revenue for the oat-milk latte where each transaction uses the price that was &lt;em&gt;actually in effect at the time&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  The fact table's &lt;code&gt;product_sk&lt;/code&gt; already points at the correct historical row of &lt;code&gt;dim_product&lt;/code&gt; (that's the SCD2 magic). You don't need any extra date filtering on the dimension — just sum &lt;code&gt;line_total&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4.&lt;/strong&gt; Bean &amp;amp; Stalk launches a "drink of the week" promotion. How would you model that using a factless fact table?&lt;/p&gt;

&lt;p&gt;Hint&lt;br&gt;
  Each row: &lt;code&gt;(product_sk, date_sk, store_sk)&lt;/code&gt;. No measures. The row's existence &lt;em&gt;is&lt;/em&gt; the fact "this drink was promoted on this day at this store."&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;This article covered the conceptual fundamentals — the things that show up in &lt;em&gt;every&lt;/em&gt; dimensional model. From here, the series gets progressively less textbook and more "here's what actually goes wrong in practice":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/nbaubek/kimball-for-saas-subscriptions-mrr-and-churn-modeled-right-2min"&gt;Part 2&lt;/a&gt;&lt;/strong&gt; applies these fundamentals to SaaS subscriptions — MRR reconstructed as-of arbitrary dates, account hierarchies, and SCD2 doing real work on plan changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/nbaubek/kimball-for-order-fulfillment-milestones-split-shipments-and-facts-that-arrive-late-3llc"&gt;Part 3&lt;/a&gt;&lt;/strong&gt; covers accumulating snapshots that don't behave — orders that split into multiple shipments, carrier webhooks that arrive out of order, and a measure that genuinely can't be summed across days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/nbaubek/kimball-for-many-to-many-bridge-tables-weighting-factors-and-the-diagnosis-code-problem-3ei7"&gt;Part 4&lt;/a&gt;&lt;/strong&gt; is bridge tables — a fact that's legitimately about more than one dimension value at once, and the trap of inventing money the moment you join through one carelessly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/nbaubek/kimballs-last-hard-problem-when-there-is-no-right-grain-3lbk"&gt;Part 5&lt;/a&gt;&lt;/strong&gt; is the capstone: a scenario with no textbook answer, where three stakeholders each have a defensible number for the same deal, and the job is building a model honest enough to tell the truth to all three at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same ideas throughout, meaner problems each time. Start with Part 2 whenever you're ready.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ralph Kimball &amp;amp; Margy Ross, *The Data Warehouse Toolkit&lt;/strong&gt;* — the book. Still the best reference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://docs.getdbt.com/best-practices/how-we-structure/1-guide-overview" rel="noopener noreferrer"&gt;dbt — Modeling your data&lt;/a&gt;&lt;/strong&gt; — modern take on dimensional modeling with dbt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://docs.getdbt.com/best-practices" rel="noopener noreferrer"&gt;Analytics Engineering on the dbt blog&lt;/a&gt;&lt;/strong&gt; — patterns and anti-patterns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.startdataengineering.com/" rel="noopener noreferrer"&gt;Joseph Machado's Start Data Engineering&lt;/a&gt;&lt;/strong&gt; — solid practical writeups.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If this was useful, the companion repo has the full schema, seed data, and a quiz that tests all of this. Parts 2 through 5 apply everything here to domains that break it in progressively more interesting ways. See you there.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>datamodeling</category>
      <category>datawarehouse</category>
      <category>kimball</category>
    </item>
    <item>
      <title>Normalize It, Then Break It On Purpose: 3NF to Star Schema, Explained Through Food Delivery</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Fri, 04 Sep 2026 16:37:43 +0000</pubDate>
      <link>https://dev.to/nbaubek/normalize-it-then-break-it-on-purpose-3nf-to-star-schema-explained-through-food-delivery-kp7</link>
      <guid>https://dev.to/nbaubek/normalize-it-then-break-it-on-purpose-3nf-to-star-schema-explained-through-food-delivery-kp7</guid>
      <description>&lt;p&gt;Every data engineer eventually runs into the same apparent contradiction: the database design that every textbook, every senior review, and every "how do I avoid duplicate data" instinct insists is &lt;em&gt;correct&lt;/em&gt; turns out to be the wrong shape the moment someone asks a real business question about it. That's not a contradiction. It's two different jobs sharing one word — "database" — when they actually want opposite things from how the data is laid out.&lt;/p&gt;

&lt;p&gt;This article walks both halves, in order, on one running example: build a properly normalized schema from a genuinely messy starting point, watch it become painful the moment someone wants to &lt;em&gt;ask&lt;/em&gt; something of it, and then deliberately undo the normalization — on purpose, for a documented reason — into a star schema built for exactly that question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meet Curb Appetite
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Curb Appetite&lt;/strong&gt; is a food delivery app: customers order from local restaurants, a driver picks it up and delivers it, everyone involved generates data. The starting point is the kind of table that actually exists in a lot of early-stage companies — a flat export somebody built to get the app shipped, never designed, just grown:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;order_id&lt;/th&gt;
&lt;th&gt;order_date&lt;/th&gt;
&lt;th&gt;customer_name&lt;/th&gt;
&lt;th&gt;customer_email&lt;/th&gt;
&lt;th&gt;customer_city&lt;/th&gt;
&lt;th&gt;customer_state&lt;/th&gt;
&lt;th&gt;customer_zip&lt;/th&gt;
&lt;th&gt;restaurant_name&lt;/th&gt;
&lt;th&gt;restaurant_cuisine&lt;/th&gt;
&lt;th&gt;driver_name&lt;/th&gt;
&lt;th&gt;driver_phone&lt;/th&gt;
&lt;th&gt;items_ordered&lt;/th&gt;
&lt;th&gt;order_total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5001&lt;/td&gt;
&lt;td&gt;2026-03-14&lt;/td&gt;
&lt;td&gt;Priya Shah&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:priya@example.com"&gt;priya@example.com&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Austin&lt;/td&gt;
&lt;td&gt;TX&lt;/td&gt;
&lt;td&gt;78701&lt;/td&gt;
&lt;td&gt;Bangkok Nights&lt;/td&gt;
&lt;td&gt;Thai&lt;/td&gt;
&lt;td&gt;Marcus Webb&lt;/td&gt;
&lt;td&gt;512-555-0142&lt;/td&gt;
&lt;td&gt;Pad Thai x2, Spring Rolls x1, Thai Iced Tea x1&lt;/td&gt;
&lt;td&gt;38.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5002&lt;/td&gt;
&lt;td&gt;2026-03-14&lt;/td&gt;
&lt;td&gt;Diego Ruiz&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:diego@example.com"&gt;diego@example.com&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Austin&lt;/td&gt;
&lt;td&gt;TX&lt;/td&gt;
&lt;td&gt;78701&lt;/td&gt;
&lt;td&gt;Bangkok Nights&lt;/td&gt;
&lt;td&gt;Thai&lt;/td&gt;
&lt;td&gt;Alicia Nguyen&lt;/td&gt;
&lt;td&gt;512-555-0198&lt;/td&gt;
&lt;td&gt;Green Curry x1, Thai Iced Tea x2&lt;/td&gt;
&lt;td&gt;23.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This table works, in the sense that it renders a receipt. It's also a small museum of everything normalization exists to fix, and every violation in it will cost someone real time later. Let's fix them in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting to First Normal Form: atomic values, no repeating groups
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First Normal Form (1NF)&lt;/strong&gt; requires that every column hold a single, atomic value — no lists, no repeating groups crammed into one field — and that every row be uniquely identifiable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;items_ordered&lt;/code&gt; fails immediately: &lt;code&gt;"Pad Thai x2, Spring Rolls x1, Thai Iced Tea x1"&lt;/code&gt; is three facts wearing one column. Ask "how many Pad Thais did we sell this month" against this table and the honest answer is: you can't, not with SQL — you'd need to parse a string first. That's the tell for a 1NF violation: if answering a normal-sounding question requires string-splitting a column, the column is doing the job of a table.&lt;/p&gt;

&lt;p&gt;The fix is to give each item its own row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- order_items, one row per item on an order&lt;/span&gt;
&lt;span class="c1"&gt;-- (not yet normalized further — watch what's still duplicated)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;menu_item_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;item_name&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;item_price&lt;/span&gt;   &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;quantity&lt;/span&gt;     &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;menu_item_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;order_id  menu_item_id  item_name       item_price  quantity
5001      MI-101        Pad Thai        14.00       2
5001      MI-102        Spring Rolls    6.50        1
5001      MI-103        Thai Iced Tea   4.00        1
5002      MI-104        Green Curry     15.00       1
5002      MI-103        Thai Iced Tea   4.00        2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;items_ordered&lt;/code&gt; is gone from &lt;code&gt;orders&lt;/code&gt;, replaced by this table. Every value is now atomic, and "how many Pad Thais did we sell" is &lt;code&gt;SUM(quantity) WHERE item_name = 'Pad Thai'&lt;/code&gt; instead of a parsing exercise. Technically 1NF-compliant — but look at &lt;code&gt;MI-103&lt;/code&gt; appearing twice, at the same price, on two unrelated orders. That's not a coincidence, and it's not fixed yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting to Second Normal Form: no partial dependencies
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Second Normal Form (2NF)&lt;/strong&gt; requires 1NF, plus: every non-key column must depend on the &lt;em&gt;entire&lt;/em&gt; primary key — not just part of it. This only ever bites when a table has a composite key, which &lt;code&gt;order_items&lt;/code&gt; does: &lt;code&gt;(order_id, menu_item_id)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Ask what &lt;code&gt;item_name&lt;/code&gt; and &lt;code&gt;item_price&lt;/code&gt; actually depend on, and the honest answer is: only &lt;code&gt;menu_item_id&lt;/code&gt;. Bangkok Nights' Thai Iced Tea costs $4.00 regardless of which order it's attached to — &lt;code&gt;order_id&lt;/code&gt; contributes nothing to that fact. That's a &lt;strong&gt;partial dependency&lt;/strong&gt;, and it's exactly why &lt;code&gt;MI-103&lt;/code&gt; shows up twice with the same price above: the price isn't stored once, it's stored once &lt;em&gt;per order line that happens to include it&lt;/em&gt;. Raise the price to $4.50 tomorrow and you have to find and update every historical row that references it, or old and new orders quietly disagree about what a Thai Iced Tea costs.&lt;/p&gt;

&lt;p&gt;The fix is to extract what the item actually is from what was ordered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;menu_items&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;menu_item_id&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;restaurant_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;restaurants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;restaurant_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;item_name&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;item_price&lt;/span&gt;    &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;menu_item_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;menu_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;menu_item_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;quantity&lt;/span&gt;     &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;menu_item_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now Thai Iced Tea's price exists in exactly one row, in &lt;code&gt;menu_items&lt;/code&gt;, and &lt;code&gt;order_items&lt;/code&gt; just references it. One update, everywhere correct.&lt;/p&gt;

&lt;p&gt;Worth a clarifying note here, because it trips people up: &lt;code&gt;orders&lt;/code&gt; itself was never at risk of a 2NF violation, because its primary key (&lt;code&gt;order_id&lt;/code&gt;) is a single column. 2NF violations are specifically about &lt;em&gt;partial&lt;/em&gt; dependency on a &lt;em&gt;composite&lt;/em&gt; key — with a single-column key, every non-key column depends on 100% of the key by definition, so there's no "partial" to violate. 2NF only ever does work on tables like &lt;code&gt;order_items&lt;/code&gt;, where more than one column makes up the key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting to Third Normal Form: no transitive dependencies
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Third Normal Form (3NF)&lt;/strong&gt; requires 2NF, plus: no non-key column may depend on another non-key column instead of on the primary key directly. This is called a &lt;strong&gt;transitive dependency&lt;/strong&gt;, and &lt;code&gt;orders&lt;/code&gt; is full of them.&lt;/p&gt;

&lt;p&gt;Look at &lt;code&gt;customer_city&lt;/code&gt; and &lt;code&gt;customer_state&lt;/code&gt;. They don't actually describe the order — they describe the customer, by way of the customer's zip code. &lt;code&gt;order_id → customer_id → zip → city/state&lt;/code&gt; is a chain, and 3NF says a column has to depend on the key &lt;em&gt;directly&lt;/em&gt;, not by riding along on another attribute's coattails. The same thing is true of &lt;code&gt;restaurant_name&lt;/code&gt;/&lt;code&gt;restaurant_cuisine&lt;/code&gt; (they describe the restaurant, not the order) and &lt;code&gt;driver_name&lt;/code&gt;/&lt;code&gt;driver_phone&lt;/code&gt; (they describe the driver).&lt;/p&gt;

&lt;p&gt;Notice something in the sample data above: both orders share &lt;code&gt;zip = 78701&lt;/code&gt;, and both store &lt;code&gt;"Austin", "TX"&lt;/code&gt; redundantly. That's the anomaly made visible — if a zip code's city assignment ever needs correcting, you're hunting down every order row that happens to reference it, instead of fixing one row in one place.&lt;/p&gt;

&lt;p&gt;The fix, applied consistently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;zip_codes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;zip&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;city&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;zip&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;zip_codes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;restaurants&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;restaurant_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cuisine&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;drivers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;driver_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;phone&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;order_date&lt;/span&gt;    &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;restaurant_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;restaurants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;restaurant_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;driver_id&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;drivers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;order_total&lt;/span&gt;   &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;orders&lt;/code&gt; has shrunk from thirteen columns to six. Every fact in the schema now lives in exactly one place, and every non-key column depends on nothing but its own table's primary key. Seven tables, zero duplicated facts, zero update anomalies. This is a genuinely good schema — for the job it's designed for.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F01jrc44ob1etul38d8o2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F01jrc44ob1etul38d8o2.png" alt="orders_raw splitting through 1NF, 2NF, and 3NF into a fully normalized seven-table schema, each step labeled with the specific dependency it fixes" width="800" height="2707"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;Here's the full result as an entity-relationship diagram — the thing normalization was building toward the whole time:&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgfqrubn4ngbbafqw0zox.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgfqrubn4ngbbafqw0zox.png" alt="Entity-relationship diagram of the final 3NF schema: customers, zip_codes, restaurants, menu_items, drivers, orders, and order_items, with primary and foreign keys marked" width="800" height="907"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;h2&gt;
  
  
  This is a good schema. It is not a good answer.
&lt;/h2&gt;

&lt;p&gt;Curb Appetite's normalized schema is exactly right for what it's for: taking an order, charging a card, dispatching a driver, without ever risking two rows disagreeing about a fact that should only exist once. It's optimized for &lt;strong&gt;writes&lt;/strong&gt; — specifically, for writes that can never quietly corrupt themselves.&lt;/p&gt;

&lt;p&gt;Now someone in ops asks a completely reasonable question: &lt;em&gt;"What's our revenue by cuisine, by city, by month?"&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cuisine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;mi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;item_price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;menu_items&lt;/span&gt; &lt;span class="n"&gt;mi&lt;/span&gt;   &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;mi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;menu_item_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;menu_item_id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;restaurants&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;   &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restaurant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restaurant_id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;     &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;zip_codes&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;     &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zip&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cuisine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six joins across seven tables, for a question that isn't even asking for anything unusual. This isn't a sign the normalization was done wrong — it's the opposite. Every one of those joins exists precisely &lt;em&gt;because&lt;/em&gt; the schema is correctly normalized: cuisine lives with the restaurant, city lives with the zip, price lives with the menu item, none of it duplicated anywhere. Correctness for writes and convenience for reads are different design goals, and a schema optimized entirely for the first will always look like this the moment you ask it to do the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Denormalizing on purpose: from 3NF to star schema
&lt;/h2&gt;

&lt;p&gt;The fix isn't to loosen the normalized schema — that would reintroduce the exact update anomalies it exists to prevent, in the system that's still taking live orders. The fix is to build a &lt;strong&gt;second, derived schema&lt;/strong&gt;, populated from the normalized one on a schedule, shaped entirely around the read side. This is a Kimball-style &lt;strong&gt;star schema&lt;/strong&gt;: one fact table at a clearly stated grain, surrounded by denormalized dimension tables, one join away from anything.&lt;/p&gt;

&lt;p&gt;First, the grain — the single most important decision in the whole exercise, stated as a sentence before any SQL: &lt;strong&gt;one row per item on an order.&lt;/strong&gt; Not one row per order (too coarse — you'd lose the ability to ask "how did Pad Thai do specifically"), not one row per delivery event (too fine — nothing here needs that granularity).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;fact_order_line&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;order_line_sk&lt;/span&gt;   &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- degenerate dimension&lt;/span&gt;
    &lt;span class="n"&gt;order_date_sk&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;customer_sk&lt;/span&gt;     &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_customer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;restaurant_sk&lt;/span&gt;   &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_restaurant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;restaurant_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;menu_item_sk&lt;/span&gt;    &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_menu_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;menu_item_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;driver_sk&lt;/span&gt;       &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dim_driver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;driver_sk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;quantity&lt;/span&gt;        &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;item_price&lt;/span&gt;      &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;line_total&lt;/span&gt;      &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the dimensions — each one flattening back together exactly what 3NF just spent three sections pulling apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dim_customer&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;customer_sk&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;city&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- denormalized back in from zip_codes&lt;/span&gt;
    &lt;span class="k"&gt;state&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;zip&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dim_restaurant&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;restaurant_sk&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;restaurant_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cuisine&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dim_menu_item&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;menu_item_sk&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;menu_item_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;item_name&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;restaurant_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dim_driver&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;driver_sk&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;driver_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
    &lt;span class="c1"&gt;-- phone didn't make the cut: a support agent's tool needs it,&lt;/span&gt;
    &lt;span class="c1"&gt;-- an analyst asking "which drivers deliver fastest" doesn't.&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;city&lt;/code&gt; and &lt;code&gt;state&lt;/code&gt; are back on &lt;code&gt;dim_customer&lt;/code&gt;, duplicated across every customer in the same zip — exactly the redundancy 3NF removed. That's not a mistake here; it's the point. A dimension table is small relative to the fact table and read far more than it's written, so the storage cost of the duplication is negligible and the join it saves is real, on every single query.&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fho86dmfog5178el6i7w3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fho86dmfog5178el6i7w3.png" alt="The 3NF schema as a six-join tangle next to the star schema, where fact_order_line sits one hop away from every dimension it needs" width="800" height="525"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;h2&gt;
  
  
  The same question, asked of the star schema
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cuisine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;line_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fact_order_line&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_restaurant&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restaurant_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restaurant_sk&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_customer&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;   &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_sk&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dim_date&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;       &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date_sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_date_sk&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cuisine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;year&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three joins, all one hop, instead of six across seven tables. Nothing about the underlying facts changed — this is the exact same revenue, sliced the exact same way. What changed is which design goal the schema is optimized for, and the query got dramatically simpler because the schema stopped fighting the question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Denormalizing the system that's still taking orders.&lt;/strong&gt; The star schema is a second, derived copy, built by a scheduled job from the normalized source — not a replacement for it. The normalized schema keeps doing what it's good at (safe writes); the star schema is where reads happen.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treating "denormalized" as "no rules."&lt;/strong&gt; Grain still has to be chosen and stated as a sentence before any table gets built — "one row per order" instead of "one row per order line" would have silently made the Pad Thai question impossible again, just for a different reason than before.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Forgetting that a price can change.&lt;/strong&gt; Bangkok Nights raises the Thai Iced Tea price next month. If &lt;code&gt;dim_menu_item&lt;/code&gt; just gets updated in place, every historical &lt;code&gt;fact_order_line&lt;/code&gt; row that references it will &lt;em&gt;appear&lt;/em&gt;, on next query, to have been sold at the new price — which is wrong, and silently wrong, for every report touching last quarter. This is a slowly changing dimension problem, and it needs a real answer, not a shrug.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Rebuilding the star schema by hand, forever.&lt;/strong&gt; This whole pipeline — normalized source, transform, star schema — is exactly the kind of thing that belongs in a scheduled, tested job, not a one-off script someone reruns when the numbers look stale.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Normalization and denormalization aren't opposing philosophies where one side is right — they're answers to two different questions: "how do I write this safely" and "how do I read this quickly."&lt;/li&gt;
&lt;li&gt;1NF, 2NF, and 3NF each fix one specific kind of redundancy — repeating groups, partial dependency on a composite key, transitive dependency on a non-key column — in that order, because each one assumes the last is already fixed.&lt;/li&gt;
&lt;li&gt;The joins that make a normalized schema painful to query are the same joins that make it safe to write to. That's not a design flaw to route around; it's the tradeoff, made visible.&lt;/li&gt;
&lt;li&gt;A star schema doesn't replace the normalized schema — it's a second, deliberately redundant copy, built for a different job, kept in sync on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;p&gt;If this is the first time you've deliberately denormalized something instead of just being told "star schemas are good, snowflakes are bad," the five-part Kimball series on this profile goes considerably deeper on the read side specifically — grain, star vs. snowflake, slowly changing dimensions (including the exact menu-item-price problem flagged above), accumulating snapshots, bridge tables, and a case with no textbook-correct grain at all. This article is the piece that comes before all of it.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>sql</category>
      <category>datamodeling</category>
    </item>
    <item>
      <title>Testing Data Pipelines Like You Mean It: A pytest Crash Course for Data Engineers</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Wed, 02 Sep 2026 06:57:38 +0000</pubDate>
      <link>https://dev.to/nbaubek/testing-data-pipelines-like-you-mean-it-a-pytest-crash-course-for-data-engineers-24</link>
      <guid>https://dev.to/nbaubek/testing-data-pipelines-like-you-mean-it-a-pytest-crash-course-for-data-engineers-24</guid>
      <description>&lt;p&gt;Most data engineers write pipelines the way most people write shell scripts: run it, eyeball the output, ship it. That works right up until a schema changes upstream, a null slips through a join, or someone "fixes" a transformation and silently breaks three downstream tables. By then the bug isn't your problem anymore — it's a bad number in someone's dashboard.&lt;/p&gt;

&lt;p&gt;Software engineers solved this problem decades ago with automated testing. Data engineering has been slower to adopt the habit, partly because our code touches messy external reality (files, databases, clusters) in a way a typical web app doesn't. But that's exactly why testing matters more here, not less. This article is a practical, DE-flavored crash course in &lt;code&gt;pytest&lt;/code&gt; — the dominant Python testing framework — plus the patterns you actually need for pandas, Polars, and PySpark pipelines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother testing a data pipeline?
&lt;/h2&gt;

&lt;p&gt;A few concrete failure modes that tests catch before production does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A column gets renamed upstream and your join silently produces all-null matches instead of erroring.&lt;/li&gt;
&lt;li&gt;A "cleaning" function that's supposed to drop duplicates accidentally drops valid rows too.&lt;/li&gt;
&lt;li&gt;A date-parsing function works on your local machine's locale and breaks in the CI environment.&lt;/li&gt;
&lt;li&gt;A refactor changes an aggregation from &lt;code&gt;sum&lt;/code&gt; to &lt;code&gt;mean&lt;/code&gt; and nobody notices until finance asks why revenue looks 90% smaller.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these require exotic testing techniques. They require the habit of writing small, deterministic checks against small, deterministic inputs — which is exactly what pytest is built for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where pytest fits — and where it doesn't
&lt;/h2&gt;

&lt;p&gt;Before diving in, it's worth being precise about scope, because "testing a data pipeline" actually covers two different questions, and conflating them is a common source of confusion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Is my code correct?&lt;/strong&gt; Given a known input, does the transformation logic produce the right output? This is a property of your &lt;em&gt;code&lt;/em&gt;, and it doesn't change based on what day it is or what a source system decided to send you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is today's data correct?&lt;/strong&gt; Even with perfect code, a source system can start sending nulls, a partner feed can drop 90% of its rows overnight, a foreign key can stop resolving. This is a property of &lt;em&gt;the data currently flowing through the system&lt;/em&gt;, and no amount of code testing can catch it, because the code was never wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;pytest answers the first question. Tools like &lt;strong&gt;dbt test&lt;/strong&gt;, &lt;strong&gt;Great Expectations&lt;/strong&gt;, and &lt;strong&gt;Soda&lt;/strong&gt; answer the second. They're not competitors — they run at different times, against different inputs, and catch different bugs:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr4whrkiy43ccqsekhfci.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr4whrkiy43ccqsekhfci.png" alt="Code testing with pytest vs. data testing with dbt test, Great Expectations, or Soda" width="800" height="291"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A useful rule of thumb when you're not sure which bucket a check belongs in: &lt;strong&gt;if the same check would fail identically on a completely different day's data, it's a code test; if it depends on what actually arrived today, it's a data test.&lt;/strong&gt; "Does &lt;code&gt;calculate_discount&lt;/code&gt; cap at 50%?" is always true or always false regardless of the date — code test. "Did today's order count come in within 20% of the seven-day average?" only means something in the context of today's actual data — data test.&lt;/p&gt;

&lt;p&gt;This article is entirely about the first column. If you're looking for the second, dbt's testing docs, Great Expectations, and Soda are the right places to go next — and a mature data platform usually runs both, not one instead of the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: pytest fundamentals
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Installing and writing your first test
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv add &lt;span class="nt"&gt;--dev&lt;/span&gt; pytest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This adds pytest as a &lt;strong&gt;development dependency&lt;/strong&gt; — something your project needs to run its own test suite, but not something anyone installing your package needs. uv writes it into a &lt;code&gt;[dependency-groups]&lt;/code&gt; table in &lt;code&gt;pyproject.toml&lt;/code&gt; (the standardized format from PEP 735), kept separate from your project's real runtime dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dependency-groups]&lt;/span&gt;
&lt;span class="py"&gt;dev&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="py"&gt;"pytest&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;8.3&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pytest's core idea: a test is just a function whose name starts with &lt;code&gt;test_&lt;/code&gt;, living in a file whose name starts with &lt;code&gt;test_&lt;/code&gt; or ends with &lt;code&gt;_test.py&lt;/code&gt;. No boilerplate classes required (though you can use them).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# test_transformations.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_tax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_add_tax_applies_default_rate&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;add_tax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mf"&gt;110.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;uv run&lt;/code&gt;, which executes the command inside the project's managed virtual environment without you ever having to activate one by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run pytest
&lt;span class="c"&gt;# or, more verbosely:&lt;/span&gt;
uv run pytest &lt;span class="nt"&gt;-v&lt;/span&gt; test_transformations.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pytest uses the plain &lt;code&gt;assert&lt;/code&gt; keyword — no &lt;code&gt;self.assertEqual(...)&lt;/code&gt; ceremony. When an assertion fails, pytest rewrites it under the hood to show you exactly what was compared, which is a big part of why it's more pleasant than the built-in &lt;code&gt;unittest&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test discovery and project layout
&lt;/h3&gt;

&lt;p&gt;A typical DE repo looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my_pipeline/
├── src/
│   └── my_pipeline/
│       ├── __init__.py
│       ├── extract.py
│       ├── transform.py
│       └── load.py
├── tests/
│   ├── conftest.py
│   ├── unit/
│   │   ├── test_transform.py
│   │   └── test_extract.py
│   └── integration/
│       └── test_pipeline_end_to_end.py
├── pyproject.toml   # dependencies, dev group, and pytest config all live here
└── uv.lock          # exact resolved versions — commit this to the repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pytest configuration lives in &lt;code&gt;pyproject.toml&lt;/code&gt; too, under &lt;code&gt;[tool.pytest.ini_options]&lt;/code&gt; — no separate &lt;code&gt;pytest.ini&lt;/code&gt; needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[tool.pytest.ini_options]&lt;/span&gt;
&lt;span class="py"&gt;testpaths&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"tests"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;addopts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"-ra"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;testpaths&lt;/code&gt; tells pytest (and your editor's test runner) where to look without specifying a path on every invocation; &lt;code&gt;addopts&lt;/code&gt; bakes in flags you'd otherwise retype constantly — &lt;code&gt;-ra&lt;/code&gt; here prints a one-line summary of every non-passing test at the end of the run.&lt;/p&gt;

&lt;p&gt;Separating &lt;code&gt;unit/&lt;/code&gt; and &lt;code&gt;integration/&lt;/code&gt; isn't required, but it lets you run fast tests constantly and slow ones less often:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run pytest tests/unit          &lt;span class="c"&gt;# fast, run on every save&lt;/span&gt;
uv run pytest tests/integration   &lt;span class="c"&gt;# slower, run before pushing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Fixtures: pytest's dependency injection
&lt;/h3&gt;

&lt;p&gt;Fixtures are reusable pieces of setup, declared with &lt;code&gt;@pytest.fixture&lt;/code&gt; and requested by name as a test function argument. This is the single most important pytest feature for DE work, because pipelines need repeatable inputs — sample dataframes, temp directories, mock connections.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;raw_orders&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;25.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;40.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_drop_nulls_removes_incomplete_rows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_orders&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;my_pipeline.transform&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;drop_null_amounts&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drop_null_amounts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_orders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;isnull&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fixtures can depend on other fixtures, and pytest resolves the graph for you. They can also have a &lt;strong&gt;scope&lt;/strong&gt;, controlling how often they're recreated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# default: fresh per test
&lt;/span&gt;&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;module&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# once per test file
&lt;/span&gt;&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# once per whole test run
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scope="session"&lt;/code&gt; matters a lot for expensive setup — like spinning up a local Spark session (more on this below). The tradeoff is isolation versus speed: a fresh fixture per test can never leak state between tests, while a shared one is faster but puts the burden on you to make sure nothing one test does lingers to affect the next.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8gxo4u3y92qs4luhqwwm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8gxo4u3y92qs4luhqwwm.png" alt="Fixture scope: function vs. module vs. session" width="798" height="174"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;conftest.py&lt;/code&gt;: sharing fixtures across files
&lt;/h3&gt;

&lt;p&gt;Fixtures defined in &lt;code&gt;tests/conftest.py&lt;/code&gt; are automatically available to every test file in that directory and below, no import needed. This is where you put your "standard" sample datasets, temp-directory helpers, and mock clients so every test file can reuse them without duplication.&lt;/p&gt;

&lt;h3&gt;
  
  
  Parametrize: one test, many cases
&lt;/h3&gt;

&lt;p&gt;Data pipelines are full of edge cases — empty strings, nulls, negative numbers, weird encodings. &lt;code&gt;@pytest.mark.parametrize&lt;/code&gt; lets you run the same test logic against a table of inputs and expected outputs instead of copy-pasting near-identical test functions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw,expected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-01-15&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-01-15&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024/01/15&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-01-15&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;15-01-2024&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-01-15&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_normalize_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;my_pipeline.transform&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;normalize_date&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;normalize_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is arguably the highest-leverage pytest feature for DE testing: it forces you to explicitly enumerate the messy input variants you actually expect from real-world data, instead of testing only the happy path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Marks: skip, xfail, and custom categories
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.mark.skipif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;win32&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path handling differs on Windows&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_partition_path_format&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.mark.slow&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_full_backfill_pipeline&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register custom marks like &lt;code&gt;slow&lt;/code&gt; or &lt;code&gt;spark&lt;/code&gt; in the same &lt;code&gt;[tool.pytest.ini_options]&lt;/code&gt; table from the project layout above, so there's one config file for the whole project instead of two competing ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# pyproject.toml&lt;/span&gt;
&lt;span class="nn"&gt;[tool.pytest.ini_options]&lt;/span&gt;
&lt;span class="py"&gt;testpaths&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"tests"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;markers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"slow: long-running integration tests"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"spark: tests requiring a SparkSession"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run pytest &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"not slow"&lt;/span&gt;     &lt;span class="c"&gt;# skip slow tests during local dev&lt;/span&gt;
uv run pytest &lt;span class="nt"&gt;-m&lt;/span&gt; spark          &lt;span class="c"&gt;# run only Spark tests&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(A standalone &lt;code&gt;pytest.ini&lt;/code&gt; file still works if you'd rather keep pytest's config out of &lt;code&gt;pyproject.toml&lt;/code&gt; — but there's little reason to when everything else about the project, dependencies included, already lives there.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Mocking
&lt;/h3&gt;

&lt;p&gt;Not everything a data pipeline needs tested is a transformation. A pipeline also makes requests — HTTP calls to a source API, queries against a database, writes to S3 — and those need their own tests: does the code retry on a &lt;code&gt;503&lt;/code&gt;, does it handle a malformed response body, does it fail loudly on a bad auth token instead of silently returning nothing. This section covers the mechanics; &lt;a href="https://dev.to/nbaubek/failing-gracefully-robust-rest-api-requests-for-data-engineering-1cde"&gt;testing retry and backoff logic specifically&lt;/a&gt; gets a deeper, dedicated treatment in a companion article, including asserting on retry counts and simulated failure sequences with the &lt;code&gt;responses&lt;/code&gt; library.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;unittest.mock&lt;/code&gt; (built into the standard library) lets you replace a real dependency — an API call, a database connection, an S3 client — with a fake that returns canned data. This keeps unit tests fast and independent of network or infrastructure. The name is a historical artifact of when it shipped as part of the &lt;code&gt;unittest&lt;/code&gt; package — pytest itself has no dependency on &lt;code&gt;unittest&lt;/code&gt; for its core mechanics (discovery, fixtures, assertions are all pytest's own), and &lt;code&gt;unittest.mock&lt;/code&gt; works standalone in plain pytest-style function tests, no &lt;code&gt;TestCase&lt;/code&gt; required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;unittest.mock&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MagicMock&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_fetch_exchange_rate_handles_api_response&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;my_pipeline.extract&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fetch_exchange_rate&lt;/span&gt;

    &lt;span class="n"&gt;fake_response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MagicMock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;fake_response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.08&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;fake_response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my_pipeline.extract.requests.get&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fake_response&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_exchange_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EUR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mf"&gt;1.08&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;pytest-mock&lt;/code&gt;&lt;/strong&gt; wraps the same &lt;code&gt;unittest.mock&lt;/code&gt; machinery in a &lt;code&gt;mocker&lt;/code&gt; fixture — not a different mocking engine, just a thinner interface that fits pytest's fixture style and cleans up automatically after each test instead of needing a &lt;code&gt;with&lt;/code&gt; block or a &lt;code&gt;@patch&lt;/code&gt; decorator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_fetch_exchange_rate_handles_api_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mocker&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;my_pipeline.extract&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fetch_exchange_rate&lt;/span&gt;

    &lt;span class="n"&gt;fake_response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mocker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MagicMock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;fake_response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.08&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;fake_response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
    &lt;span class="n"&gt;mocker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my_pipeline.extract.requests.get&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fake_response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_exchange_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EUR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mf"&gt;1.08&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same assertions, same underlying patch mechanism — the difference is entirely ergonomic. &lt;code&gt;mocker.patch(...)&lt;/code&gt; calls are automatically undone at the end of the test via pytest's fixture teardown, so there's no &lt;code&gt;with&lt;/code&gt; block to wrap your arrange/act/assert around and no risk of a patch leaking into the next test if an exception fires mid-test.&lt;/p&gt;

&lt;p&gt;The key discipline, regardless of which style you use: &lt;strong&gt;mock at the boundary of your system&lt;/strong&gt;, not deep inside your own logic. If you find yourself mocking three layers deep to test a transformation function, that's usually a sign the function is doing too much and should be split into a pure part (testable without mocks) and an I/O part (tested with mocks or integration tests).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fou8964lqhtjkczy2y8c4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fou8964lqhtjkczy2y8c4.png" alt="Mock at the I/O boundary, keep the transform core pure" width="800" height="135"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: the AAA pattern
&lt;/h2&gt;

&lt;p&gt;Arrange–Act–Assert is a structural convention, not a pytest feature, but it keeps tests readable as your suite grows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_deduplicate_orders_keeps_latest_record&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# Arrange
&lt;/span&gt;    &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updated_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-01-05&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shipped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="c1"&gt;# Act
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deduplicate_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Assert
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;iloc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shipped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every test should have exactly one clear "Act" step and assertions that check &lt;em&gt;one behavior&lt;/em&gt;, even if that takes multiple &lt;code&gt;assert&lt;/code&gt; lines. If a test's Arrange section is enormous and its Assert section is checking five unrelated things, split it — you'll thank yourself when it fails and you need to know why in five seconds, not five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: unit vs. integration tests, for pipelines specifically
&lt;/h2&gt;

&lt;p&gt;The unit/integration distinction maps onto DE work a bit differently than it does onto typical application code:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unit tests&lt;/strong&gt; — test a single transformation function in isolation, with an in-memory dataframe you constructed by hand. No file I/O, no database, no cluster. These should run in milliseconds and make up the bulk of your suite.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_calculate_discount_caps_at_50_percent&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate_discount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loyalty_years&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Integration tests&lt;/strong&gt; — test that multiple pieces work together against something closer to real infrastructure: a real (but local/test) database, a real file read/write, a local Spark session, a mocked-but-realistic S3 bucket (via &lt;code&gt;moto&lt;/code&gt;). These are slower and fewer in number, but they catch the bugs unit tests structurally can't — a SQL query that's syntactically valid but returns the wrong join cardinality, a Parquet schema mismatch between writer and reader.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_pipeline_writes_expected_row_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw_orders&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;my_pipeline.pipeline&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;run_pipeline&lt;/span&gt;

    &lt;span class="n"&gt;input_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;output_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.parquet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;raw_orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;run_pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tmp_path&lt;/code&gt; is a built-in pytest fixture that gives you a fresh temporary directory per test, auto-cleaned afterward — extremely useful for testing anything that reads or writes files, without polluting your real filesystem or needing manual teardown.&lt;/p&gt;

&lt;p&gt;The shape of a healthy suite follows from how expensive each layer is to run and how much of your logic it can realistically cover:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgey9xbvrmn2rbe3di86g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgey9xbvrmn2rbe3di86g.png" alt="The shape of a healthy test suite: many unit tests, fewer integration tests, fewest end-to-end tests" width="552" height="1036"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A useful rule of thumb: if you can't explain in one sentence what real-world bug a test would catch, it's probably testing implementation detail rather than behavior — cut it or rewrite it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: testing pandas and Polars pipelines
&lt;/h2&gt;

&lt;h3&gt;
  
  
  pandas
&lt;/h3&gt;

&lt;p&gt;The standard library ships purpose-built comparison helpers — use them instead of &lt;code&gt;==&lt;/code&gt;, because dataframe equality has edge cases (dtype mismatches, index alignment, float precision) that &lt;code&gt;==&lt;/code&gt; handles inconsistently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pandas.testing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;assert_frame_equal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assert_series_equal&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_aggregate_revenue_by_region&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;input_df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;west&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;west&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;east&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;aggregate_revenue_by_region&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;east&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;west&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nf"&gt;assert_frame_equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;drop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;drop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;check_dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;check_dtype=False&lt;/code&gt; is worth knowing about: it's common for a groupby-aggregate to return &lt;code&gt;int64&lt;/code&gt; where your hand-built expected frame has &lt;code&gt;int64&lt;/code&gt; too, but small differences (e.g., &lt;code&gt;float64&lt;/code&gt; vs &lt;code&gt;float32&lt;/code&gt;) shouldn't fail a test that's really checking values, not storage format — unless dtype correctness is exactly what you're testing, in which case leave it on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Polars
&lt;/h3&gt;

&lt;p&gt;Polars ships an equivalent testing module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;polars&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;polars.testing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;assert_frame_equal&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_filter_active_customers&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;filter_active_customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nf"&gt;assert_frame_equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because Polars encourages a lazy/expression-based style, it's often cleanest to test the underlying expression logic directly (e.g., a function that returns a &lt;code&gt;pl.Expr&lt;/code&gt;) separately from the I/O that triggers &lt;code&gt;.collect()&lt;/code&gt;. That keeps the fast unit-testable core small and pure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Property-based testing (optional but powerful)
&lt;/h3&gt;

&lt;p&gt;For transformation logic with many edge cases, &lt;code&gt;hypothesis&lt;/code&gt; can generate hundreds of varied inputs automatically instead of you hand-writing each case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;hypothesis&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;hypothesis&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;strategies&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;

&lt;span class="nd"&gt;@given&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allow_nan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allow_infinity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_normalize_never_produces_values_outside_zero_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min_max_normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is worth reaching for once your &lt;code&gt;parametrize&lt;/code&gt; list starts feeling like you're guessing at edge cases rather than enumerating known ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 5: testing PySpark pipelines
&lt;/h2&gt;

&lt;p&gt;Spark's biggest testing challenge is the startup cost of a &lt;code&gt;SparkSession&lt;/code&gt;. Solve it with a session-scoped fixture so it's created once for the whole test run, not once per test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tests/conftest.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;master&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;local[2]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pytest-spark&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spark.sql.shuffle.partitions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# keep local runs fast
&lt;/span&gt;        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;
    &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha with sharing a session across every test: anything a test leaves behind — a temp view, a changed config, a cached table — is still there for the &lt;em&gt;next&lt;/em&gt; test, since they're the same session. If tests start passing or failing depending on execution order, that's usually the tell. A cheap guard is a small &lt;code&gt;autouse&lt;/code&gt; fixture that clears temp views between tests, or dropping to &lt;code&gt;scope="module"&lt;/code&gt; for the specific test file where isolation matters more than the extra setup cost.&lt;/p&gt;

&lt;p&gt;Every test that needs Spark just requests the &lt;code&gt;spark&lt;/code&gt; fixture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_flag_high_value_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDataFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;500.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;50.0&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;flag_high_value_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_high_value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two libraries make Spark dataframe assertions much less painful than manual &lt;code&gt;.collect()&lt;/code&gt; comparisons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;chispa&lt;/code&gt;&lt;/strong&gt; — gives you &lt;code&gt;assert_df_equality(result, expected, ignore_row_order=True)&lt;/code&gt; with readable diff output, similar in spirit to &lt;code&gt;pandas.testing.assert_frame_equal&lt;/code&gt;. It's the most established option and still the one with the nicest failure messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pytest-spark&lt;/code&gt;&lt;/strong&gt; — provides Spark-related fixtures and config out of the box if you don't want to hand-roll the session fixture above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're on Spark 4.0+, it's also worth knowing PySpark now ships a built-in &lt;code&gt;pyspark.testing.assertDataFrameEqual&lt;/code&gt;, so you can get row/column-order-insensitive comparisons without a third-party dependency. &lt;code&gt;chispa&lt;/code&gt; still has the edge on diff readability, but the native option is a reasonable default if you'd rather not add a dependency.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chispa&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;assert_df_equality&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_join_orders_with_customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDataFrame&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDataFrame&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Acme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join_orders_with_customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDataFrame&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Acme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nf"&gt;assert_df_equality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ignore_row_order&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ignore_column_order&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Spark specifically, mark these tests (&lt;code&gt;@pytest.mark.spark&lt;/code&gt;) and consider keeping them out of the default fast test run — a two-node local session still takes a few seconds to spin up, which adds up across a large suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 6: mocking external systems
&lt;/h2&gt;

&lt;p&gt;DE pipelines are full of edges that touch the outside world: S3, a warehouse, a REST API, a message queue. You don't want unit tests hitting real infrastructure — it's slow, flaky, and sometimes destructive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;moto&lt;/code&gt;&lt;/strong&gt; mocks AWS services at the boundary so your code calls the real &lt;code&gt;boto3&lt;/code&gt; API but nothing actually leaves your machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;moto&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;mock_aws&lt;/span&gt;

&lt;span class="nd"&gt;@mock_aws&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_upload_writes_expected_key&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us-east-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test-bucket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;my_pipeline.load&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;upload_parquet&lt;/span&gt;
    &lt;span class="nf"&gt;upload_parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test-bucket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders/2024-01-01.parquet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fake-bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;objects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_objects_v2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test-bucket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Contents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders/2024-01-01.parquet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For databases, prefer a real-but-disposable instance over mocking the driver whenever practical — e.g., SQLite in-memory for logic that's DB-agnostic, or a Dockerized Postgres/test schema for integration tests that need to check actual SQL behavior. Mocking a database connection to return canned &lt;code&gt;fetchall()&lt;/code&gt; results tests your Python glue code, but it can't catch a broken JOIN or a typo in a column name — only a real query engine can.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The clock is an external dependency too, and it's an easy one to forget.&lt;/strong&gt; Any pipeline logic that reasons about "today," "yesterday's partition," or "records from the last 24 hours" is implicitly depending on &lt;code&gt;datetime.now()&lt;/code&gt; — which means the test's outcome depends on when you happen to run it, unless you pin it down. &lt;code&gt;freezegun&lt;/code&gt; (or the newer &lt;code&gt;time-machine&lt;/code&gt;, which does the same job faster) fixes this by mocking the clock itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;freezegun&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;freeze_time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_partition_path_uses_yesterday&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;my_pipeline.extract&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;yesterday_partition_path&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;freeze_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-03-15&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;yesterday_partition_path&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders/dt=2024-03-14&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, a test like the one above either hardcodes today's date (and quietly breaks tomorrow) or skips testing the date logic entirely — both worse options than mocking the one dependency that's actually causing the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 7: data-quality-specific testing patterns
&lt;/h2&gt;

&lt;p&gt;A few habits particular to data engineering that don't show up in typical backend testing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test schemas, not just values.&lt;/strong&gt; A transformation can return the "right" numbers with the wrong column names or types, and a naive test that only checks a couple of cell values will miss it. Libraries like &lt;code&gt;pandera&lt;/code&gt; (for pandas/Polars) let you assert against a schema as part of your test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandera&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pandera&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DataFrameSchema&lt;/span&gt;

&lt;span class="n"&gt;order_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DataFrameSchema&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_transform_output_matches_schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_orders&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;transform_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_orders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;order_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# raises if schema doesn't match
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the pandera use case from the code-testing column of the framework earlier in this article: a schema checked against a fixture you built by hand, inside a pytest test, as part of CI. The same &lt;code&gt;order_schema&lt;/code&gt; object can just as easily validate a real dataframe pulled from production at pipeline runtime — at that point it's stopped being a code test and become a data test, even though the schema definition didn't change. Worth remembering which hat it's wearing in a given call site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the same transformation tests keep breaking from schema drift, that's a signal about which layer is doing the catching — not a sign you need to be more diligent about rewriting tests.&lt;/strong&gt; A transformation unit test runs against a fixture &lt;em&gt;you built and control&lt;/em&gt;. That fixture doesn't spontaneously change. So if a test keeps failing without you having touched it, on a schema that keeps moving underneath you, the test is usually quietly acting as an integration test — running against something closer to live data than a true fixture — which breaks the isolation this article has been arguing for since Part 1's "mock at the boundary" rule.&lt;/p&gt;

&lt;p&gt;The fix isn't sturdier tests. It's moving the check to where it belongs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separate "I made a mistake" failures from "reality changed" failures.&lt;/strong&gt; If updating one column definition in a schema fixes five failing tests at once, that's the tell that they were all downstream of the same unvalidated assumption, not five independent bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pytest is the wrong tool for "does today's data match what I expected."&lt;/strong&gt; That's a data-testing question — the same distinction this article draws early on, in "Where pytest fits — and where it doesn't" — and belongs to a schema/contract check, not a transformation test. Catching drift there means one clear failure ("upstream &lt;code&gt;region&lt;/code&gt; column is now nullable") instead of five cryptic transformation-test failures with no obvious common cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse the pandera schema as that gate.&lt;/strong&gt; Define the &lt;em&gt;input&lt;/em&gt; schema once, validate it at the pipeline's entry point before any transformation runs, and a schema change becomes one edit to that definition instead of a hunt through every test file that happened to hardcode an assumption about that column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If it's an upstream team's schema changing, not your own, the durable fix is a data contract, not a better pipeline.&lt;/strong&gt; A producing team declaring their schema — and a build failing loudly the moment they drift from it — turns "five tests broke on a Tuesday for no visible reason" into a versioned, reviewable change on their side instead of a mystery on yours.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Build small, deliberately ugly fixtures.&lt;/strong&gt; Real production data is a bad test fixture — it's huge, it changes, and it obscures which specific case you're testing. A handful of hand-built rows that include a null, a duplicate, an empty string, and a negative number will catch more bugs than a 10,000-row sample of "normal" data ever will.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate pure transformation logic from I/O.&lt;/strong&gt; A function like &lt;code&gt;def transform(df: pd.DataFrame) -&amp;gt; pd.DataFrame&lt;/code&gt; is trivially unit-testable. A function like &lt;code&gt;def run(): df = pd.read_csv(...); ...; df.to_sql(...)&lt;/code&gt; is not — you're forced into slow integration tests for everything. Structure pipelines as thin I/O wrappers around pure, well-tested transformation functions wherever you can.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test failure paths, not just success paths.&lt;/strong&gt; What happens when the input file is empty? When a required column is missing? When two upstream systems disagree on a foreign key? These are the tests that actually save you in production, and they're the ones people skip because writing the happy-path test already "felt done."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use golden/reference datasets for complex aggregations.&lt;/strong&gt; For a business-logic-heavy transformation (e.g., a multi-step revenue reconciliation), it's sometimes more maintainable to check a small input CSV against a small expected-output CSV committed to the repo, rather than constructing dataframes inline in every test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 8: wiring it into CI
&lt;/h2&gt;

&lt;p&gt;None of this pays off if it only runs on your laptop. A minimal GitHub Actions setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/test.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tests&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install uv&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;astral-sh/setup-uv@v7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.11"&lt;/span&gt;
          &lt;span class="na"&gt;enable-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uv sync --locked --all-extras --dev&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uv run pytest tests/unit -v&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uv run pytest tests/integration -v -m "not slow"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;uv sync --locked&lt;/code&gt; installs exactly what's pinned in &lt;code&gt;uv.lock&lt;/code&gt; and fails the build if the lockfile is out of date with &lt;code&gt;pyproject.toml&lt;/code&gt; — the CI equivalent of "works on my machine" actually meaning something. &lt;code&gt;astral-sh/setup-uv&lt;/code&gt; is the official action for installing uv itself; it can also pin the Python version the same way &lt;code&gt;actions/setup-python&lt;/code&gt; used to, and &lt;code&gt;enable-cache: true&lt;/code&gt; caches uv's package store between runs so later builds skip re-downloading dependencies that haven't changed.&lt;/p&gt;

&lt;p&gt;Keeping unit and integration runs as separate steps means a failing integration test doesn't hide a failing unit test in the same log, and you get faster feedback from the unit step first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together: a minimal but real test suite
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tests/
├── conftest.py              # shared fixtures: sample dfs, spark session, tmp helpers
├── unit/
│   ├── test_transform.py    # pure functions, pandas/polars asserts, parametrize-heavy
│   └── test_validation.py   # schema checks, edge cases
└── integration/
    ├── test_spark_jobs.py   # marked @pytest.mark.spark, uses chispa
    ├── test_s3_io.py        # uses moto
    └── test_end_to_end.py   # runs the full pipeline against tmp_path input/output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The core habits worth taking away
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Know which kind of testing you're doing.&lt;/strong&gt; pytest catches bugs in your code, using data you control. It cannot catch a data quality problem that only exists in today's actual data — that's a separate job for dbt test, Great Expectations, or Soda. If a transformation test keeps breaking from schema drift with nothing changed on your end, that's a sign the check belongs one layer earlier, not a sign to rewrite the test again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the transformation logic, not the framework.&lt;/strong&gt; You don't need to test that pandas' &lt;code&gt;groupby&lt;/code&gt; works — you need to test that &lt;em&gt;your&lt;/em&gt; aggregation logic does the right thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep unit tests fast and dependency-free&lt;/strong&gt;; push anything touching a real file, database, or cluster into a clearly separated, clearly marked integration suite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build small, deliberately messy fixtures&lt;/strong&gt; instead of testing against production-sized samples.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the domain-specific assertion helpers&lt;/strong&gt; (&lt;code&gt;assert_frame_equal&lt;/code&gt;, &lt;code&gt;assert_df_equality&lt;/code&gt;, &lt;code&gt;pandera&lt;/code&gt; schemas) instead of hand-rolled comparisons — they exist because naive equality checks on dataframes are full of footguns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mock at the boundary&lt;/strong&gt;, not inside your own logic — and remember the clock counts as a boundary too. If mocking feels awkward, it's often telling you to refactor, not to mock harder.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this requires a big investment up front. Start by putting AAA-structured unit tests around your messiest transformation function, get comfortable with fixtures and &lt;code&gt;parametrize&lt;/code&gt;, and expand outward from there. The payoff compounds fast: every bug a test catches before a stakeholder sees a bad number is time you don't spend doing forensic debugging on a Friday afternoon.&lt;/p&gt;

</description>
      <category>python</category>
      <category>dataengineering</category>
      <category>testing</category>
      <category>pytest</category>
    </item>
    <item>
      <title>Security &amp; Governance for Data Networking</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Tue, 01 Sep 2026 16:34:34 +0000</pubDate>
      <link>https://dev.to/nbaubek/security-governance-for-data-networking-321e</link>
      <guid>https://dev.to/nbaubek/security-governance-for-data-networking-321e</guid>
      <description>&lt;h1&gt;
  
  
  Security &amp;amp; Governance for Data Networking
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Part 6 of 6 in the Networking &amp;amp; VPC Foundations for Data Engineering series&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We've built the pieces (&lt;a href="https://dev.to/nbaubek/why-your-pipeline-cant-talk-to-anything-vpc-fundamentals-for-data-engineers-5987"&gt;Part 1&lt;/a&gt;), compared how AWS and GCP implement them (&lt;a href="https://dev.to/nbaubek/vpcs-compared-aws-vs-gcp-4o92"&gt;Part 2&lt;/a&gt;), assembled a real 3-tier architecture (&lt;a href="https://dev.to/nbaubek/the-3-tier-vpc-building-a-real-architecture-40kh"&gt;Part 3&lt;/a&gt;), closed the gap to managed data services (&lt;a href="https://dev.to/nbaubek/private-connectivity-to-data-services-closing-the-last-gap-2lkb"&gt;Part 4&lt;/a&gt;), and connected multiple VPCs together (&lt;a href="https://dev.to/nbaubek/multi-vpc-and-multi-project-architectures-2h5"&gt;Part 5&lt;/a&gt;). This last part is about a different kind of question: &lt;strong&gt;how do you know whether what you built is actually secure&lt;/strong&gt; — not on day one, but six months later, after a dozen people have touched it?&lt;/p&gt;

&lt;p&gt;This is where most real-world network security failures actually happen. Not from someone deliberately building an insecure architecture, but from a &lt;em&gt;correctly&lt;/em&gt; designed one slowly drifting as changes accumulate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Least Privilege, Applied to Networks
&lt;/h2&gt;

&lt;p&gt;Every principle in this section is really one idea wearing different outfits: &lt;strong&gt;a resource should only be reachable by exactly what needs to reach it, on exactly the port it needs, and nothing else.&lt;/strong&gt; A few concrete applications of that, specific to data platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your data warehouse's security group / firewall rule should name the &lt;em&gt;specific&lt;/em&gt; app-tier security group or service account that's allowed in — never a broad CIDR range, and never "allow from anywhere."&lt;/li&gt;
&lt;li&gt;Compute clusters (EMR, Dataproc, self-managed Spark) should run in private subnets with no external IP, by default, every time — not as an exception you remember to configure.&lt;/li&gt;
&lt;li&gt;Admin access should go through SSM Session Manager or IAP (&lt;a href="https://dev.to/nbaubek/the-3-tier-vpc-building-a-real-architecture-40kh"&gt;Part 3&lt;/a&gt;), not a standing bastion host with a permanently open port.&lt;/li&gt;
&lt;li&gt;If a private-subnet resource doesn't need outbound internet at all — many data-tier resources don't — don't route it through NAT "just in case." No route out is a stronger guarantee than a route out plus a rule that's supposed to block it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. The hard part isn't knowing these rules — it's that they quietly stop being true over time unless something is actively checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Configurations Drift
&lt;/h2&gt;

&lt;p&gt;A few patterns account for the overwhelming majority of real incidents:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A rule added for a one-time fix that never gets removed.&lt;/strong&gt; Someone needed temporary access to debug something, opened up a security group or firewall rule to make it happen, and it's still there a year later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rule that's technically correct but way broader than intended.&lt;/strong&gt; &lt;code&gt;0.0.0.0/0&lt;/code&gt; on a port that only needed one specific IP range, because it was faster to write during an incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shadowed rules.&lt;/strong&gt; In rule systems that evaluate in order — GCP firewall rule priorities, AWS NACL rule numbers — a more permissive rule earlier in the sequence silently overrides a more restrictive one added later: the restrictive rule &lt;em&gt;looks&lt;/em&gt; like it's protecting you, but it's never actually evaluated. (AWS security groups don't have this problem — every rule is evaluated, with no ordering — which cuts both ways: you also can't neutralize an overly broad SG rule by adding a stricter one.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route tables changed for an unrelated reason&lt;/strong&gt; — one route table edit silently reclassifies a subnet's internet reachability, with zero code changes on the data team's side.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix for all four isn't vigilance — humans are bad at noticing things that used to be right and quietly became wrong. The fix is tooling that checks continuously, plus a review cadence that doesn't depend on someone remembering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools That Actually Catch This
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;AWS:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VPC Flow Logs&lt;/strong&gt; — captures accepted and rejected traffic at the ENI, subnet, or VPC level. The raw material for almost every other investigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPC Reachability Analyzer&lt;/strong&gt; — answers "can resource A actually reach resource B?" by tracing the full path (route tables, security groups, NACLs) without sending any real traffic. Genuinely useful for confirming a fix worked before you find out the hard way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network Access Analyzer&lt;/strong&gt; — scans for unintended network access at scale, e.g. "which resources are reachable from the internet that shouldn't be."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;GCP:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VPC Flow Logs&lt;/strong&gt; — same core idea as AWS's version, captured per subnet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firewall Insights&lt;/strong&gt; (part of Network Intelligence Center) — automatically flags &lt;strong&gt;shadowed rules&lt;/strong&gt;, overly permissive rules, and rules that have never matched any real traffic. This is the direct answer to failure mode #3 above, and it's worth running as a matter of routine, not just when something's already gone wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network Analyzer&lt;/strong&gt; — continuously monitors your VPC configuration and proactively surfaces misconfigurations and suboptimal routing, correlating failures with recent config changes to point at likely root causes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connectivity Tests&lt;/strong&gt; — GCP's equivalent of Reachability Analyzer: simulate a packet path between two points and get a definitive allowed/blocked answer, with the reason why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these tools are a substitute for the design principles above — they're what catches the moment those principles quietly stop being true.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Working Review Checklist
&lt;/h2&gt;

&lt;p&gt;When you're checking a data platform's network posture — your own, or reviewing someone else's — this is roughly the order that actually finds problems, largely mirroring the debugging order from &lt;a href="https://dev.to/nbaubek/why-your-pipeline-cant-talk-to-anything-vpc-fundamentals-for-data-engineers-5987"&gt;Part 1&lt;/a&gt;, now run proactively instead of reactively:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnubijtmfeblpzezot40j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnubijtmfeblpzezot40j.png" alt="A six-step network security review checklist" width="800" height="2754"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Run this quarterly at minimum, and definitely after any significant architecture change — a new VPC connection (&lt;a href="https://dev.to/nbaubek/multi-vpc-and-multi-project-architectures-2h5"&gt;Part 5&lt;/a&gt;), a new managed service integration (&lt;a href="https://dev.to/nbaubek/private-connectivity-to-data-services-closing-the-last-gap-2lkb"&gt;Part 4&lt;/a&gt;), or a new team onboarding onto shared infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the Loop
&lt;/h2&gt;

&lt;p&gt;Back in &lt;a href="https://dev.to/nbaubek/why-your-pipeline-cant-talk-to-anything-vpc-fundamentals-for-data-engineers-5987"&gt;Part 1&lt;/a&gt;, the whole series opened with a diagram nobody had actually explained, and a job that couldn't reach an external API for a reason nobody could immediately see. Six parts later, that diagram isn't mysterious anymore: it's CIDR blocks and subnets (&lt;a href="https://dev.to/nbaubek/why-your-pipeline-cant-talk-to-anything-vpc-fundamentals-for-data-engineers-5987"&gt;Part 1&lt;/a&gt;), implemented slightly differently depending on which cloud you're on (&lt;a href="https://dev.to/nbaubek/vpcs-compared-aws-vs-gcp-4o92"&gt;Part 2&lt;/a&gt;), arranged into tiers of decreasing trust (&lt;a href="https://dev.to/nbaubek/the-3-tier-vpc-building-a-real-architecture-40kh"&gt;Part 3&lt;/a&gt;), with a private path to your data services that skips the public internet entirely (&lt;a href="https://dev.to/nbaubek/private-connectivity-to-data-services-closing-the-last-gap-2lkb"&gt;Part 4&lt;/a&gt;), connected to whatever other networks your organization actually needs (&lt;a href="https://dev.to/nbaubek/multi-vpc-and-multi-project-architectures-2h5"&gt;Part 5&lt;/a&gt;) — and now, checked continuously instead of trusted blindly (Part 6).&lt;/p&gt;

&lt;p&gt;That's the whole foundation. Everything past this point — Kubernetes networking, service meshes, more exotic hybrid-cloud topologies — is built on exactly these same primitives, just with more layers on top. If this series did its job, none of it should feel like starting from zero.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>vpc</category>
      <category>governance</category>
      <category>security</category>
    </item>
    <item>
      <title>Multi-VPC and Multi-Project Architectures</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Tue, 01 Sep 2026 16:31:28 +0000</pubDate>
      <link>https://dev.to/nbaubek/multi-vpc-and-multi-project-architectures-2h5</link>
      <guid>https://dev.to/nbaubek/multi-vpc-and-multi-project-architectures-2h5</guid>
      <description>&lt;h1&gt;
  
  
  Multi-VPC and Multi-Project Architectures
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Part 5 of 6 in the Networking &amp;amp; VPC Foundations for Data Engineering series&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every diagram in this series so far has shown one VPC. Real organizations rarely stay that small — you end up with separate environments (dev, staging, prod), separate teams with their own AWS accounts or GCP projects, sometimes a data platform that's deliberately isolated from everything else for compliance reasons. All of those networks eventually need to talk to each other, and how you connect them matters a lot more than it looks at first glance.&lt;/p&gt;

&lt;h2&gt;
  
  
  AWS: VPC Peering vs. Transit Gateway
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;VPC Peering&lt;/strong&gt; connects two VPCs directly, using AWS's private backbone — no hourly charge, low latency, straightforward to set up. You request the connection, the other side accepts it, you update route tables on both ends, done.&lt;/p&gt;

&lt;p&gt;The catch is the same one we flagged back in &lt;a href="https://dev.to/nbaubek/vpcs-compared-aws-vs-gcp-4o92"&gt;Part 2&lt;/a&gt;: &lt;strong&gt;peering isn't transitive.&lt;/strong&gt; If VPC A peers with B, and B peers with C, A still cannot reach C — B doesn't relay traffic on A's behalf. Connect 4 VPCs with peering and you need 6 individual connections; get to a few dozen VPCs and you're maintaining a genuinely unmanageable mesh of point-to-point links and route table entries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgo8bhy36qn18pb47arfu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgo8bhy36qn18pb47arfu.png" alt="Four VPCs fully meshed with peering: six point-to-point connections" width="546" height="1182"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's what &lt;strong&gt;4 VPCs fully connected via peering&lt;/strong&gt; looks like: 6 connections, and it only gets worse from there. This is exactly the problem &lt;strong&gt;Transit Gateway&lt;/strong&gt; solves — a central hub that every VPC attaches to &lt;em&gt;once&lt;/em&gt;. Attach 100 VPCs, and you have 100 attachments instead of thousands of pairwise connections, and — critically — routing between them &lt;strong&gt;is&lt;/strong&gt; transitive through the hub.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc48ua76dhm7fbtkbhduk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc48ua76dhm7fbtkbhduk.png" alt="Transit Gateway: every VPC attaches once to a central hub" width="800" height="331"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Transit Gateway isn't free, though — it charges an hourly rate per attachment plus a per-gigabyte processing fee, and it adds a small routing hop of latency compared to peering's direct path. For a data platform specifically: if you're centralizing ingestion from many isolated source VPCs, or connecting a handful of stable environments (dev/staging/prod) that rarely change, peering is often genuinely fine. Once you're managing more than a handful of VPCs, need hybrid on-premises connectivity in the same place, or want centralized routing policy, Transit Gateway earns its cost.&lt;/p&gt;

&lt;p&gt;One more peering limitation worth knowing before you design around it: &lt;strong&gt;CIDR blocks can't overlap.&lt;/strong&gt; Two VPCs that both happen to use &lt;code&gt;10.0.0.0/16&lt;/code&gt; simply cannot be peered — a real problem after a merger or when two teams picked the same "obvious" default range independently. Transit Gateway can at least contain the damage — overlapping attachments can be walled off into separate route table domains — but the two ranges still can't talk to each other, so it's containment, not a fix. The real solution is avoiding overlapping ranges from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  GCP: Shared VPC vs. Network Connectivity Center
&lt;/h2&gt;

&lt;p&gt;GCP splits this same problem into two different tools, aimed at two different situations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared VPC&lt;/strong&gt; isn't primarily about connecting separate networks — it's about &lt;strong&gt;one team centrally managing a network that other teams' projects use directly.&lt;/strong&gt; A "host project" owns the VPC, subnets, and firewall rules; other "service projects" get resources (VMs, GKE clusters, Dataproc jobs) attached directly into those shared subnets, as if they lived in the host project's network all along. There's no peering, no route exchange to configure — because there's really only one network underneath.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkmhl9852d8602vvb4nvo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkmhl9852d8602vvb4nvo.png" alt="GCP Shared VPC: a host project sharing subnets with service projects" width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a genuinely common pattern for data platforms: a central platform team owns the Shared VPC (and its Cloud NAT, its Private Google Access settings, its firewall rules), while individual data and ML teams get their own projects — separate billing, separate IAM boundaries — without needing to reinvent network setup each time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network Connectivity Center (NCC)&lt;/strong&gt;, by contrast, is GCP's actual hub-and-spoke connectivity tool — the closer equivalent to Transit Gateway. It connects genuinely &lt;em&gt;separate&lt;/em&gt; VPCs (plus on-premises VPN or Interconnect links) through a central hub, with automatic route exchange between spokes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwblrng5ls4bl3ip78bwf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwblrng5ls4bl3ip78bwf.png" alt="Network Connectivity Center hub connecting separate VPCs and on-prem" width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rule of thumb GCP itself gives: use plain VPC peering for a simple two-network connection; reach for NCC once you're dealing with more than a handful of networks, need transitive routing between them, or want to fold in on-premises connectivity centrally. Don't reach for Shared VPC as a substitute hub-and-spoke tool — it was built for centralized ownership of &lt;em&gt;one&lt;/em&gt; network across projects, not for connecting many independent networks together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;AWS&lt;/th&gt;
&lt;th&gt;GCP&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct, simple link&lt;/td&gt;
&lt;td&gt;VPC Peering&lt;/td&gt;
&lt;td&gt;VPC Peering&lt;/td&gt;
&lt;td&gt;A few stable, long-lived VPC pairs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Centralized hub, many networks&lt;/td&gt;
&lt;td&gt;Transit Gateway&lt;/td&gt;
&lt;td&gt;Network Connectivity Center&lt;/td&gt;
&lt;td&gt;Dozens of VPCs, hybrid on-prem, transitive routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One network, many teams&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Shared VPC&lt;/td&gt;
&lt;td&gt;Centralized platform team, decentralized project ownership&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Bringing a Data Platform Example Together
&lt;/h2&gt;

&lt;p&gt;A fairly typical shape for a mid-sized data platform: a central &lt;strong&gt;ingestion VPC&lt;/strong&gt; (or Shared VPC on GCP) that owns the NAT/Cloud NAT setup and receives data from various sources, connected via Transit Gateway or NCC to separate &lt;strong&gt;processing VPCs&lt;/strong&gt; per team or environment, each with their own 3-tier layout from &lt;a href="https://dev.to/nbaubek/the-3-tier-vpc-building-a-real-architecture-40kh"&gt;Part 3&lt;/a&gt;. Isolation where it's needed, shared infrastructure where it isn't — and no team accidentally getting network access they were never supposed to have, because the hub enforces exactly which spokes can reach which.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;We've now covered the full shape of a multi-VPC data platform — but plenty of the security and cost decisions along the way have been mentioned only in passing: least-privilege network design, common misconfigurations that pass an initial review but fail an audit, and how to actually check a platform's network posture before something goes wrong. &lt;a href="https://dev.to/nbaubek/security-governance-for-data-networking-321e"&gt;Part 6&lt;/a&gt;, the final piece of this series, pulls all of that together.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>cloud</category>
      <category>networking</category>
      <category>aws</category>
    </item>
    <item>
      <title>Private Connectivity to Data Services: Closing the Last Gap</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Tue, 01 Sep 2026 16:27:13 +0000</pubDate>
      <link>https://dev.to/nbaubek/private-connectivity-to-data-services-closing-the-last-gap-2lkb</link>
      <guid>https://dev.to/nbaubek/private-connectivity-to-data-services-closing-the-last-gap-2lkb</guid>
      <description>&lt;h1&gt;
  
  
  Private Connectivity to Data Services: Closing the Last Gap
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Part 4 of 6 in the Networking &amp;amp; VPC Foundations for Data Engineering series&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Quick gut check before we start: in &lt;a href="https://dev.to/nbaubek/the-3-tier-vpc-building-a-real-architecture-40kh"&gt;Part 3&lt;/a&gt;'s 3-tier architecture, when your Spark cluster in the private subnet needs to read from S3 or write to BigQuery, where does that traffic actually go?&lt;/p&gt;

&lt;p&gt;If your answer is "out through NAT, same as any other internet call" — you're right, and that's the gap this article closes. Even though S3 and BigQuery are the cloud provider's &lt;em&gt;own&lt;/em&gt; services, sitting on the &lt;em&gt;same&lt;/em&gt; network as your VPC, traffic to them by default still leaves through your NAT Gateway or Cloud NAT and travels over the public internet as far as routing is concerned. It works, but it's not actually private, and it's not free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is Worth Fixing
&lt;/h2&gt;

&lt;p&gt;Three concrete reasons this matters for a data platform specifically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cost.&lt;/strong&gt; NAT charges per gigabyte processed. Data pipelines reading and writing large volumes to S3 or GCS through NAT can rack up meaningful NAT data-processing charges for traffic that never needed to leave the provider's network in the first place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security posture.&lt;/strong&gt; "Traffic to our data services goes over the public internet" is exactly the kind of finding that fails a security review, even if the traffic is encrypted in transit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's avoidable.&lt;/strong&gt; Both clouds offer a way to route this traffic entirely within their private backbone — no NAT, no internet exposure, often no cost at all for the most common services.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  AWS: Gateway Endpoints vs. Interface Endpoints (PrivateLink)
&lt;/h2&gt;

&lt;p&gt;AWS actually gives you two different mechanisms here, and which one you use depends on the service:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gateway Endpoints&lt;/strong&gt; — for &lt;strong&gt;S3 and DynamoDB only&lt;/strong&gt;. These work by adding a route to your route table pointing at the service, with no new network interface, no hourly charge, and no data-processing charge at all. If your data platform's main need is private S3 access, this is the one you want, and there's no real reason not to enable it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interface Endpoints&lt;/strong&gt; — powered by &lt;strong&gt;AWS PrivateLink&lt;/strong&gt; — cover the other 100+ AWS services (and third-party SaaS via the AWS Marketplace) that Gateway Endpoints don't reach. These create an actual network interface (ENI) with a private IP inside your subnet, governed by a security group like any other resource. They carry an hourly charge per AZ plus a data-processing fee — not free, but still typically far cheaper than the NAT charges they replace.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F88zfyjflf5ktnfuu3xka.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F88zfyjflf5ktnfuu3xka.png" alt="AWS gateway endpoints (free, route-based) vs interface endpoints (ENI with private IP)" width="800" height="785"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One newer option worth knowing about: AWS introduced &lt;strong&gt;Resource Endpoints&lt;/strong&gt;, which let a VPC privately reach a specific resource — like an RDS instance — in another VPC or on-premises, without needing a Network Load Balancer in front of it. If you're designing a fresh multi-VPC data platform today, it's worth a look, though Gateway and Interface Endpoints still cover the overwhelming majority of day-to-day data engineering needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  GCP: Private Google Access vs. Private Service Connect
&lt;/h2&gt;

&lt;p&gt;GCP splits this along a similar line, though the mechanics differ:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Private Google Access&lt;/strong&gt; — a simple, subnet-level setting. Turn it on, and instances with no external IP in that subnet can reach Google APIs (Cloud Storage, BigQuery, Pub/Sub, etc.) using Google's own shared IP ranges for those APIs. No new resource to manage, no direct cost. This is the GCP rough-equivalent of AWS's Gateway Endpoints in spirit — flip a switch, get private access to the common case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Private Service Connect (PSC)&lt;/strong&gt; — the more flexible, more deliberate option. Instead of relying on Google's shared public-API IP ranges, PSC creates an actual &lt;strong&gt;endpoint with your own internal IP address&lt;/strong&gt; inside your VPC, which then privately forwards traffic to the target service. PSC covers Google APIs (with more routing/DNS control than Private Google Access gives you) &lt;em&gt;and&lt;/em&gt; — this is the part that maps most directly to AWS PrivateLink — lets you privately reach services published by other VPCs or third parties, including your own internal services shared across teams.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnkr6ymevmo0b1nytlzon.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnkr6ymevmo0b1nytlzon.png" alt="GCP Private Google Access vs Private Service Connect endpoints" width="800" height="705"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rule of thumb: start with Private Google Access for straightforward private access to Google's own APIs. Reach for PSC when you need your own IP addressing, tighter DNS/routing control, or you're connecting to something that isn't a first-party Google API at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side by Side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Gateway Endpoint&lt;/td&gt;
&lt;td&gt;S3, DynamoDB only&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Route table entry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Interface Endpoint (PrivateLink)&lt;/td&gt;
&lt;td&gt;100+ AWS services, SaaS&lt;/td&gt;
&lt;td&gt;Hourly + per-GB&lt;/td&gt;
&lt;td&gt;ENI with private IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GCP Private Google Access&lt;/td&gt;
&lt;td&gt;Google APIs&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Subnet-level setting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GCP Private Service Connect&lt;/td&gt;
&lt;td&gt;Google APIs + third-party/internal services&lt;/td&gt;
&lt;td&gt;Varies by target&lt;/td&gt;
&lt;td&gt;Endpoint with your own internal IP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The DNS Piece Nobody Mentions Until It Bites You
&lt;/h2&gt;

&lt;p&gt;Here's a detail that trips people up on both clouds: creating the endpoint isn't the whole job. Your application is almost certainly still resolving &lt;code&gt;s3.amazonaws.com&lt;/code&gt; or &lt;code&gt;storage.googleapis.com&lt;/code&gt; to the service's &lt;strong&gt;public&lt;/strong&gt; IP by default — which means it'll try to reach that public IP, and depending on your routing, that might still mean going out through NAT, or simply failing if there's no route out at all.&lt;/p&gt;

&lt;p&gt;To actually route traffic &lt;em&gt;through&lt;/em&gt; the endpoint, DNS resolution for that service needs to return the endpoint's private address instead of the public one. AWS Interface Endpoints handle this via &lt;strong&gt;private DNS&lt;/strong&gt;, which — when enabled on the endpoint — overrides the service's standard DNS name within your VPC to resolve privately. GCP's PSC endpoints similarly need appropriate private DNS zone configuration pointing at the endpoint's internal IP.&lt;/p&gt;

&lt;p&gt;If you've done everything right — endpoint created, security group/firewall configured — and traffic still isn't using it, DNS resolution is the first thing to check. It's the same lesson from &lt;a href="https://dev.to/nbaubek/why-your-pipeline-cant-talk-to-anything-vpc-fundamentals-for-data-engineers-5987"&gt;Part 1&lt;/a&gt;'s DNS section, just showing up again in a new context.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;We've been assuming a single VPC this whole series. Real organizations rarely stop at one — different environments, different teams, sometimes different AWS accounts or GCP projects entirely, all needing to talk to each other. &lt;a href="https://dev.to/nbaubek/multi-vpc-and-multi-project-architectures-2h5"&gt;Part 5&lt;/a&gt; covers how: VPC Peering, Transit Gateway, and Shared VPC.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>vpc</category>
      <category>cloud</category>
      <category>aws</category>
    </item>
    <item>
      <title>The 3-Tier VPC: Building a Real Architecture</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Tue, 01 Sep 2026 16:20:00 +0000</pubDate>
      <link>https://dev.to/nbaubek/the-3-tier-vpc-building-a-real-architecture-40kh</link>
      <guid>https://dev.to/nbaubek/the-3-tier-vpc-building-a-real-architecture-40kh</guid>
      <description>&lt;h1&gt;
  
  
  The 3-Tier VPC: Building a Real Architecture
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Part 3 of 6 in the Networking &amp;amp; VPC Foundations for Data Engineering series&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We've spent two articles on pieces: CIDR blocks, subnets, routing, DNS, NAT in &lt;a href="https://dev.to/nbaubek/why-your-pipeline-cant-talk-to-anything-vpc-fundamentals-for-data-engineers-5987"&gt;Part 1&lt;/a&gt;; how AWS and GCP each implement those pieces in &lt;a href="https://dev.to/nbaubek/vpcs-compared-aws-vs-gcp-4o92"&gt;Part 2&lt;/a&gt;. Time to assemble them into something you'd actually deploy.&lt;/p&gt;

&lt;p&gt;The pattern we're building is the one you'll see, in some form, behind nearly every production data platform: the &lt;strong&gt;3-tier VPC&lt;/strong&gt;. Three layers, each more locked-down than the last.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shape of It
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2qav46inkyeruv7gn5e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2qav46inkyeruv7gn5e.png" alt="The 3-tier VPC: public, private app, and isolated data tiers" width="800" height="1132"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice the arrows: the internet can reach Tier 1, and only Tier 1. Tier 2 is reachable from Tier 1, but not directly from the internet. Tier 3 is reachable only from Tier 2 — nothing else touches it, ever. Each tier is a stricter version of the last, and that strictness is the entire point: a compromised load balancer doesn't hand an attacker your database, because there's no direct path between them.&lt;/p&gt;

&lt;p&gt;Let's build this concretely, tier by tier, on both AWS and GCP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier 1: The Public Subnet
&lt;/h2&gt;

&lt;p&gt;This is the only tier with a route to an Internet Gateway. What lives here is deliberately narrow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Load balancers&lt;/strong&gt; (ALB/NLB on AWS, external Load Balancer on GCP) — the front door for real traffic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NAT Gateway / Cloud NAT&lt;/strong&gt; — so Tier 2 can reach the internet outbound without living here itself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bastion host&lt;/strong&gt; (or its modern replacement — more on this below) — the narrow door for human administrative access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else does not belong in Tier 1. Not your database, not your compute, not your internal services — even though it would technically still be protected by security groups/firewall rules, minimizing what's reachable at the network layer is a much stronger guarantee than trusting every instance-level rule to be configured correctly forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier 2: The Private Subnet — Where Your Compute Actually Lives
&lt;/h2&gt;

&lt;p&gt;This is home for your Spark cluster, Airflow workers, EMR/Dataproc nodes, application servers — the things that need to &lt;em&gt;do work&lt;/em&gt; and occasionally reach the internet (installing a package, calling an external API), but should never be reachable &lt;em&gt;from&lt;/em&gt; the internet.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS&lt;/strong&gt;: route table sends &lt;code&gt;0.0.0.0/0&lt;/code&gt; to the NAT Gateway sitting in Tier 1&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GCP&lt;/strong&gt;: instances here simply have no external IP; Cloud NAT (attached via a Cloud Router) handles outbound access&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tier 3: The Data Subnet — Fully Isolated
&lt;/h2&gt;

&lt;p&gt;Your RDS instance, your internal warehouse endpoint, anything holding the data itself. This tier often doesn't even get outbound internet access — there's rarely a legitimate reason for a database to initiate a connection to the public internet.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS&lt;/strong&gt;: often no route to &lt;code&gt;0.0.0.0/0&lt;/code&gt; at all — not even via NAT&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GCP&lt;/strong&gt;: same idea — no external IP, and you can skip attaching this subnet's range to Cloud NAT entirely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Access into Tier 3 is granted narrowly: only Tier 2's security group (AWS) or Tier 2's service account (GCP) is allowed in, on exactly the port the database needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Concrete AWS Build
&lt;/h2&gt;

&lt;p&gt;Here's what this looks like as an actual subnet layout, spread across two AZs for availability:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subnet&lt;/th&gt;
&lt;th&gt;CIDR&lt;/th&gt;
&lt;th&gt;AZ&lt;/th&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Public Subnet 1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.1.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AZ 1&lt;/td&gt;
&lt;td&gt;Public&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Private Subnet 1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.2.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AZ 1&lt;/td&gt;
&lt;td&gt;App&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public Subnet 2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.3.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AZ 2&lt;/td&gt;
&lt;td&gt;Public&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Private Subnet 2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.4.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AZ 2&lt;/td&gt;
&lt;td&gt;App&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(A data tier would typically add &lt;code&gt;10.0.10.0/24&lt;/code&gt; and &lt;code&gt;10.0.11.0/24&lt;/code&gt;, one per AZ, following the same pattern.)&lt;/p&gt;

&lt;p&gt;And the security group chain that governs traffic between tiers:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2g7yp3r5jv2k5w7y74rw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2g7yp3r5jv2k5w7y74rw.png" alt="AWS security group chaining: internet → ALB-SG → EC2-SG → RDS-SG" width="800" height="78"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each security group only trusts the one before it — never the internet directly, past the first hop. This is the AWS &lt;strong&gt;security group chaining&lt;/strong&gt; pattern, and it's worth internalizing: it's the mechanism, not just this specific example, that you'll reapply constantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Same Build on GCP
&lt;/h2&gt;

&lt;p&gt;GCP gets you the same three-tier isolation, but remember from &lt;a href="https://dev.to/nbaubek/vpcs-compared-aws-vs-gcp-4o92"&gt;Part 2&lt;/a&gt;: there's no subnet-level "public" flag here — it comes down to external IPs and firewall rules.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjg51768j4jumxu1q07ax.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjg51768j4jumxu1q07ax.png" alt="GCP equivalent: firewall rules chained via network tags" width="800" height="101"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instead of chaining security group IDs, you chain &lt;strong&gt;network tags&lt;/strong&gt; (or service accounts) through firewall rule sources. The load balancer's backend instances carry an &lt;code&gt;lb&lt;/code&gt; tag; the firewall rule for your app tier says "allow traffic tagged &lt;code&gt;lb&lt;/code&gt;"; the database tier's rule says "allow traffic tagged &lt;code&gt;app&lt;/code&gt;." Same layered-trust outcome, different mechanism — tags and service accounts instead of security group references.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Narrow Door: Bastion Hosts
&lt;/h2&gt;

&lt;p&gt;Even with three tiers locked down, someone eventually needs to actually log into a Tier 2 or Tier 3 instance — to debug a stuck Spark job, check disk space, whatever. You don't want to open SSH from the internet directly to that instance. The &lt;strong&gt;bastion host&lt;/strong&gt; (sometimes called a jump host) exists to solve exactly this.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ez8x76l2l0t8wnfgzl1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ez8x76l2l0t8wnfgzl1.png" alt="SSH hop through a bastion host into a private subnet" width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A bastion is a single, hardened instance sitting in the public subnet, whose security group allows SSH from a narrow, known set of IPs — your office, your VPN range, nowhere else. Every private-subnet instance's security group, in turn, allows SSH only from the bastion. You hop through it to reach anything private. One door, tightly guarded, heavily audited — rather than every private instance having its own exposed SSH port.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Modern Alternative: Skip the Bastion Entirely
&lt;/h3&gt;

&lt;p&gt;Both clouds now offer a way to get the same outcome — controlled admin access to private instances — without running a bastion host at all, and without opening an inbound port anywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS: SSM Session Manager.&lt;/strong&gt; No inbound port, no SSH keys to manage, no bastion instance to patch and pay for. Access is governed entirely by IAM policy, and every session is logged automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GCP: Identity-Aware Proxy (IAP) TCP forwarding.&lt;/strong&gt; Same idea — you connect to the private instance through IAP's proxy, authenticated by your Google identity and IAM role, with no external IP and no open SSH port required on the instance at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyzlou76ef0teyw0da9ez.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyzlou76ef0teyw0da9ez.png" alt="SSM Session Manager / IAP broker admin access with no open inbound ports" width="800" height="81"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The trade-off is close to nonexistent at this point — both are the recommended default on their respective clouds, and a real bastion host is increasingly something you'd only stand up for a specific compliance requirement or a legacy workflow that hasn't been migrated yet. If you're designing a new data platform today, start with SSM or IAP and only reach for a traditional bastion if something specifically forces your hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting the Whole Thing Together
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdq1uq5jaszyqpotppj1b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdq1uq5jaszyqpotppj1b.png" alt="The full 3-tier architecture with IAM-brokered admin access" width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is, structurally, most of what you need to know to read &lt;em&gt;any&lt;/em&gt; production data platform's network diagram — including the one from the intro of &lt;a href="https://dev.to/nbaubek/why-your-pipeline-cant-talk-to-anything-vpc-fundamentals-for-data-engineers-5987"&gt;Part 1&lt;/a&gt;. Three tiers, decreasing trust, one narrow administrative door that increasingly doesn't even need to be a door anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;So far, every private-subnet resource that needs to reach a managed service — S3, BigQuery, a managed Kafka cluster — has been going out through NAT, over the public internet, even though the traffic never leaves the cloud provider's own network in practice. &lt;a href="https://dev.to/nbaubek/private-connectivity-to-data-services-closing-the-last-gap-2lkb"&gt;Part 4&lt;/a&gt; covers how to close that gap entirely: &lt;strong&gt;VPC endpoints / PrivateLink&lt;/strong&gt; on AWS and &lt;strong&gt;Private Service Connect&lt;/strong&gt; on GCP, so that traffic to your data services never touches the public internet at all — not even for a private subnet with NAT egress.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>networking</category>
      <category>security</category>
      <category>cloud</category>
    </item>
  </channel>
</rss>
