<?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: Steven Blough</title>
    <description>The latest articles on DEV Community by Steven Blough (@stevenblough).</description>
    <link>https://dev.to/stevenblough</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%2F1427921%2F672e20ae-ab92-4c13-a23a-27099f4de799.jpeg</url>
      <title>DEV Community: Steven Blough</title>
      <link>https://dev.to/stevenblough</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stevenblough"/>
    <language>en</language>
    <item>
      <title>Row and Field-Level Data Provenance: Why It's Worth the Pain (and Where the Pain Is)</title>
      <dc:creator>Steven Blough</dc:creator>
      <pubDate>Fri, 31 Jul 2026 15:37:17 +0000</pubDate>
      <link>https://dev.to/stevenblough/row-and-field-level-data-provenance-why-its-worth-the-pain-and-where-the-pain-is-mih</link>
      <guid>https://dev.to/stevenblough/row-and-field-level-data-provenance-why-its-worth-the-pain-and-where-the-pain-is-mih</guid>
      <description>&lt;p&gt;Most "data lineage" you've seen answers a schema question: &lt;em&gt;table B comes from table A&lt;/em&gt;, or &lt;em&gt;column &lt;code&gt;B.total&lt;/code&gt; comes from columns &lt;code&gt;A.price&lt;/code&gt; and &lt;code&gt;A.qty&lt;/code&gt;&lt;/em&gt;. That's genuinely useful, and tools like OpenLineage do it well. But notice what it &lt;em&gt;doesn't&lt;/em&gt; tell you: it says which columns &lt;strong&gt;can&lt;/strong&gt; influence an output. It never says which &lt;strong&gt;values actually did&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That gap is the whole subject of this post. I built a small, self-contained reference pipeline that captures provenance at the &lt;strong&gt;row and field level&lt;/strong&gt; — "the value in this destination row, this field, was computed from &lt;em&gt;these specific&lt;/em&gt; source &lt;code&gt;(row, field)&lt;/code&gt; pairs" — and I want to walk through two things: &lt;strong&gt;why&lt;/strong&gt; you'd ever want provenance at that granularity, and &lt;strong&gt;why it's genuinely hard&lt;/strong&gt; once you commit to it.&lt;/p&gt;

&lt;p&gt;Repo (dbt-core + DuckDB, no server, no cloud, runs on a clean checkout): &lt;strong&gt;&lt;a href="https://github.com/stevenblough/row-level-prov" rel="noopener noreferrer"&gt;https://github.com/stevenblough/row-level-prov&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The one distinction everything follows from
&lt;/h2&gt;

&lt;p&gt;Here's the sentence the entire project turns on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Column-level lineage is a schema-sized, static fact you can derive from code. Value-level provenance is a data-sized, dynamic fact you must capture at execution.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Put it in complexity terms and the consequences become obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Column lineage is &lt;strong&gt;&lt;code&gt;O(schema)&lt;/code&gt;&lt;/strong&gt;. It scales with how many columns you have. You can compute it by parsing SQL, offline, without ever looking at a single row.&lt;/li&gt;
&lt;li&gt;Value provenance is &lt;strong&gt;&lt;code&gt;O(rows × fan-in)&lt;/code&gt;&lt;/strong&gt;. It scales with your data volume &lt;em&gt;times&lt;/em&gt; how many source values feed each output value. It does not exist anywhere until the query runs, and it can only be captured &lt;em&gt;there&lt;/em&gt;, piggybacked on the query that actually produced the values.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You cannot "reconstruct" value provenance later by re-querying the sources — the moment the source changes, you'd reconstruct a different answer than what really happened. That single exponent change (&lt;code&gt;schema&lt;/code&gt; → &lt;code&gt;rows × fan-in&lt;/code&gt;) is why value-level provenance has an entire class of problems that column lineage never faces.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why bother? The reasons for this level of granularity
&lt;/h2&gt;

