<?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>PyArrow, Explained: How Zero-Copy Actually Works, and Why It's Everywhere</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Tue, 08 Sep 2026 09:11:37 +0000</pubDate>
      <link>https://dev.to/nbaubek/pyarrow-explained-how-zero-copy-actually-works-and-why-its-everywhere-4627</link>
      <guid>https://dev.to/nbaubek/pyarrow-explained-how-zero-copy-actually-works-and-why-its-everywhere-4627</guid>
      <description>&lt;p&gt;Every time data moves between two tools in a modern Python data stack — a DuckDB query result becoming a pandas DataFrame, a Polars frame getting handed to a PySpark job, a warehouse client returning query results to a notebook — something has to decide how that data is laid out in memory on both ends, and whether crossing that boundary means copying it. Most of the time, historically, it did: pickle it, walk every value, rebuild it on the other side. &lt;strong&gt;Apache Arrow exists to make that copy unnecessary&lt;/strong&gt;, and PyArrow is the Python implementation of it. This article is about what Arrow actually is, the specific mechanism that makes "zero-copy" a literal, checkable claim rather than a marketing phrase, and where it shows up across a real data engineering stack — including a real benchmark, not an assumed one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Arrow actually is
&lt;/h2&gt;

&lt;p&gt;If you've read &lt;a href="https://dev.to/nbaubek/parquet-explained-how-one-file-format-quietly-won-the-data-world-2610"&gt;the companion piece on Parquet&lt;/a&gt;, you already have the right instinct for this: Parquet is a &lt;strong&gt;file format&lt;/strong&gt;, specifying how columnar data should be laid out &lt;em&gt;on disk&lt;/em&gt;. Apache Arrow is a &lt;strong&gt;memory format&lt;/strong&gt; — a precise specification for how columnar data should be laid out &lt;em&gt;in RAM&lt;/em&gt;, inside a running process. Different problem, same underlying idea: lay data out by column, not by row, because it's faster to scan and compress that way.&lt;/p&gt;

&lt;p&gt;The detail that makes Arrow a genuinely different thing from Parquet isn't cross-language support — Parquet has that too, just as much as Arrow does. It's what each format is actually optimized for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parquet trades compute-readiness for compact storage.&lt;/strong&gt; It's compressed and encoded (dictionary encoding, run-length encoding, general-purpose compression) to take up as little space as possible on disk. You can't compute on it directly — it has to be decoded first, every time, by whatever's reading it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arrow trades storage compactness for direct computability.&lt;/strong&gt; It's already the &lt;em&gt;decoded&lt;/em&gt;, ready-to-compute-on layout — uncompressed, fixed-width buffers a CPU can operate on directly, with no decode step standing between "here's the memory" and "here's usable data."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's specifically what makes sharing Arrow between processes free, in a way sharing Parquet isn't: there's no decode step to pay on the way in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual memory layout — buffers, not objects
&lt;/h2&gt;

&lt;p&gt;Here's the detail that explains almost everything else in this article. A Python list of strings, or a NumPy array with &lt;code&gt;dtype=object&lt;/code&gt; (which is what pandas falls back to for strings), doesn't actually store the strings contiguously. It stores an array of &lt;em&gt;pointers&lt;/em&gt;, each one referencing a separate Python string object sitting wherever the memory allocator happened to put 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%2Fgmdwza3uew9pfyax5std.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%2Fgmdwza3uew9pfyax5std.png" alt="A Python object array storing scattered pointers to individually-allocated strings, versus an Arrow array storing the same strings as three contiguous buffers: a validity bitmap, an offsets buffer, and one values buffer" width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;An Arrow array of the same three strings is built from a small, fixed number of &lt;strong&gt;contiguous memory buffers&lt;/strong&gt; instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;validity bitmap&lt;/strong&gt; — one bit per value, marking nulls. (Arrow's null handling is a bitmap sitting alongside the data, not a sentinel value mixed into it — this is also why Arrow's nullable-integer support is cleaner than NumPy's, which has no way to represent a null integer without upcasting the whole column to float.)&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;offsets buffer&lt;/strong&gt; — for variable-length types like strings, an array of integers marking where each value starts and ends inside the values buffer.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;values buffer&lt;/strong&gt; — the actual bytes, laid out back-to-back, with no per-value object overhead and no pointer to chase to reach the next one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters for two independent reasons, and it's worth keeping them separate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's faster to compute over&lt;/strong&gt;, even within one process. Contiguous, uniformly-typed memory is exactly what lets a CPU vectorize (process several values per instruction) and stay cache-friendly (reading value &lt;em&gt;n+1&lt;/em&gt; doesn't mean jumping somewhere else in RAM the way following a pointer does). This is the same columnar-layout argument the Parquet article makes for reading off disk, just one level closer to the CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's what makes the memory shareable between processes and languages at all.&lt;/strong&gt; A Python object array is full of language-specific state (Python's own object headers, reference counts) that only Python's runtime understands. A block of Arrow buffers is just bytes with a schema — nothing in it is specific to any language's runtime, which is the precondition for the next section.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why it's called zero-copy — the actual mechanism
&lt;/h2&gt;

&lt;p&gt;"Zero-copy" gets thrown around loosely enough that it's worth being precise about what specifically makes it true. The mechanism is called the &lt;strong&gt;Arrow C Data Interface&lt;/strong&gt;, and it's smaller than you'd expect: a handful of plain C struct definitions (&lt;code&gt;ArrowArray&lt;/code&gt;, &lt;code&gt;ArrowSchema&lt;/code&gt;, and a streaming variant &lt;code&gt;ArrowArrayStream&lt;/code&gt;) that any project can copy directly into its own source tree. There's no library to link against and no shared build dependency — two completely independently-compiled programs, in two different languages, can exchange an Arrow array at runtime just by agreeing on the layout of these structs and passing a pointer to one. Non-C/C++ languages participate through their own FFI layer — Python via &lt;code&gt;ctypes&lt;/code&gt;/&lt;code&gt;cffi&lt;/code&gt;, similarly for Rust, Go, and Julia.&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%2Fu1waopr5jikxpt35p5m5.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%2Fu1waopr5jikxpt35p5m5.png" alt="Zero-copy handoff via the Arrow C Data Interface: a pointer and a small metadata struct passed between systems that already share a memory layout, versus a serialization roundtrip that walks every value, builds a new byte stream, and allocates a fresh copy on the other side" width="800" height="576"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;Compare that to what pickle, JSON, or protobuf actually do: walk every value in the source structure, transform it into a different byte layout entirely, send that stream across, and have the receiving side parse it back into a new, freshly-allocated structure. That's real, unavoidable work that scales with the size of the data. Arrow's C Data Interface skips all of it — when two Arrow-aware libraries hand data to each other &lt;em&gt;within the same process&lt;/em&gt;, they're not converting anything; they're agreeing to both look at the same buffers.&lt;/p&gt;

&lt;p&gt;One nuance worth having precisely right: this specific mechanism is for sharing memory &lt;strong&gt;within a single process&lt;/strong&gt; — the pointer only means something as long as both sides can address the same memory. The moment data genuinely needs to leave the process (over a network, to disk, to a separate machine), Arrow uses a different piece of the spec, the &lt;strong&gt;Arrow IPC format&lt;/strong&gt; — but even that is barely a serialization step by the standards of pickle or JSON: IPC is close to writing the same in-memory buffers out sequentially with a thin framing header, not reshaping the data into a different structure. &lt;code&gt;Feather&lt;/code&gt; files are just Arrow IPC written to disk. Arrow Flight, covered below, is Arrow IPC streamed over gRPC.&lt;/p&gt;