&lt;p&gt;Granularity is expensive, so it has to earn its place. Here's where row/field provenance pays for itself:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Answering "why is &lt;em&gt;this number&lt;/em&gt; wrong?" in one hop.&lt;/strong&gt;&lt;br&gt;
A daily site summary reads 4.2 when a domain expert insists it should be 4.1. With table or column lineage you learn the summary came from the readings table — now go find the needle yourself. With value provenance you get the &lt;em&gt;exact&lt;/em&gt; set of contributing readings, each addressable, each with its own status. Debugging goes from an investigation to a lookup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Corrections and their blast radius.&lt;/strong&gt;&lt;br&gt;
A sensor re-submits a reading with a corrected value. Which downstream aggregates are now stale? Column lineage says "anything computed from readings" — technically true, uselessly broad. Value provenance says &lt;em&gt;these three daily summaries and no others&lt;/em&gt;, because it knows which specific rows fed which specific outputs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Compliance and the right to explanation.&lt;/strong&gt;&lt;br&gt;
"Show me every source value that fed this figure in the regulatory report" is a per-value question. So is GDPR-style "where did this person's data flow?" These are not answerable at column granularity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Trust as a first-class, &lt;em&gt;visible&lt;/em&gt; property.&lt;/strong&gt;&lt;br&gt;
This one is underrated. The most dangerous failure in a provenance system isn't a missing trace — it's a &lt;strong&gt;confident, plausible, wrong&lt;/strong&gt; trace. If a trace can silently be wrong, it's worse than no trace at all, because people act on it. So every node and edge carries a &lt;code&gt;verification_status&lt;/code&gt;, and any trace that crosses an unverified or flagged edge &lt;em&gt;says so&lt;/em&gt; rather than returning a silently confident answer. That converts undetected-wrongness into visible-uncertainty — which is the single most valuable property if anyone downstream is making decisions on your lineage.&lt;/p&gt;


&lt;h2&gt;
  
  
  The challenges (this is the interesting part)
&lt;/h2&gt;

&lt;p&gt;Committing to value-level provenance signs you up for a specific set of hard problems. Here are the ones that shaped the build.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Storage blowup — the defining scaling problem
&lt;/h3&gt;

&lt;p&gt;A daily summary over ~1,440 readings, times millions of summaries, is &lt;strong&gt;billions of edges&lt;/strong&gt;. Do the naive thing — one edge per contributor — and your provenance store dwarfs the data it describes by orders of magnitude. This is the problem column-level tools &lt;em&gt;never&lt;/em&gt; hit, and it's a direct consequence of the &lt;code&gt;rows × fan-in&lt;/code&gt; exponent.&lt;/p&gt;

&lt;p&gt;The insight that rescues it: contributing sets are usually &lt;strong&gt;not arbitrary&lt;/strong&gt;. They're the result of a predicate — &lt;code&gt;WHERE site_id = X AND reading_date = Y&lt;/code&gt; — over a partition. So you store &lt;strong&gt;the predicate, not the enumeration&lt;/strong&gt;: &lt;code&gt;O(1)&lt;/code&gt; instead of &lt;code&gt;O(n)&lt;/code&gt;. In the reference build the aggregate's contributors are predicate-encoded — one edge carrying the equality and a checksum — and the individual member rows are only &lt;em&gt;materialized on demand&lt;/em&gt; when someone actually runs a trace query.&lt;/p&gt;

&lt;p&gt;Concretely, instead of ~1,440 edges the aggregate emits &lt;strong&gt;one predicate edge per output field&lt;/strong&gt; (distilled from the mart model — the real thing is a &lt;code&gt;LIST(STRUCT(...))&lt;/code&gt; dbt shreds into the PROV tables):&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;-- write side: one O(1) reference instead of O(n) enumeration&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s1"&gt;'target_field'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="s1"&gt;'avg_temp_c'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'source_dataset'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'int_calibrated_readings'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'set_encoding'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="s1"&gt;'predicate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'set_definition'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="s1"&gt;'source_dataset'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'int_calibrated_readings'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s1"&gt;'predicate'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s1"&gt;'site_id'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="n"&gt;site_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="s1"&gt;'reading_date'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reading_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="s1"&gt;'as_of_txn'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="s1"&gt;'s transaction instant&amp;gt; }
  },
  '&lt;/span&gt;&lt;span class="n"&gt;set_cardinality&lt;/span&gt;&lt;span class="s1"&gt;': card,   -- how many rows it stood for
  '&lt;/span&gt;&lt;span class="n"&gt;set_checksum&lt;/span&gt;&lt;span class="s1"&gt;':    ck      -- md5 over the ACTUAL members, at execution:
}                            --   md5(string_agg(row_id, '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;' order by row_id))
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;set_checksum&lt;/code&gt; is the crux. But — and this is the subtle part — &lt;strong&gt;a predicate is only sound if the data it ranges over is immutable or versioned.&lt;/strong&gt; If the underlying partition changes after you recorded the predicate, re-evaluating it at trace time gives a &lt;em&gt;different&lt;/em&gt; set than was actually used. Compression stopped being a mere optimization and became a &lt;strong&gt;correctness-critical component&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So expansion is &lt;em&gt;as-of aware&lt;/em&gt;: it pins the predicate to the transaction-time snapshot it was captured under, and re-checksums what it reconstructs (distilled from &lt;code&gt;prov/_common.py&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;expand_predicate&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="n"&gt;set_definition&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;spec&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;set_definition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dataset&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_dataset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;predicate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;predicate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;as_of&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;predicate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;as_of_txn&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="c1"&gt;# the captured snapshot
&lt;/span&gt;
    &lt;span class="n"&gt;where&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; AND &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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;k&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;predicate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sql&lt;/span&gt;   &lt;span class="o"&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 row_id FROM &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; r&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;as_of&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# keep only rows whose bitemporal belief interval contains the coordinate,
&lt;/span&gt;        &lt;span class="c1"&gt;# so a stored predicate reproduces its EXACT captured membership even
&lt;/span&gt;        &lt;span class="c1"&gt;# after the source has grown.
&lt;/span&gt;        &lt;span class="n"&gt;sql&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; JOIN prov_bitemporal b ON b.entity_id = r.row_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; AND b.txn_from &amp;lt;= ? AND (b.txn_to IS NULL OR b.txn_to &amp;gt; ?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;members&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="mi"&gt;0&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&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="n"&gt;sql&lt;/span&gt; &lt;span class="o"&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 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="si"&gt;}&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;fetchall&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;

    &lt;span class="n"&gt;checksum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;md5&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;         &lt;span class="c1"&gt;# SAME expression as capture
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;),&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;members&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;checksum&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At trace time you compare that recomputed &lt;code&gt;checksum&lt;/code&gt; against the stored &lt;code&gt;set_checksum&lt;/code&gt;. A mismatch means the set drifted — so you get a &lt;strong&gt;detected error, not a silently wrong trace.&lt;/strong&gt; Two invariants make this honest: the checksum is computed from execution-time members (never from the predicate's own re-expansion — that would let a wrong predicate confirm itself), and drift is &lt;em&gt;detected&lt;/em&gt; rather than assumed away.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Capture correctness — and "who verifies the verifier?"
&lt;/h3&gt;

&lt;p&gt;There are two completely different ways capture can fail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Total silence&lt;/strong&gt; — no event emitted at all. This is an &lt;em&gt;absence&lt;/em&gt;, and you catch it by measuring completeness against an expectation (a root registry / orphan sweep).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial or wrong capture&lt;/strong&gt; — an event &lt;em&gt;is&lt;/em&gt; emitted, but the mapping is wrong. This is a &lt;em&gt;present-but-false&lt;/em&gt; record, and it is the more dangerous class, because it produces confident false traces.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A system that only checks "did an event show up?" catches silence and is &lt;strong&gt;blind to wrong mappings&lt;/strong&gt;. So you need at least one detector rooted in an &lt;em&gt;independent oracle&lt;/em&gt; — ground truth the capture layer never touches. In the build, one detector reconciles event counts against raw row counts (pure cardinality, shares no code with the capture logic), and another re-derives each model's row-correlation rule &lt;strong&gt;from the compiled SQL&lt;/strong&gt; and diffs it against what was emitted. That second one catches the nastiest fault: an edge re-pointed at the &lt;em&gt;wrong&lt;/em&gt; source row while keeping the count correct — invisible to every count-based check.&lt;/p&gt;

&lt;p&gt;The honest residual: a bug that is &lt;strong&gt;common-mode&lt;/strong&gt; to both the capture layer &lt;em&gt;and&lt;/em&gt; its oracle is undetectable. That's the argument for anchoring at least one detector in something as dumb and independent as row counts. And the capture layer certifies &lt;em&gt;nothing&lt;/em&gt; about itself — capture can only ever write &lt;code&gt;unverified&lt;/code&gt;; it is structurally incapable of writing &lt;code&gt;verified&lt;/code&gt;. Only the separate detector pass promotes. A capture path that certifies its own output is "a report written by the suspect."&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Identity — the discipline no schema can enforce
&lt;/h3&gt;

&lt;p&gt;Every edge needs a stable anchor on both ends, and it &lt;strong&gt;cannot&lt;/strong&gt; be the business primary key (PKs are reused, composite, sometimes absent, and — critically — the &lt;em&gt;same&lt;/em&gt; PK is legitimately re-submitted with new content during a correction, which is exactly when you need to tell versions apart). So identity is a &lt;strong&gt;surrogate &lt;code&gt;row_id&lt;/code&gt; minted at ingestion&lt;/strong&gt;, independent of content and business key, and &lt;strong&gt;preserved unmodified across every storage boundary.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That preservation is a &lt;em&gt;discipline&lt;/em&gt;, not something the schema can enforce. Any transform that joins, unions, or repartitions and quietly re-mints the id &lt;strong&gt;silently severs the chain&lt;/strong&gt;. The only defense is an id-preservation test that catches it after the fact. And when a transform genuinely &lt;em&gt;does&lt;/em&gt; merge many rows into one (an N:1 aggregate), it &lt;em&gt;must&lt;/em&gt; mint a new id — so "preserve the id" and "mint a new id" both have to be deliberate, reviewable choices, never accidents.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Temporal correctness — corrections, late arrivals, bitemporality
&lt;/h3&gt;

&lt;p&gt;Real data isn't loaded once. Corrections arrive (same key, new value, newer timestamp). Late arrivals show up (a reading older than what you've already processed). Duplicates get redelivered. Each needs distinct handling — a late arrival is &lt;strong&gt;tagged&lt;/strong&gt;, not dropped and not overwritten; a correction creates a new version that &lt;strong&gt;supersedes&lt;/strong&gt; the old one — and the hard part is &lt;strong&gt;propagation&lt;/strong&gt;: a corrected reading has to trigger re-aggregation of exactly the summaries it touched, and the new summary has to supersede the old. Under-scope that and you get either over-recomputation (correct but expensive) or missed recomputation (cheap but wrong).&lt;/p&gt;