&lt;h3&gt;
  
  
  Proving it, not just asserting it
&lt;/h3&gt;

&lt;p&gt;Rather than take the "zero-copy is fast" claim on faith, here's a real, reproducible comparison: handing a table from DuckDB to Polars via Arrow, versus a genuine serialization roundtrip (materializing rows, &lt;code&gt;json.dumps&lt;/code&gt;, &lt;code&gt;json.loads&lt;/code&gt;, then rebuilding a DataFrame) — timed at increasing row counts.&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;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;duckdb&lt;/span&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;bench_zero_copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_rows&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;con&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        CREATE TABLE t AS
        SELECT i AS id, (i % 100) AS bucket, CAST(i AS DOUBLE) * 1.5 AS amount
        FROM range(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n_rows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) t(i)
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;arrow_table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;arrow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# DuckDB -&amp;gt; Arrow
&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="nf"&gt;from_arrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arrow_table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                          &lt;span class="c1"&gt;# Arrow -&amp;gt; Polars
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;bench_serialize_roundtrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_rows&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;con&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        CREATE TABLE t AS
        SELECT i AS id, (i % 100) AS bucket, CAST(i AS DOUBLE) * 1.5 AS amount
        FROM range(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n_rows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) t(i)
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&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="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&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="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                   &lt;span class="c1"&gt;# the actual reformatting work
&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="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&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;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;bucket&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="n"&gt;orient&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;row&lt;/span&gt;&lt;span class="sh"&gt;"&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;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run on this machine — after one warm-up call to absorb import/connection-setup costs, and taking the median of 5 runs at each row count, since a single cold run swings around enough to be misleading on its own:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Zero-copy median (s)&lt;/th&gt;
&lt;th&gt;Serialize roundtrip median (s)&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;0.0009&lt;/td&gt;
&lt;td&gt;0.0138&lt;/td&gt;
&lt;td&gt;15x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;0.0038&lt;/td&gt;
&lt;td&gt;0.1428&lt;/td&gt;
&lt;td&gt;38x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;0.0133&lt;/td&gt;
&lt;td&gt;2.0589&lt;/td&gt;
&lt;td&gt;154x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000,000&lt;/td&gt;
&lt;td&gt;0.1698&lt;/td&gt;
&lt;td&gt;11.7037&lt;/td&gt;
&lt;td&gt;69x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two honest notes on this table, since fabricated-looking precision is worse than useful imprecision: these are wall-clock numbers from one machine under median-of-5 conditions, so absolute values — and even the exact ratio at each row count, which bounces around here rather than climbing smoothly — will vary with hardware, load, and how DuckDB's query planner happens to behave at each size. Don't treat 15x/38x/154x/69x as a formula. What's consistent and worth trusting is the shape: the zero-copy path stays under two-tenths of a second even at 5 million rows, while the serialization path grows to nearly 12 seconds over the same range, because it's doing real per-value work — building a byte stream, then rebuilding objects from it — that the zero-copy path simply never does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this shows up in Spark and Databricks
&lt;/h2&gt;

&lt;p&gt;This is the part that's easy to undersell as "Arrow makes Spark faster" without saying how. A plain Python UDF in PySpark serializes data row-by-row across the JVM↔Python process boundary — real, per-row overhead, the same category of cost as the JSON roundtrip above. &lt;strong&gt;Pandas UDFs&lt;/strong&gt; (vectorized UDFs) fix this by batching rows and moving them across that boundary as Arrow data instead, which is where the commonly-cited "up to 100x faster than row-at-a-time UDFs" figure comes from — it's the same mechanism this article just demonstrated, applied specifically to the JVM/Python boundary.&lt;/p&gt;

&lt;p&gt;Even Pandas UDFs still pay one small cost worth knowing about: converting an Arrow batch into a pandas Series isn't always free, particularly around null handling, so there's a real Arrow→pandas conversion step in between. Databricks introduced a further evolution in 2026: &lt;strong&gt;native Arrow UDFs&lt;/strong&gt;, which operate directly on &lt;code&gt;pyarrow.Array&lt;/code&gt; / &lt;code&gt;RecordBatch&lt;/code&gt; objects and skip the pandas conversion entirely — one less hop between "data arrives as Arrow" and "your function runs on it."&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the ecosystem
&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%2F44ij9tl7wm2rsq0kslr5.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%2F44ij9tl7wm2rsq0kslr5.png" alt="Arrow as the shared in-memory layer connecting pandas, Polars, DuckDB, Spark/Databricks, and Arrow Flight over the network, sitting above Parquet as the on-disk layer" width="800" height="227"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pandas 2.0+.&lt;/strong&gt; You can back an entire DataFrame with Arrow dtypes instead of NumPy (&lt;code&gt;dtype_backend="pyarrow"&lt;/code&gt;) — better nullable-type support, and less silent type coercion than NumPy's object-dtype fallback for strings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parquet I/O.&lt;/strong&gt; PyArrow is one of pandas' two Parquet engines (&lt;code&gt;engine="pyarrow"&lt;/code&gt;), and generally the more spec-complete of the two — the direct link back to the companion article on this profile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB.&lt;/strong&gt; Genuinely tight integration: &lt;code&gt;duckdb.sql(...).arrow()&lt;/code&gt; hands back a PyArrow table, and DuckDB can query a PyArrow table in memory as if it were a SQL table, no loading step in either direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polars&lt;/strong&gt; — worth being precise here: Polars doesn't depend on the &lt;code&gt;pyarrow&lt;/code&gt; package internally; it has its own Rust-native Arrow implementation. But it speaks the same Arrow &lt;em&gt;format&lt;/em&gt;, which is why &lt;code&gt;pl.from_arrow()&lt;/code&gt; on a PyArrow table is cheap rather than a real conversion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arrow Flight / Flight SQL.&lt;/strong&gt; A gRPC-based transport built specifically to move Arrow data between systems fast — increasingly the transport layer for BI tools and newer database drivers, instead of row-oriented ODBC/JDBC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ADBC (Arrow Database Connectivity).&lt;/strong&gt; An emerging standard positioning itself as ODBC/JDBC's Arrow-native successor. Snowflake's and BigQuery's Python connectors can already hand back query results as Arrow tables directly, rather than a row-by-row cursor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When to reach for the &lt;code&gt;pyarrow&lt;/code&gt; API directly
&lt;/h2&gt;

&lt;p&gt;Most of the time, Arrow is working underneath a tool you're already using and you never touch the &lt;code&gt;pyarrow&lt;/code&gt; package by name. A few situations where reaching for it directly is the right call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pyarrow.compute&lt;/code&gt;&lt;/strong&gt; — a library of vectorized functions (filtering, string operations, aggregations) that operate straight on Arrow arrays, useful when you want columnar-speed operations without pulling in the rest of pandas or Polars.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing Parquet with fine control&lt;/strong&gt; — &lt;code&gt;pyarrow.parquet&lt;/code&gt; exposes the row-group size, compression codec, and encoding options the Parquet article covers, when a higher-level &lt;code&gt;df.to_parquet()&lt;/code&gt; call doesn't expose the knob you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arrow Flight&lt;/strong&gt;, if you're building (not just consuming) a system that needs to move large columnar results between processes or machines fast.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the "zero-copy" story has real edges
&lt;/h2&gt;