&lt;p&gt;Underneath, corrections and late arrivals are both &lt;strong&gt;bitemporal&lt;/strong&gt;: there are two time axes — when the event &lt;em&gt;happened&lt;/em&gt; (valid time) and when the system &lt;em&gt;learned&lt;/em&gt; it (transaction time). A famous edge case makes the axes unavoidable: a &lt;strong&gt;zero-delta correction&lt;/strong&gt;, where a value is re-submitted &lt;em&gt;unchanged&lt;/em&gt;. Membership doesn't move, the rounded average doesn't move — every value-based check sees nothing. But it's a distinct transaction-time version, so with a proper bitemporal model it's still distinguishable. The reference build carries valid &lt;em&gt;and&lt;/em&gt; transaction time as first-class coordinates so you can ask "what did we believe as-of last Tuesday?" and get the answer that was true then, not the answer that's true now.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Immutability vs. the right to erasure
&lt;/h3&gt;

&lt;p&gt;The provenance store is append-only and immutable — that's what makes it trustworthy &lt;em&gt;and&lt;/em&gt; what makes predicate encoding sound. But immutability collides head-on with GDPR Article 17 erasure. If a captured value is personal data, "delete this person's data" and "never mutate the log" are in direct conflict.&lt;/p&gt;

&lt;p&gt;The cleanest reconciliation is &lt;strong&gt;reference, don't copy&lt;/strong&gt;: the graph stores row/field &lt;strong&gt;ids and addresses&lt;/strong&gt;, not the values themselves. Delete the underlying value and the edges survive but dangle — the trace &lt;em&gt;structure&lt;/em&gt; is intact, the PII is gone. (Crypto-shredding and tombstoning are the other options, each with its own regulatory trade-offs.) This isn't a storage trick; it's a governance decision about what the graph is even &lt;em&gt;allowed&lt;/em&gt; to contain, and it's a strong argument for an address-only graph.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the reference build actually proves
&lt;/h2&gt;

&lt;p&gt;I want to be precise about scope, because "provenance" invites scope creep. The build is deliberately small — &lt;strong&gt;dbt-core + DuckDB + one SQL parser, no Docker, no server, no network at run time&lt;/strong&gt; — and it exists to demonstrate exactly one thing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Value-level provenance can be captured faithfully at execution time, and gaps in capture are &lt;em&gt;detectable&lt;/em&gt; rather than silent.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Concretely, it runs over two genuine dbt loads (so incremental propagation and corrections are exercised for real, not sidestepped by a single rebuild), captures provenance for ~20k readings across a 1:1 → M:N join → N:1 aggregate DAG, encodes the high-fan-in aggregate as predicates, and runs &lt;strong&gt;five independent detectors&lt;/strong&gt; — completeness, cardinality reconciliation, temporal supersession, a compiled-SQL diff audit, and a bitemporal soundness check. There's a fault-injection suite that deliberately breaks capture in seven ways and asserts the right detector catches each one (and, honestly, asserts which faults the count-based detectors stay &lt;em&gt;blind&lt;/em&gt; to — the blind spots are real and named, not hidden).&lt;/p&gt;

&lt;p&gt;The W3C PROV data model (Entity / Activity / Agent) is the backbone, which keeps "the transformation run" cleanly separate from "its output" — a distinction that sounds pedantic until you try to model supersession without it and everything tangles.&lt;/p&gt;

&lt;p&gt;If any of this is relevant to your own pipelines — or you just want to see how far you can push value-level lineage in a few hundred lines of dbt and Python — the whole thing is here, and &lt;code&gt;make demo&lt;/code&gt; runs the full chain green on a clean checkout:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://github.com/stevenblough/row-level-prov" rel="noopener noreferrer"&gt;https://github.com/stevenblough/row-level-prov&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The two design docs in the repo go much deeper than this post: one is the concrete build spec, the other maps the full problem space — including the parts deliberately left for later (nested/array identity, cross-engine capture, cold archival). Provenance is a rabbit hole; this is a map of it with one vertical slice actually dug.&lt;/p&gt;

</description>
      <category>lineage</category>
      <category>provenance</category>
      <category>dataengineering</category>
      <category>data</category>
    </item>
    <item>
      <title>Agile or Winging It</title>
      <dc:creator>Steven Blough</dc:creator>
      <pubDate>Wed, 25 Mar 2026 09:17:43 +0000</pubDate>
      <link>https://dev.to/stevenblough/agile-or-winging-it-1ea5</link>
      <guid>https://dev.to/stevenblough/agile-or-winging-it-1ea5</guid>
      <description>&lt;h2&gt;
  
  
  Hey Agile Folks, We Need to Talk About Planning
&lt;/h2&gt;

&lt;p&gt;I've been in and around software teams long enough to watch Agile go from a genuinely good idea to something that gets used as a hall pass for not thinking things through. And I think it's worth having an honest conversation about it, because I've seen it from both sides of the fence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem Nobody Wants to Say Out Loud
&lt;/h2&gt;

&lt;p&gt;Here's what I keep running into. A team says they're "doing Agile," and what they really mean is they skipped the part where you sit down and figure out what you're actually building and why. No requirements. No discovery. No real conversation with the people who'll use the thing. Just, "Let's start sprinting and we'll figure it out."&lt;/p&gt;

&lt;p&gt;A 2024 study by Engprax and J.L. Partners surveyed 600 software engineers in the US and UK and found that projects with documented requirements before development began were 50% more likely to succeed. Projects with &lt;em&gt;clear&lt;/em&gt; requirements were 97% more likely to succeed. That's not a rounding error. That's a wake-up call.&lt;/p&gt;

&lt;p&gt;The Agile Manifesto says to value "responding to change over following a plan." But folks, read that again. It says &lt;em&gt;over&lt;/em&gt;, not &lt;em&gt;instead of&lt;/em&gt;. You still need a plan. You just hold it loosely. Somewhere along the way, a whole lot of teams heard "don't plan" and ran with it, and honestly, it shows.&lt;/p&gt;

&lt;p&gt;I've watched teams burn months of budget building the wrong thing because nobody stopped to ask a few basic questions up front. As one practitioner put it, if you skip upfront planning, you might not learn enough about what you're taking on until you're mid-stream and expectations are already set. By then you're in trouble and everybody knows it.&lt;/p&gt;

&lt;h2&gt;
  
  
  But Hold On, It's Not That Simple
&lt;/h2&gt;

&lt;p&gt;Now, before the Agile coaches come for me, let me be fair. There's good reason the industry moved this direction.&lt;/p&gt;

&lt;p&gt;The Standish Group has studied over 50,000 IT projects across 25 years. Their 2020 CHAOS report found that Agile projects are roughly three times more likely to succeed than traditional Waterfall projects, and Waterfall projects are twice as likely to fail outright. That's a mountain of data, and you can't just wave it away.&lt;/p&gt;

&lt;p&gt;A meta-analysis of 25 peer-reviewed studies found that iterative approaches deliver 25 to 28 percent faster time-to-market than linear processes. The key insight was that iterative methods don't skip planning. They distribute it throughout the work instead of front-loading it all into a phase that's outdated by the time you finish writing it up.&lt;/p&gt;

&lt;p&gt;And that's the real point. The original Agile thinkers were obsessed with design quality. Robert Martin's foundational Agile book was mostly about SOLID principles, design patterns, and test-driven development. Martin Fowler is synonymous with thoughtful software architecture. These folks weren't saying "don't think." They were saying "don't pretend you can think of everything before you start."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I Come Down
&lt;/h2&gt;

&lt;p&gt;The research and my own experience both point to the same place. You need "just enough" upfront work to establish a shared understanding of what you're building, who it's for, and what the big risks are. Domain modeling, architecture decisions, basic user research. Then you iterate from there.&lt;/p&gt;

&lt;p&gt;The problem isn't Agile. The problem is lazy Agile. It's using the framework as cover for skipping the hard work of understanding your problem space. "We don't need a test team because we're Agile" isn't a principle. It's a budget cut wearing a hoodie.&lt;/p&gt;