&lt;p&gt;In the spirit of not overselling this: not everything touching Arrow is actually zero-copy, and it's worth knowing where that breaks down before you assume it everywhere.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Casting between incompatible types still copies.&lt;/strong&gt; Converting an Arrow &lt;code&gt;int32&lt;/code&gt; array to &lt;code&gt;int64&lt;/code&gt; has to build a new buffer at the new width — there's no way around that being real work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Combining chunked arrays can copy.&lt;/strong&gt; Arrow tables are often stored as multiple chunks (e.g., one per batch read from a file); an operation that needs a single contiguous array sometimes has to concatenate chunks first, which is a real copy, not a pointer handoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crossing into pandas' legacy NumPy object dtype for strings still copies&lt;/strong&gt;, because that representation is fundamentally different from Arrow's buffer layout — this is exactly why pandas 2.0's Arrow-backed dtype option exists, to avoid needing to make that crossing at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are bugs — they're places where the data genuinely has to change shape, and Arrow doesn't pretend otherwise. The claim isn't "nothing ever copies." It's "moving the same logical data between two systems that already agree on its layout doesn't have to," which is a much stronger and much more specific claim — and, per the benchmark above, a true one.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Arrow is a memory format, not a library trick — the same byte layout works across languages, which is the precondition for zero-copy to mean anything.&lt;/li&gt;
&lt;li&gt;The mechanism is the Arrow C Data Interface: a tiny, stable set of C structs that let independently-built libraries hand off a pointer instead of re-serializing data.&lt;/li&gt;
&lt;li&gt;"Zero-copy" is a real, measurable claim, not a marketing word — the benchmark above shows the gap growing, not shrinking, as data gets larger.&lt;/li&gt;
&lt;li&gt;PyArrow shows up almost everywhere in a modern Python data stack — pandas, Polars, DuckDB, Spark/Databricks, warehouse connectors — usually invisibly, which is exactly the point.&lt;/li&gt;
&lt;li&gt;It's not infinitely free: type casts, chunk concatenation, and legacy pandas string handling are real, known places where a copy still happens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If this is useful background, &lt;a href="https://dev.to/nbaubek/parquet-explained-how-one-file-format-quietly-won-the-data-world-2610"&gt;the Parquet article on this profile&lt;/a&gt; covers the disk-format half of this exact story — the two are designed to be read as a pair.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>python</category>
      <category>bigdata</category>
      <category>pyarrow</category>
    </item>
    <item>
      <title>From Detection to Production: A PII-Safe Pipeline in Python and DuckDB</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Mon, 07 Sep 2026 07:21:54 +0000</pubDate>
      <link>https://dev.to/nbaubek/from-detection-to-production-a-pii-safe-pipeline-in-python-and-duckdb-1638</link>
      <guid>https://dev.to/nbaubek/from-detection-to-production-a-pii-safe-pipeline-in-python-and-duckdb-1638</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/nbaubek/you-cant-protect-what-you-cant-find-detecting-and-classifying-pii-in-data-pipelines-261f"&gt;Part 1&lt;/a&gt; gave you a map: every PII-bearing column, tier-tagged. &lt;a href="https://dev.to/nbaubek/mask-hash-tokenize-or-encrypt-choosing-the-right-pii-protection-for-your-pipeline-4dje"&gt;Part 2&lt;/a&gt; armed you: HMAC for direct identifiers, tokens for what must be reversible, masking for display, generalization for quasi-identifiers. Now the part where articles usually wave their hands and your actual weekend disappears: &lt;strong&gt;putting it together.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything in this post runs from the &lt;a href="https://github.com/nbaubek/devto-articles-repo/tree/main/pii-pipeline" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; with &lt;code&gt;uv sync&lt;/code&gt; — no warehouse, no Docker, no cloud account. DuckDB stands in for your storage layers so the mechanics survive the translation; the article flags every spot where production wants something heavier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture: three zones and a key
&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%2Fj3dwm9h806733utma7zv.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%2Fj3dwm9h806733utma7zv.png" alt="The pipeline: raw zone, curated zone, vault, serving views" width="799" height="212"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Zone&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What lives there&lt;/th&gt;
&lt;th&gt;Access story&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Raw&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;raw.duckdb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;everything, unmasked, as landed&lt;/td&gt;
&lt;td&gt;restricted &lt;em&gt;by access&lt;/em&gt;, short-lived by policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Curated&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;curated.duckdb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;pseudonymized join keys, masked phones, generalized quasi-identifiers&lt;/td&gt;
&lt;td&gt;the analytics surface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vault&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vault.duckdb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;token → identity, for authorized reversal&lt;/td&gt;
&lt;td&gt;the crown jewels; audit everything&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And floating beside them: &lt;code&gt;PII_HMAC_KEY&lt;/code&gt;, from your secrets manager to the pipeline's environment. It never touches disk in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raw zone argument
&lt;/h2&gt;

&lt;p&gt;First, the uncomfortable design decision. Can't you just protect PII &lt;em&gt;before&lt;/em&gt; it lands anywhere — mask at ingestion, skip the raw zone entirely? In a perfect world, yes. In this one: sources drift, transforms have bugs, and the day you need to reprocess last month you'll want the original bytes. Most pipelines keep a raw zone.&lt;/p&gt;

&lt;p&gt;The point this series argues for is smaller and sharper: &lt;strong&gt;once raw PII lands in cheap, copyable storage, you've inherited a governance problem&lt;/strong&gt; — so treat the raw zone like the liability it is. Concretely, in the repo: it's a separate database file, separate from anything an analyst or BI tool touches, and it's the one zone where you'd set a retention timer (the demo skips implementing it; your warehouse's lifecycle policies shouldn't). Protection then happens at the &lt;em&gt;first boundary out of raw&lt;/em&gt; — not "eventually, in some dashboard."&lt;/p&gt;

&lt;h2&gt;
  
  
  The transform: policy-driven, not vibes-driven
&lt;/h2&gt;

&lt;p&gt;The pipeline reads Part 1's &lt;code&gt;classifications.yaml&lt;/code&gt; and applies Part 2's decision per tier — &lt;code&gt;direct → pseudonymize&lt;/code&gt;, &lt;code&gt;quasi → generalize&lt;/code&gt;, &lt;code&gt;free_text → keep-restricted&lt;/code&gt;, &lt;code&gt;non_pii → pass-through&lt;/code&gt;. The heart of it is ~15 lines:&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;# src/pii/pipeline.py (trimmed)
&lt;/span&gt;&lt;span class="n"&gt;curated_rows&lt;/span&gt; &lt;span class="o"&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;row&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="nf"&gt;pseudonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&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="p"&gt;),&lt;/span&gt;        &lt;span class="c1"&gt;# direct -&amp;gt; HMAC join key
&lt;/span&gt;        &lt;span class="nf"&gt;pseudonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contact_ref&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="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# direct -&amp;gt; same key for the same person
&lt;/span&gt;        &lt;span class="nf"&gt;mask_phone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;               &lt;span class="c1"&gt;# direct -&amp;gt; display mask
&lt;/span&gt;        &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zip&lt;/span&gt;&lt;span class="sh"&gt;"&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="c1"&gt;# quasi   -&amp;gt; generalize
&lt;/span&gt;        &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dob&lt;/span&gt;&lt;span class="sh"&gt;"&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="c1"&gt;# quasi   -&amp;gt; generalize
&lt;/span&gt;        &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;support_notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;                   &lt;span class="c1"&gt;# free_text -&amp;gt; kept, restricted at serving
&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;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&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 console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;uv run python &lt;span class="nt"&gt;-m&lt;/span&gt; pii.pipeline
&lt;span class="go"&gt;warning: PII_HMAC_KEY not set — using the dev-only key. Fine for the
warning: demo, a firing offence in production.
raw      : 300 rows landed (restricted zone, add retention)
curated  : 300 rows, 291 with user_pseudo_id = contact_token
vault    : 300 reversible tokens (guard this file)
&lt;/span&gt;&lt;span class="gp"&gt;joins survive pseudonymization: email and contact_ref -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;same token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;291&lt;/code&gt; is the Part 2 payoff, measured: 97% of &lt;code&gt;contact_ref&lt;/code&gt; values are the same email as the &lt;code&gt;email&lt;/code&gt; column (Part 1 planted that lie), and HMAC's determinism means both columns collapse onto one join key per person. Names never make it into curated at all — they live only in the vault.&lt;/p&gt;