&lt;p&gt;Good Agile teams plan. They just plan differently. They plan enough to set a direction, then they learn and adjust as they go. Bad Agile teams don't plan at all and call it a methodology.&lt;/p&gt;

&lt;p&gt;If your team can't answer "what problem are we solving and for whom" before the first sprint starts, you're not being Agile. You're just winging it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: The Engprax study was conducted to promote a competing methodology and should be read with that context in mind.&lt;/p&gt;

&lt;p&gt;Sources: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Engprax/J.L. Partners survey (2024)&lt;/li&gt;
&lt;li&gt;Standish Group CHAOS Reports (1994-2020)&lt;/li&gt;
&lt;li&gt;meta-analysis of 25 peer-reviewed studies on iterative vs. linear change management (Rietze et al., 2022)&lt;/li&gt;
&lt;li&gt;Digital.ai State of Agile Reports&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>discuss</category>
      <category>management</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Build with Intention: The Case for Synthetic Data</title>
      <dc:creator>Steven Blough</dc:creator>
      <pubDate>Thu, 19 Mar 2026 11:55:39 +0000</pubDate>
      <link>https://dev.to/stevenblough/build-with-intention-the-case-for-synthetic-data-53g</link>
      <guid>https://dev.to/stevenblough/build-with-intention-the-case-for-synthetic-data-53g</guid>
      <description>&lt;p&gt;Every development team eventually confronts the same problem: the data needed to build and test a system is either unavailable, sensitive, incomplete, or all three. The instinctive solution is to copy some production data, sanitize the obvious fields, and move on. It is understandable. It is also, in most cases, the wrong answer. A more deliberate approach, one grounded in understanding the business domain and constructing data from first principles, produces better software and fewer surprises in production.&lt;/p&gt;

&lt;p&gt;This is the argument for synthetic data as a primary development and testing strategy, with replicated production data reserved for the specific, bounded purpose it actually serves: final verification that nothing was missed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Foundation: Understanding Precedes Generation
&lt;/h2&gt;

&lt;p&gt;There is a persistent myth that synthetic data is technically difficult or that it requires specialized AI tooling before teams can get started. This misunderstands what synthetic data actually demands. The hard part is not the generation; it is the understanding that must precede it.&lt;/p&gt;

&lt;p&gt;If an engineering team can articulate, precisely, what their system does with data: what a valid order looks like, what distinguishes an active customer from a lapsed one, what the lifecycle of a financial transaction is from initiation to settlement, then creating synthetic records that represent those concepts is straightforward. The domain knowledge is the hard work. The data generation follows naturally from it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Understanding the source data, its characteristics, distributions, and dependencies, is the essential prerequisite to generating synthetic data that maintains the statistical properties necessary for valid testing."&lt;/p&gt;

&lt;p&gt;— Tonic.ai, Guide to Synthetic Test Data Generation&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This observation has a useful corollary: if a team struggles to define what valid synthetic data should look like, that struggle is a signal. It reveals gaps in the team's understanding of the domain, gaps that will produce bugs regardless of the testing approach used. Forcing that conversation early, during data design, is itself a quality-improvement activity.&lt;/p&gt;

&lt;p&gt;Rule-based synthesis (generating records according to explicit business rules, ranges, and relationships) is the natural starting point. For most transactional business domains, it is also sufficient. More sophisticated statistical or ML-based generation adds value later, when fidelity to complex distributions matters. But the entry point is simply: know your domain, codify its rules, generate records that obey them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cause and Effect: Designing Tests That Actually Test
&lt;/h2&gt;

&lt;p&gt;The most powerful characteristic of synthetic data is one that is rarely discussed: it enables genuine experimental design. When you construct data rather than inherit it, you control the inputs precisely. That control is the precondition for meaningful testing.&lt;/p&gt;

&lt;p&gt;Consider what it means to test a refund processing workflow. With production-derived data, you find refunds that happened to exist in the snapshot. You test against the outcomes that were already embedded in that data. With synthetic data, you construct the exact scenario you intend to test: a refund requested after the return window closes, a partial refund on an order with a promotional discount, a refund on a subscription that has already renewed. Each scenario has a known input and an expected output. The test is a controlled experiment, not an archaeological dig.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario Coverage
&lt;/h3&gt;

&lt;p&gt;This matters enormously for edge cases and failure paths. Production data snapshots reflect what has already happened: the distribution of events that occurred in the past under real conditions. They systematically under-represent rare events: the unusual transaction pattern, the concurrent write conflict, the workflow state that only arises when three conditions coincide. These are precisely the cases that cause production incidents.&lt;/p&gt;

&lt;p&gt;Synthetic data inverts this. You can generate exactly as many rare-event scenarios as you need, not because they happen frequently in production, but because you have decided they are important to test. The frequency of a scenario in a test suite should reflect its risk and complexity, not its historical prevalence in production data.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem with Production Snapshots
&lt;/h2&gt;

&lt;p&gt;A replicated subset of production data feels safe because it is real. That intuition is worth examining. Real data carries real distributions, real constraints, and real history. But those properties are exactly what makes it a poor primary testing tool, not a good one.&lt;/p&gt;

&lt;h3&gt;
  
  
  What production snapshots miss
&lt;/h3&gt;

&lt;p&gt;The most significant limitation is coverage of critical business events. A snapshot taken on any given day captures a cross-section of normal operations. Month-end close behavior, fiscal year rollovers, promotional surge processing, first-time-purchaser workflows, and account reactivation paths: these events happen infrequently and are statistically unlikely to be well-represented in any particular snapshot. Yet these are precisely the flows that accumulate the most technical debt and surface the most defects.&lt;/p&gt;

&lt;p&gt;A related problem is that production data encodes past behavior, not future requirements. When a business adds a new product category, changes its pricing model, or enters a new market, the production snapshot contains no records of these new patterns. The team is forced to either hand-craft a handful of test records (ad hoc and not versioned) or test with data that does not reflect the scenarios the new code must handle.&lt;/p&gt;

&lt;p&gt;Finally, production data is bounded by its own scale. A subset that represents normal daily volume tells you little about behavior under peak load, bulk import operations, or the long-tail of a large customer's data set. The snapshot is, by definition, a sample; and samples are not the right tool for performance and scalability testing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Replicated production data systematically under-represents the rare, high-risk events most likely to cause production incidents: the precise scenarios a test suite most needs to cover."&lt;/p&gt;

&lt;p&gt;— Engineering Practice Observation&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Scalability: Data on Demand
&lt;/h2&gt;

&lt;p&gt;One of synthetic data's practical advantages is deceptively simple: you can have as much of it as you need, in exactly the shape you need, when you need it. This sounds obvious, but its implications run deep through a development workflow.&lt;/p&gt;

&lt;p&gt;Performance testing requires volumes that no sanitized production subset can provide. Load simulations, database query optimization, and index strategy validation all depend on data at production scale or beyond. Synthetic data generators can produce millions of statistically coherent records in minutes. The same specification that generates a thousand records for a developer's local environment generates ten million for a stress test, with no new configuration and no waiting for a DBA to provision a larger snapshot.&lt;/p&gt;

&lt;p&gt;This scalability also applies along a different axis: time. Development teams can generate fresh data sets for every test run, eliminating the accumulated drift that affects shared test databases. Tests that depend on specific data states do not corrupt the environment for subsequent runs. Every pipeline starts clean. The feedback loop tightens because the test environment is deterministic.&lt;/p&gt;

&lt;p&gt;For teams working in CI/CD pipelines, this is not a convenience; it is an architectural requirement. A test environment that depends on a pre-provisioned, manually refreshed copy of production data is a bottleneck with a scheduled maintenance window. A test environment backed by a data generation specification is a pipeline input that scales horizontally.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Recommended Pipeline: Synthetic First, Production Last
&lt;/h2&gt;

&lt;p&gt;This is not an argument for abandoning production data in testing entirely. It is an argument for placing it correctly in the pipeline: at the end, as final verification, rather than at the beginning, as the primary test environment.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1:&lt;/strong&gt; Domain Modeling &amp;amp; Spec&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 2:&lt;/strong&gt; Synthetic Data Generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 3:&lt;/strong&gt; Scenario &amp;amp; Edge Case Testing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 4:&lt;/strong&gt; Scale &amp;amp; Performance Runs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 5:&lt;/strong&gt; Final Verification with Masked Production Data&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The recommended workflow proceeds as follows. Development and unit testing use fully synthetic data, generated from domain specifications. Integration and system testing use synthetic data sized and shaped to the scenario requirements. Performance and load testing use synthetic data at production scale or beyond. Only the final pre-release verification stage (the stage intended to confirm that the real-world data distribution does not contain surprises the synthetic specification missed) uses a masked or anonymized production subset.&lt;/p&gt;

&lt;p&gt;This final verification stage is legitimate and important. No domain specification is perfect. Production data will occasionally reveal a pattern or relationship that the synthetic specification did not capture: an unusual character encoding in a legacy customer record, a historical data migration artifact, an edge case in a third-party data feed. The production subset exists to catch those cases, not to serve as the primary testing substrate for all the cases that were already well-understood.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compliance as a benefit, not an afterthought
&lt;/h3&gt;