&lt;h2&gt;
  
  
  The serving layer: roles, not tables
&lt;/h2&gt;

&lt;p&gt;DuckDB has no native masking policies — it's an embedded database, roles aren't its job. So the repo simulates the pattern with views and says so honestly:&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;-- src/pii/serve.py: what analysts get&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;v_customers_analyst&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;user_pseudo_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;phone_masked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;zip3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;birth_year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;support_notes&lt;/span&gt;      &lt;span class="c1"&gt;-- redact_on_read, from classifications.yaml&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;curated_customers&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 console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;uv run python &lt;span class="nt"&gt;-m&lt;/span&gt; pii.serve
&lt;span class="go"&gt;analyst sees:
  ('0822e8f3-...', '0138f3a7b71bff72', '***-***-9935', '044', 1942, None)

support sees:
  ('47378190-...', '6814444dac9bd257', '***-***-7873',
&lt;/span&gt;&lt;span class="gp"&gt;   "Hi, this is James Santos — order #&lt;/span&gt;3615 never arrived. I&lt;span class="s1"&gt;'m at williamjohnson@...")
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production this exact shape becomes a warehouse-native policy attached to the column, evaluated per query:&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;-- the Snowflake version of the same idea&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MASKING&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;mask_notes&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt; &lt;span class="o"&gt;-&amp;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="k"&gt;CURRENT_ROLE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SUPPORT'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="k"&gt;NULL&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;curated_customers&lt;/span&gt;
  &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;support_notes&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;MASKING&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;mask_notes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson isn't "use views" — it's that &lt;strong&gt;redaction lives at the serving boundary&lt;/strong&gt;, declared once, instead of being remembered separately by every downstream consumer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Erasure: "delete the user" is three deletes
&lt;/h2&gt;