&lt;p&gt;This pipeline structure also resolves the compliance problem that haunts every team that relies on production data for development. Synthetic data does not contain personally identifiable information, protected health records, financial account details, or any other regulated data class, because it was never derived from real individuals. Development environments built on synthetic data satisfy GDPR, HIPAA, PCI-DSS, and similar frameworks structurally, without depending on masking pipelines that require ongoing maintenance and audit. The compliance posture is embedded in the architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tooling: A Mature Ecosystem
&lt;/h2&gt;

&lt;p&gt;Teams considering this approach will find the tooling landscape well-developed. The choice of tool depends on data type, scale requirements, and team preferences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tonic.ai&lt;/strong&gt; — Structured and unstructured synthesis; strong referential integrity preservation for relational databases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gretel.ai&lt;/strong&gt; — Developer-first APIs for generation, transformation, and privacy at scale&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MOSTLY AI&lt;/strong&gt; — Privacy-preserving synthesis with fairness controls; strong for regulated data sharing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic Data Vault (SDV)&lt;/strong&gt; — Open-source Python ecosystem for tabular, relational, and time-series data; widely used in enterprise and academia&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;K2View&lt;/strong&gt; — Enterprise-grade entity-based synthesis combined with test data management and masking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mockaroo&lt;/strong&gt; — Lightweight, schema-driven generation for smaller-scale development use cases; accessible free tier&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For teams with primarily tabular, rule-governed data (the common case in transactional business systems), open-source options like SDV or direct programmatic generation using libraries like Python's &lt;code&gt;Faker&lt;/code&gt; and &lt;code&gt;factory_boy&lt;/code&gt; are often sufficient to start. The investment in domain specification pays dividends regardless of the generation mechanism used.&lt;/p&gt;




&lt;h2&gt;
  
  
  Comparison: Approaches at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Synthetic Data&lt;/th&gt;
&lt;th&gt;Production Subset&lt;/th&gt;
&lt;th&gt;Hand-crafted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Privacy &amp;amp; compliance&lt;/td&gt;
&lt;td&gt;✓ Structural&lt;/td&gt;
&lt;td&gt;△ Requires masking&lt;/td&gt;
&lt;td&gt;✓ If no real data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge case coverage&lt;/td&gt;
&lt;td&gt;✓ By design&lt;/td&gt;
&lt;td&gt;✗ Historically rare&lt;/td&gt;
&lt;td&gt;△ Labor-intensive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scalability&lt;/td&gt;
&lt;td&gt;✓ On demand&lt;/td&gt;
&lt;td&gt;△ Snapshot-bounded&lt;/td&gt;
&lt;td&gt;✗ Not practical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Controlled cause &amp;amp; effect&lt;/td&gt;
&lt;td&gt;✓ Precise&lt;/td&gt;
&lt;td&gt;✗ Inherited state&lt;/td&gt;
&lt;td&gt;✓ Precise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-world distribution&lt;/td&gt;
&lt;td&gt;△ Approximated&lt;/td&gt;
&lt;td&gt;✓ Actual&lt;/td&gt;
&lt;td&gt;✗ Minimal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD integration&lt;/td&gt;
&lt;td&gt;✓ Native&lt;/td&gt;
&lt;td&gt;✗ Refresh bottleneck&lt;/td&gt;
&lt;td&gt;△ Fragile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New feature coverage&lt;/td&gt;
&lt;td&gt;✓ Specifiable&lt;/td&gt;
&lt;td&gt;✗ Not yet in data&lt;/td&gt;
&lt;td&gt;△ Manual effort&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  A Closing Argument
&lt;/h2&gt;

&lt;p&gt;The teams that do this well share a common trait: they have invested in understanding their domain deeply enough to specify it. That investment is not a cost unique to synthetic data; it is a cost that any team building reliable software must eventually pay. The difference is whether that investment happens at the beginning of development, where it shapes the design, or at the end, after a production incident forces a reckoning.&lt;/p&gt;

&lt;p&gt;Synthetic data does not replace production data. It replaces the false confidence that production snapshots provide: the feeling that testing against "real" data means testing against reality. A snapshot of the past, stripped of its most sensitive content and frozen at a point in time, is not reality. It is an artifact. The team that understands its domain well enough to construct the right data from scratch understands its system better than the team that inherited a database dump.&lt;/p&gt;

&lt;p&gt;Build with intention. Verify against reality. In that order.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: Gartner Market Guide for Data Masking · MIT Sloan Management Review · Perforce 2025 State of Synthetic Data Report · Tonic.ai · Accutive Security · Netguru · Enov8&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