&lt;p&gt;A GDPR erasure request arrives for one customer. Where do they live? Everywhere (Part 2 explained why pseudonyms don't exempt you):&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%2Fd12lhwukcl1yyc7fbiea.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%2Fd12lhwukcl1yyc7fbiea.png" alt="Erasure propagated across zones and backups" width="800" height="563"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;uv run python &lt;span class="nt"&gt;-m&lt;/span&gt; pii.erasure johnsonjoshua@example.com
&lt;span class="go"&gt;vault    deleted 1 row(s)
curated  deleted 1 row(s)
raw      deleted 1 row(s)

backups: not covered here — that's what crypto-shredding is for.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick worth stealing: you don't need the vault to find the person in curated — &lt;code&gt;hmac(key, email)&lt;/code&gt; recomputes the pseudonym deterministically, so erasure fans out to every zone from the request itself. And the honest footnote the script prints: &lt;code&gt;DELETE&lt;/code&gt; doesn't touch backups and snapshots. That's why erasure-ready pipelines either keep backup windows short or encrypt per-customer and destroy keys on request (crypto-shredding).&lt;/p&gt;

&lt;h2&gt;
  
  
  The guardrail: fail the PR, not the incident
&lt;/h2&gt;

&lt;p&gt;Everything above is one refactor away from silently leaking PII again. So the repo's five tests do the remembering for you:&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/test_no_leaked_pii.py (trimmed)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_curated_zone_has_no_direct_pii&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="c1"&gt;# run the pipeline into temp DBs, then:
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                    &lt;span class="c1"&gt;# every curated column...
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EMAIL_RE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PHONE_RE&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="c1"&gt;# ...must match zero PII patterns
&lt;/span&gt;            &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT count(*) FROM curated_customers &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WHERE regexp_matches(CAST(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; AS VARCHAR), &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&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;assert&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; matches PII pattern &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — leak!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth a pause on that f-string, given the subject matter: &lt;code&gt;column&lt;/code&gt; and &lt;code&gt;pattern&lt;/code&gt; are interpolated straight into the SQL. That's safe &lt;em&gt;here&lt;/em&gt; specifically because both come from a closed, hardcoded list defined in the test file, not from anything a user or upstream system controls — there's no injection surface. But copy this pattern into anything where &lt;code&gt;column&lt;/code&gt; could originate from a config file, an API response, or a contract someone else edits, and you've built the exact kind of hole this series spends three articles closing. Parameterize or validate against an allow-list the moment that input stops being something you typed yourself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;uv run pytest &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;span class="go"&gt;tests/test_no_leaked_pii.py::test_curated_zone_has_no_direct_pii PASSED [ 20%]
tests/test_no_leaked_pii.py::test_analyst_view_exposes_no_free_text PASSED [ 40%]
tests/test_no_leaked_pii.py::test_pseudonymization_preserves_joins PASSED [ 60%]
tests/test_no_leaked_pii.py::test_every_column_is_classified PASSED      [ 80%]
tests/test_no_leaked_pii.py::test_validate_contract_passes PASSED        [100%]

5 passed in 5.40s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read those test names again — each one is a failure mode from Parts 1 and 3 that now &lt;em&gt;cannot happen silently&lt;/em&gt;: PII leaking into curated, free text reaching analysts, join keys breaking, a new column appearing without classification, a contract drifting from reality. This is the whole series in one command: detection became a pipeline stage, not an audit.&lt;/p&gt;

&lt;p&gt;(If the unit/integration split and the mocking pattern above look familiar, they're the same ones from &lt;a href="https://dev.to/nbaubek/testing-data-pipelines-like-you-mean-it-a-pytest-crash-course-for-data-engineers-24"&gt;my pytest crash course for data pipelines&lt;/a&gt; — this test suite is that article's advice applied to a real problem instead of a toy one.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What this repo deliberately doesn't do
&lt;/h2&gt;

&lt;p&gt;Honesty section, no charge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No orchestration&lt;/strong&gt; — nothing schedules detection scans or key rotation. Put Presidio on a nightly sample, not in CI (too slow) and not never (that's how drift wins).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No catalog or lineage&lt;/strong&gt; — tier tags belong in your data catalog too, so governance sees what engineering sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No access auditing&lt;/strong&gt; — the vault should log every reversal; DuckDB won't do that for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB views ≠ policies&lt;/strong&gt; — production wants Snowflake masking policies or BigQuery column-level security, where the database enforces what the view merely promises.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single key, single rotation story, unimplemented&lt;/strong&gt; — versioned tokens (&lt;code&gt;v2:...&lt;/code&gt;) are designed for, not built.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The series, in one diagram
&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%2F504n4kgt6csb9g2px8ok.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%2F504n4kgt6csb9g2px8ok.png" alt="Series recap: find it, choose the weapon, run it" width="800" height="663"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Part 1 argued detection is a pipeline stage, not an audit. Part 2 argued technique choice follows from what the data must still &lt;em&gt;do&lt;/em&gt;. Part 3's argument is the last one: &lt;strong&gt;protection is architecture&lt;/strong&gt; — zones, boundaries, and a guardrail that fails the build — because any PII decision that lives only in someone's memory is a future incident.&lt;/p&gt;

&lt;p&gt;The full repo is &lt;a href="https://github.com/nbaubek/devto-articles-repo/tree/main/pii-pipeline" rel="noopener noreferrer"&gt;here&lt;/a&gt; — clone it, break it, add a column of undeclared emails to the CSV and watch the contract test fail. That's the fastest way to make this series yours.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>duckdb</category>
      <category>dataprivacy</category>
      <category>security</category>
    </item>
    <item>
      <title>Mask, Hash, Tokenize, or Encrypt? Choosing the Right PII Protection for Your Pipeline</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Mon, 07 Sep 2026 07:20:46 +0000</pubDate>
      <link>https://dev.to/nbaubek/mask-hash-tokenize-or-encrypt-choosing-the-right-pii-protection-for-your-pipeline-4dje</link>
      <guid>https://dev.to/nbaubek/mask-hash-tokenize-or-encrypt-choosing-the-right-pii-protection-for-your-pipeline-4dje</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/nbaubek/you-cant-protect-what-you-cant-find-detecting-and-classifying-pii-in-data-pipelines-261f"&gt;Part 1&lt;/a&gt; you built a map: every column that holds PII, tagged with a tier. Feels like the hard part is over. Then someone in #data-platform asks the reasonable question:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"OK so... do we hash the emails? Or mask them? I feel like hashing. Let's hash them."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Hash it" is the two-word answer that's almost always wrong in an interesting way. Not because hashing is bad — because the question wasn't "which algorithm," it was "what do we still need this data to &lt;em&gt;do&lt;/em&gt;?" This article is about answering that question deliberately. All code is in the &lt;a href="https://github.com/nbaubek/devto-articles-repo/tree/main/pii-pipeline" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; — every claim below has a runnable file behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  One map before the weapons
&lt;/h2&gt;

&lt;p&gt;Every technique fits somewhere on one axis that matters more than people admit: &lt;strong&gt;can the original value be recovered?&lt;/strong&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%2Fv0mti0cpgjyaywp029dr.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%2Fv0mti0cpgjyaywp029dr.png" alt="The five techniques mapped by reversibility" width="800" height="941"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rule of thumb hiding in this diagram: &lt;strong&gt;if a key or vault can recover the person, you are still holding personal data.&lt;/strong&gt; Encryption and tokenization buy you &lt;em&gt;access control&lt;/em&gt;. Masking, hashing, and generalization buy you &lt;em&gt;distance&lt;/em&gt;. Sometimes you need one, sometimes the other, sometimes both on the same column in different zones. Let's earn that sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Masking: for when nobody needs the original
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/pii/masking.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mask_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;jane.doe@example.com -&amp;gt; j***@example.com (irreversible, display-safe).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;***@&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;***&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Masking is display logic. Partial masks keep just enough for humans to recognize ("yeah that's my email"), and the original value is &lt;em&gt;gone&lt;/em&gt; — there is no key. Static masking rewrites the data before it lands somewhere broad (analytics, test databases); dynamic masking evaluates per-user at query time (Snowflake masking policies, BigQuery column-level security — the warehouse layer, not your Python).&lt;/p&gt;

&lt;p&gt;Great for: support agents verifying identity by last-4 digits, dashboards, test data.&lt;br&gt;
Wrong for: anything downstream that needs to &lt;em&gt;join&lt;/em&gt;, &lt;em&gt;count distinct&lt;/em&gt;, or &lt;em&gt;reach back out&lt;/em&gt; to the person.&lt;/p&gt;
&lt;h2&gt;
  
  
  Hashing: the trap everyone walks into
&lt;/h2&gt;

&lt;p&gt;Hashing feels like privacy alchemy: deterministic, irreversible, join-friendly. And for high-entropy secrets (passwords) it genuinely is. PII is not high-entropy. A US phone number has 10 billion possibilities — &lt;em&gt;precomputable&lt;/em&gt;. An SSN: a billion. A gender-plus-ZIP combo: forget it. When the input space is small, an attacker doesn't reverse your hash; they hash the whole dictionary and look it up.&lt;/p&gt;

&lt;p&gt;This is not hypothetical — the repo demonstrates it (&lt;code&gt;uv run python -m pii.hashing_demo&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;leaked: 5 phone numbers, each appearing twice (as if in two tables)
attacker: precomputed dictionary of 20,000 candidate phones

1. sha256(phone)
   cracked by dictionary : 5/5
   same phone, same digest (joins work): YES — for the attacker too

2. sha256(salt + phone)
   cracked by dictionary : 0/10
   same phone, same digest (joins work): NO — broken for everyone

3. hmac(key, phone)
   cracked by dictionary : 0/5 (key unknown)
   same phone, same digest (joins work): YES — attacker needs the key
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines, one entire decision framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  The join-key dilemma
&lt;/h2&gt;

&lt;p&gt;Here's the tension that actually decides your architecture:&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%2F8egczszez8pc98cvoz6l.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%2F8egczszez8pc98cvoz6l.png" alt="The join-key dilemma" width="800" height="361"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Analytics &lt;strong&gt;needs&lt;/strong&gt; deterministic identifiers — same email in the events table and the orders table must produce the same key, or joins, distinct counts, and funnels all die. Security &lt;strong&gt;needs&lt;/strong&gt; outputs nobody can precompute. Plain hashing gives you the first and forfeits the second. The textbook fix — a random salt per row — gives you the second and forfeits the first: the same phone now hashes differently everywhere it appears, and &lt;code&gt;COUNT(DISTINCT user)&lt;/code&gt; quietly becomes nonsense.&lt;/p&gt;

&lt;h2&gt;
  
  
  HMAC: the workhorse
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/pii/masking.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pseudonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;HMAC-SHA256, truncated. Deterministic (joins survive), keyed.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HMAC is hashing with a secret key baked into the function. Same input + same key → same digest, so your joins live. But an attacker with a dictionary and no key gets nothing — their precomputed table is useless against a keyed construction. It's not magic, it's a trade-off with one new obligation: &lt;strong&gt;key management.&lt;/strong&gt; The key lives in a secrets manager, reaches the pipeline as &lt;code&gt;PII_HMAC_KEY&lt;/code&gt;, and — the part teams forget — needs a rotation story. Version your tokens (&lt;code&gt;v2:a1b2c3...&lt;/code&gt;) so rotating the key doesn't orphan every join key you've ever minted. The trade: anyone holding the key can map every pseudonym back — so the key's blast radius &lt;em&gt;is&lt;/em&gt; your privacy posture.&lt;/p&gt;

&lt;p&gt;For most analytics pipelines this is the default answer for direct identifiers: deterministic, irreversible without the key, and good enough to build on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tokenization: when the business needs to reach back
&lt;/h2&gt;

&lt;p&gt;Sometimes "irreversible" is a deal-breaker — support genuinely needs to email the customer back. Tokenization swaps the PII for a token and stores the mapping in a &lt;strong&gt;vault&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;token          email
──────────────────────────────────────
0138f3a7b71b…  jane.doe@example.com     ← vault.duckdb, max lock-down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline and analytics only ever see the token; reversal is an explicit, audited vault lookup behind an authorization boundary. Tokens can be &lt;em&gt;deterministic&lt;/em&gt; (same input → same token → joins survive, same trade as HMAC) or &lt;em&gt;random&lt;/em&gt; (unlinkable, but unjoinable). And this is the shape PCI-DSS expects for card data, because "we never store the PAN, only the token" is a sentence auditors enjoy.&lt;/p&gt;

&lt;p&gt;The honest cost: the vault becomes your crown-jewel database. Compromise it and every token in every table becomes plaintext. Guard, audit, and back it up like it's the one file with everyone's names in it — because it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encryption: access control, not anonymity
&lt;/h2&gt;

&lt;p&gt;Encryption is non-negotiable &lt;em&gt;and&lt;/em&gt; overrated, in the same way locks are: essential, but nobody claims a locked house contains no furniture. If a key can decrypt the column — and at query time something usually can — then GDPR still sees personal data, your breach blast radius still includes those columns, and your erasure obligations still apply. Encrypt everything at rest and in transit, use column-level encryption for the spicy fields, and file it under &lt;strong&gt;securing&lt;/strong&gt; PII, not &lt;strong&gt;reducing&lt;/strong&gt; it. The techniques above reduce; encryption just locks the door.&lt;/p&gt;

&lt;h2&gt;
  
  
  The legal line: pseudonymized vs. anonymous
&lt;/h2&gt;

&lt;p&gt;The distinction that ends arguments in incident reviews:&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%2F5oh010caxe18o8nla0bz.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%2F5oh010caxe18o8nla0bz.png" alt="Pseudonymized vs. anonymized" width="800" height="1085"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Under GDPR, &lt;strong&gt;a hashed email is pseudonymous, not anonymous&lt;/strong&gt; — it's still personal data, because possession of the key (or a matching hash elsewhere) re-links it. Retention limits and right-to-erasure apply in full. The only road to "anonymous" runs through the quasi-identifiers from Part 1: generalize &lt;code&gt;zip → 941&lt;/code&gt;, &lt;code&gt;dob → 1974&lt;/code&gt;, suppress rare values, until no combination singles anyone out (k-anonymity). That's why Part 1's tier tags mattered: &lt;strong&gt;direct identifiers get HMAC or tokens; quasi-identifiers get generalization.&lt;/strong&gt; Different tiers, different weapons.&lt;/p&gt;

&lt;p&gt;One caveat worth knowing before you treat k-anonymity as a finish line: it protects against &lt;em&gt;singling someone out&lt;/em&gt;, not against &lt;em&gt;learning something about them&lt;/em&gt;. If every one of the k records sharing a generalized ZIP+birth-year bucket happens to have the same sensitive attribute — the same diagnosis, say — an attacker doesn't need to identify the individual to learn the fact, because the whole bucket shares it (the textbook "homogeneity attack"). k-anonymity is the regulatory bar GDPR actually recognizes, and it's the right target for this series, but if a quasi-identifier bucket is going to sit next to a Tier 3 sensitive attribute, it's worth checking the bucket isn't accidentally uniform on that attribute — that's what extensions like l-diversity exist to catch.&lt;/p&gt;

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

&lt;p&gt;Screenshot this one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement driving the choice&lt;/th&gt;
&lt;th&gt;Masking&lt;/th&gt;
&lt;th&gt;HMAC / hashing&lt;/th&gt;
&lt;th&gt;Tokenization&lt;/th&gt;
&lt;th&gt;Encryption&lt;/th&gt;
&lt;th&gt;Generalization&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Analytics must join / count distinct on it&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes (deterministic tokens)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;partially&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business must recover the original&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes (vault)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes (key)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survives without key/vault management&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Format must stay valid (sort keys, regex)&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes (FPE tokens)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Removes GDPR obligations on the dataset&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes (k-anonymity)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical home&lt;/td&gt;
&lt;td&gt;display &amp;amp; serving layer&lt;/td&gt;
&lt;td&gt;direct identifiers in analytics&lt;/td&gt;
&lt;td&gt;payment &amp;amp; operational data&lt;/td&gt;
&lt;td&gt;storage &amp;amp; transit&lt;/td&gt;
&lt;td&gt;quasi-identifiers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most real pipelines are a composition: &lt;strong&gt;encrypt the raw zone, HMAC the direct identifiers into curated, tokenize what support must reach, mask at the serving layer, generalize the quasi-identifiers.&lt;/strong&gt; Part 3 builds exactly that.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;"Hash it" is a question about &lt;em&gt;what the data still needs to do&lt;/em&gt;, not an algorithm choice.&lt;/li&gt;
&lt;li&gt;Low-entropy PII (phones, SSNs, ZIPs) falls to dictionary attacks unless the hash is keyed — use HMAC.&lt;/li&gt;
&lt;li&gt;Salts protect passwords, not join keys. Per-row salts and analytics joins are mutually exclusive.&lt;/li&gt;
&lt;li&gt;Reversibility is access control, not anonymity. The key and the vault are personal data by proxy.&lt;/li&gt;
&lt;li&gt;GDPR calls everything except k-anonymized data "personal data." Plan retention and erasure accordingly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Next in the series:&lt;/strong&gt; we take the tier tags from Part 1 and the technique choices from Part 2, and build the actual pipeline — raw zone, curated zone, vault, role-based serving views, GDPR erasure across all of them, and the CI test that fails your build when PII leaks. All runnable, all local.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>security</category>
      <category>dataprivacy</category>
      <category>gdpr</category>
    </item>
    <item>
      <title>You Can't Protect What You Can't Find: Detecting and Classifying PII in Data Pipelines</title>
      <dc:creator>Nariman Baubekov</dc:creator>
      <pubDate>Mon, 07 Sep 2026 07:18:48 +0000</pubDate>
      <link>https://dev.to/nbaubek/you-cant-protect-what-you-cant-find-detecting-and-classifying-pii-in-data-pipelines-261f</link>
      <guid>https://dev.to/nbaubek/you-cant-protect-what-you-cant-find-detecting-and-classifying-pii-in-data-pipelines-261f</guid>
      <description>&lt;p&gt;It's 4:47 PM on a Friday. A stakeholder pings you: &lt;em&gt;"Hey, can we get the support tickets table into the warehouse? The CS team wants to chart ticket volume by region."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Sounds harmless. One table, one &lt;code&gt;SELECT *&lt;/code&gt;, done before standup Monday.&lt;/p&gt;

&lt;p&gt;You glance at the schema. Eighteen columns of pure innocence — &lt;code&gt;ticket_id&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt; — and then column nineteen: &lt;code&gt;support_notes&lt;/code&gt;. Free text. Years of customers writing things like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hi, this is Jane Doe, my order #4311 never arrived. I'm at &lt;a href="mailto:jane.doe@example.com"&gt;jane.doe@example.com&lt;/a&gt;, or call me on 555-010-8899. Billing zip is 94110. Also my card ending in 4242 keeps failing??&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Congratulations: your "harmless analytics table" is now one of the most sensitive assets in the company.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part every PII article skips
&lt;/h2&gt;

&lt;p&gt;Google "how to handle PII as a data engineer" and you'll get the same tidy list everywhere: masking, tokenization, hashing, encryption, anonymization. All correct. All useful. And all of them answer a question you can only ask &lt;strong&gt;after&lt;/strong&gt; you know where your PII lives. &lt;code&gt;MASK THE EMAIL COLUMN&lt;/code&gt; is a one-liner. Knowing that &lt;code&gt;contact_ref&lt;/code&gt; is full of emails, that the &lt;code&gt;metadata&lt;/code&gt; JSON blob hides shipping addresses, and that &lt;code&gt;support_notes&lt;/code&gt; contains entire identities — that's the actual job.&lt;/p&gt;

&lt;p&gt;PII is a gas. It expands to fill whatever container you give it, and it escapes through the same few cracks every time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cryptic names&lt;/strong&gt; — &lt;code&gt;x_field_7&lt;/code&gt;, courtesy of an upstream team you've never met&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lying names&lt;/strong&gt; — a column called &lt;code&gt;contact_ref&lt;/code&gt; that is, in fact, emails&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free text&lt;/strong&gt; — customers volunteering their whole identity into a textbox&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema drift&lt;/strong&gt; — a vendor feed that grew three columns overnight, silently. Nobody opens a ticket titled "FYI: new column of passport numbers."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So this series spends its first article on the unfashionable part: &lt;strong&gt;finding and classifying PII&lt;/strong&gt;. Part 2 will pick the protection technique (mask vs hash vs tokenize vs encrypt — with the trade-offs that actually decide it). Part 3 puts the whole thing into a runnable pipeline.&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%2F00clgxsz7ojvpro080hn.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%2F00clgxsz7ojvpro080hn.png" alt="Where this series sits in the pipeline" width="800" height="187"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Parts 2 and 3 build on the two stages this post covers — the ones everyone else skips.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The toy dataset (no real humans involved)
&lt;/h2&gt;

&lt;p&gt;Everything below runs against a deliberately messy &lt;code&gt;customers.csv&lt;/code&gt; that ships with the &lt;a href="https://github.com/nbaubek/devto-articles-repo/tree/main/pii-pipeline" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;. It's generated with Faker, so it contains zero real people — the only thing harmed in writing this article was the CSV's dignity:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Looks innocent?&lt;/th&gt;
&lt;th&gt;Actually contains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;customer_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;a UUID. Genuinely boring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;emails, honestly labeled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;phone numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;first_name&lt;/code&gt;, &lt;code&gt;last_name&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;names&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;zip&lt;/code&gt;, &lt;code&gt;dob&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;sort of&lt;/td&gt;
&lt;td&gt;quasi-identifiers (this gets uncomfortable later)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_ref&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;emails. The column name is a lie&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;support_notes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;free text with full identities&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Setup is two commands, no warehouse, no Docker, no cloud account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;git clone --depth 1 --sparse --filter=blob:none https://github.com/nbaubek/devto-articles-repo
cd devto-articles-repo
git sparse-checkout set pii-pipeline
cd pii-pipeline
uv sync
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now let's climb.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 1 — trust column names (a little)
&lt;/h2&gt;

&lt;p&gt;The cheapest detection you'll ever write: regex over schema names.&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;# src/pii/heuristics.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="n"&gt;PATTERNS&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;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;e[-_]?mail&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;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\bphone\b|\bmobile\b|\btel\b&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;ssn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\bssn\b|social&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;dob&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\bdob\b|birth&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="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first[-_ ]?name|last[-_ ]?name|full[-_ ]?name&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;scan_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;label&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PATTERNS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&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 console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;uv run python &lt;span class="nt"&gt;-m&lt;/span&gt; pii.heuristics
&lt;span class="go"&gt;customer_id    → []
email          → ['email']
phone          → ['phone']
first_name     → ['name']
last_name      → ['name']
dob            → ['dob']
&lt;/span&gt;&lt;span class="gp"&gt;contact_ref    → []        #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;emails live here. Rung 1 is blind.
&lt;span class="gp"&gt;support_notes  → []        #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;...and here.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It catches every honestly-labeled column in about twenty lines, and it's fast enough to run in CI on every schema change. Column-name heuristics are like checking luggage by reading the name tags: quick, cheap, and completely defeated by anyone who labels their suitcase "definitely not bombs."&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 2 — profile the actual content
&lt;/h2&gt;

&lt;p&gt;Fine. Don't ask the schema — ask the data. For each column, compute the fraction of non-null values that &lt;em&gt;look like&lt;/em&gt; PII:&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;-- src/pii/profile_scan.sql (single-column version)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;round&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="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;regexp_matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contact_ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'^[&lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="s1"&gt;.+-]+@[&lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="s1"&gt;-]+&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s1"&gt;[&lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="s1"&gt;.]+$'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="k"&gt;THEN&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;0&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;END&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;AS&lt;/span&gt; &lt;span class="n"&gt;email_ratio&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="s1"&gt;'data/customers.csv'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;contact_ref&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="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo loops this over every column and pattern (email, phone, SSN, credit card). The verdicts from our CSV:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;email_ratio&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;flagged — well yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact_ref&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.97&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;flagged — rung 2 catch!&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;support_notes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.08&lt;/td&gt;
&lt;td&gt;not flagged — rung 2 miss&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;contact_ref&lt;/code&gt; finally gets caught, because content doesn't lie. But &lt;code&gt;support_notes&lt;/code&gt; walks free: only some rows contain emails, they're buried mid-sentence, and a threshold-based regex over prose is brittle by nature.&lt;/p&gt;

&lt;p&gt;Two honest caveats. First, profiling means actually reading the data — run it as a controlled job with proper access, not as an ad-hoc query in the BI tool (that would be detecting PII by leaking PII, a bold strategy). Second, regexes only recognize &lt;em&gt;formatted&lt;/em&gt; identifiers. "Jane Doe of 94110" contains no &lt;code&gt;@&lt;/code&gt; and no dashes. For that, you need something that reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 3 — read the free text (Presidio)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/microsoft/presidio" rel="noopener noreferrer"&gt;Presidio&lt;/a&gt; is Microsoft's open-source PII detector: named-entity recognition plus pattern recognizers, scoring each hit:&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;# src/pii/presidio_scan.py (trimmed)
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;presidio_analyzer&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AnalyzerEngine&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;presidio_analyzer.nlp_engine&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;NlpEngineProvider&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_analyzer&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;AnalyzerEngine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Presidio's default wants the ~800 MB spacy model; we wire the small
&lt;/span&gt;    &lt;span class="c1"&gt;# one so `uv sync` is all the setup a reader needs.
&lt;/span&gt;    &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NlpEngineProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nlp_configuration&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;nlp_engine_name&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;spacy&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;models&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;lang_code&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;en&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;model_name&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;en_core_web_sm&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AnalyzerEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nlp_engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_engine&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="n"&gt;analyzer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_analyzer&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;scan_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;],&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;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&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;for&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;analyzer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it over a real row of our &lt;code&gt;support_notes&lt;/code&gt; and the buried identity surfaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;uv run python &lt;span class="nt"&gt;-m&lt;/span&gt; pii.presidio_scan
&lt;span class="gp"&gt;Hi, this is James Santos — order #&lt;/span&gt;3615 never arrived. I&lt;span class="s1"&gt;'m at williamjohnson@...
&lt;/span&gt;&lt;span class="go"&gt;  EMAIL_ADDRESS  williamjohnson@example.com   1.0
  PERSON         James Santos                 0.85
  PHONE_NUMBER   555-658-7873                 0.4
  UK_NHS         555-658-7873                 1.0    &amp;lt;- same digits, confidently wrong
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole identity in one paragraph, found — and the output above is real, which is exactly why you should read it twice. The email is found at 1.0. The person at 0.85. But the phone number limps in at 0.4, and the &lt;em&gt;same digits&lt;/em&gt; are simultaneously reported as a UK NHS number at 1.0. These scores are a model's opinion, not a fact: recognizers want tuning for your domain, and this is by far the most expensive rung. In practice you don't Presidio-scan every row of every table nightly. You scan &lt;strong&gt;new tables, schema changes, and a sample&lt;/strong&gt; of free-text columns, on a schedule.&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%2F6ev7rmr3i920tjfyxv6g.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%2F6ev7rmr3i920tjfyxv6g.png" alt="The detection ladder" width="800" height="1016"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Each rung catches what escaped the previous one — and all of them react after the fact.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Finding it ≠ knowing how dangerous it is
&lt;/h2&gt;

&lt;p&gt;So the ladder tells you &lt;em&gt;where&lt;/em&gt; PII lives. Classification decides &lt;em&gt;what it means&lt;/em&gt; — and this is the step that actually determines your protection strategy later. Four tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tier 0 — not PII.&lt;/strong&gt; &lt;code&gt;customer_id&lt;/code&gt;, timestamps. Breathe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 1 — direct identifiers.&lt;/strong&gt; Email, SSN, phone, name. Each one identifies a person &lt;em&gt;on its own&lt;/em&gt;. These get the heavy treatment in Part 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 2 — quasi-identifiers.&lt;/strong&gt; ZIP, birth date, gender. Each is boring alone; &lt;em&gt;together&lt;/em&gt; they're a fingerprint. Latanya Sweeney's classic result: ZIP + birth date + gender uniquely identify about &lt;strong&gt;87% of Americans&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 3 — sensitive attributes.&lt;/strong&gt; Health conditions, salary, credentials. Not identifiers, but the payload — the thing you're afraid of revealing &lt;em&gt;about&lt;/em&gt; whoever you re-identify. Our toy &lt;code&gt;customers.csv&lt;/code&gt; doesn't happen to carry one (support tickets rarely do), but the moment a &lt;code&gt;plan_tier&lt;/code&gt; column turns into &lt;code&gt;annual_income&lt;/code&gt; or a &lt;code&gt;notes&lt;/code&gt; field turns into &lt;code&gt;diagnosis&lt;/code&gt;, the classification step is identical — it's the &lt;em&gt;protection&lt;/em&gt; strategy in Part 2 that changes, because a salary figure has no format to mask and nothing to tokenize; it mostly wants access control and aggregation limits, not a transformation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tier 2 deserves a diagram, because this is the one that bites teams who think they're done:&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%2Fij6r6sbt1w73z6s98u8k.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%2Fij6r6sbt1w73z6s98u8k.png" alt="How quasi-identifiers re-identify people" width="800" height="291"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;"We stripped all names and emails" is not the same sentence as "nobody can be identified."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two uncomfortable truths fall out of this, and both shape the rest of the series: stripping direct identifiers from a table does &lt;strong&gt;not&lt;/strong&gt; make it anonymous, and — preview for Part 2 — even a hashed email is still personal data under GDPR. The output of all our detection work is just this file:&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;# src/pii/classifications.yaml&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;customer_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;non_pii&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;         &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;direct&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;         &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;direct&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;first_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;direct&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;last_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;direct&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;zip&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;           &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;quasi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;            &lt;span class="c1"&gt;# dangerous in combination&lt;/span&gt;
  &lt;span class="na"&gt;dob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;           &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;quasi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;contact_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;direct&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;           &lt;span class="c1"&gt;# caught by profiling, not by its name&lt;/span&gt;
  &lt;span class="na"&gt;support_notes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;free_text&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;redact_on_read&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Detection produced a map; this file turns the map into policy. Part 3's pipeline will consume it directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop detecting. Start declaring.
&lt;/h2&gt;

&lt;p&gt;One last twist — and the fourth escape route from the top of this article, the one the ladder itself never catches. Rungs 1 through 3 handle cryptic names, lying names, and free text; &lt;strong&gt;schema drift needs a different kind of defense&lt;/strong&gt;, because a scanner only tells you about columns that already exist. Everything on the ladder is also &lt;strong&gt;reactive&lt;/strong&gt; — PII gets found &lt;em&gt;after&lt;/em&gt; it arrives, maybe months after. Data contracts flip the burden: the producer declares PII tiers as part of the schema, and your pipeline enforces the declaration.&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;# contracts/customers.v1.yaml — owned by the producing team&lt;/span&gt;
&lt;span class="na"&gt;dataset&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;raw.customers&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cs-analytics&lt;/span&gt;
&lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;         &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;pii_tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;direct&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;         &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;pii_tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;direct&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;first_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;pii_tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;direct&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;last_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;pii_tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;direct&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;zip&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;           &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;pii_tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;quasi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;dob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;           &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;pii_tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;quasi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;contact_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;pii_tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;direct&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;support_notes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;pii_tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;free_text&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;redact&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small validator (&lt;code&gt;validate_contract.py&lt;/code&gt; in the repo) reconciles declaration against reality and has exactly three outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Declared and detected&lt;/strong&gt; — fine, proceed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Declared but not detected&lt;/strong&gt; — for direct identifiers, that's a stale (or optimistic) contract; warn loudly. For &lt;code&gt;quasi&lt;/code&gt; and &lt;code&gt;free_text&lt;/code&gt; tiers, the declaration &lt;em&gt;is&lt;/em&gt; the control — the scanner can't check it for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detected but not declared&lt;/strong&gt; — someone added a PII column without declaring it. &lt;strong&gt;Fail the build.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That flips the conversation from "Legal found customer emails in the BI tool" (a genuinely terrible Friday) to "Your PR adds an undeclared column that's 97% emails" (a merely awkward Friday, and it happens at review time, not breach time).&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Protection is a solved problem &lt;em&gt;once you know where PII lives&lt;/em&gt;. Detection is where pipelines actually fail.&lt;/li&gt;
&lt;li&gt;Climb the ladder in order — names, then content, then language — because each rung's cost grows by an order of magnitude.&lt;/li&gt;
&lt;li&gt;Classify into tiers, because "direct identifier" and "quasi-identifier" demand completely different handling. Stripping names is not anonymization.&lt;/li&gt;
&lt;li&gt;Contracts turn detection from an audit into an enforcement point. Shift it left.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full code for this post — messy CSV included — is in the &lt;a href="https://github.com/nbaubek/devto-articles-repo/tree/main/pii-pipeline" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next in the series:&lt;/strong&gt; you have a tier-tagged map of your PII. Now, which weapon? Masking, tokenization, hashing, or encryption — the join-key dilemma (why analytics-friendly hashes are exactly the brute-forceable ones), why your salt doesn't save you, and why a hashed email is still personal data under GDPR.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>python</category>
      <category>dataprivacy</category>
      <category>gdpr</category>
    </item>
    <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 &lt;a href="https://github.com/nbaubek/devto-articles-repo/blob/main/kimball-series/tabby_contracts/schema.sql" rel="noopener noreferrer"&gt;repo&lt;/a&gt; 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 &lt;a href="https://github.com/nbaubek/devto-articles-repo/blob/main/kimball-series/meadowlark_health/solutions.sql" rel="noopener noreferrer"&gt;solutions.sql&lt;/a&gt; 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 &lt;a href="https://github.com/nbaubek/devto-articles-repo/blob/main/kimball-series/crate_expectations/solutions.sql" rel="noopener noreferrer"&gt;solutions.sql&lt;/a&gt; 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 &lt;a href="https://github.com/nbaubek/devto-articles-repo/blob/main/kimball-series/README.md" rel="noopener noreferrer"&gt;repo&lt;/a&gt; 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 &lt;a href="https://github.com/nbaubek/devto-articles-repo/blob/main/kimball-series/saas_startup/solutions.sql" rel="noopener noreferrer"&gt;solutions.sql&lt;/a&gt; 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 &lt;a href="https://github.com/nbaubek/devto-articles-repo/blob/main/kimball-series/README.md" rel="noopener noreferrer"&gt;repo&lt;/a&gt; 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 &lt;a href="https://github.com/nbaubek/devto-articles-repo/blob/main/kimball-series/README.md" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; 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 &lt;a href="https://github.com/nbaubek/devto-articles-repo/blob/main/kimball-series/coffee_shop/queries.sql" rel="noopener noreferrer"&gt;queries.sql&lt;/a&gt; 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 &lt;a href="https://github.com/nbaubek/devto-articles-repo/blob/main/kimball-series/coffee_shop/exercises.sql" rel="noopener noreferrer"&gt;exercises.sql&lt;/a&gt; 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%2F0ape7to40tzx0u3pye3d.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%2F0ape7to40tzx0u3pye3d.png" alt="Code testing with pytest vs. data testing with dbt test, Great Expectations, or Soda" width="800" height="319"&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>
  </channel>
</rss>
