<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Data pipelines - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Data pipelines - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Fri, 13 Nov 2026 12:22:09 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/data-pipelines/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ Schema Drift vs. Semantic Drift in Data Pipelines ]]>
                </title>
                <description>
                    <![CDATA[ When a column is removed from a source database or dataset, the next pipeline run usually fails, making the problem easy to detect. But when a field keeps the same name and data type but its meaning c ]]>
                </description>
                <link>https://www.freecodecamp.org/news/schema-drift-vs-semantic-drift-in-data-pipelines/</link>
                <guid isPermaLink="false">6af6f326f94aa3564f351d31</guid>
                
                    <category>
                        <![CDATA[ Data pipelines ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pydantic ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ data-engineering ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Yemi Ojedapo ]]>
                </dc:creator>
                <pubDate>Fri, 13 Nov 2026 11:23:18 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ae47b7ae-2c13-49b9-bad7-a20a28edbecb.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When a column is removed from a source database or dataset, the next pipeline run usually fails, making the problem easy to detect. But when a field keeps the same name and data type but its meaning changes (known as semantic drift), the pipeline can continue running normally, allowing the problem to go unnoticed.</p>
<p>This happens because a schema describes the structure of data, not what the values actually represent. It tells us which fields exist, their data types, and whether they can contain null values, but not whether a timestamp uses UTC, a durability score should range from 10 to 100, or an empty string means something different from null.</p>
<p>As a result, most pipeline checks are designed to catch obvious errors, such as missing columns or incorrect data types, but often fail to detect less obvious problems where the data looks correct but its meaning or values have changed.</p>
<p>I wanted to see how far this limitation reaches in practice, so I built a small lab where I introduced nine types of pipeline failures and tested four detectors against each one. This article looks at what each detector caught and what happened when changes went unnoticed and corrupted the data.</p>
<p>You can find the code <a href="https://github.com/hyemiie/game_inventory-schema_drift_lab-">here</a> and reproduce the experiment with a few commands.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><a href="#heading-how-the-lab-works">How the Lab Works</a></li>
<li><a href="#heading-understanding-data-shape-and-meaning">Understanding Data Shape and Meaning</a></li>
<li><a href="#heading-the-results-matrix">The Results Matrix</a></li>
<li><a href="#heading-detecting-changes-in-data-shape">Detecting Changes in Data Shape</a></li>
<li><a href="#heading-semantic-changes-what-the-wrong-numbers-looked-like">Semantic Changes: What the Wrong Numbers Looked Like</a></li>
<li><a href="#heading-damage-vs-visibility">Damage vs. Visibility</a></li>
<li><a href="#heading-detecting-changes-in-data-meaning">Detecting Changes in Data Meaning</a></li>
<li><a href="#heading-what-this-experiment-suggests">What this Experiment Suggests</a></li>
<li><a href="#heading-limitations">Limitations</a></li>
</ul>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>To follow along and run the lab yourself, you’ll need:</p>
<ul>
<li><strong>Python 3.10 or newer</strong>, and basic comfort reading Python code</li>
<li><strong>Pydantic v2</strong>, installed with <code>pip install pydantic</code></li>
<li><strong>Git</strong>, to clone the repository</li>
<li><strong>A terminal</strong>, to run the scripts</li>
<li><strong>Basic SQL</strong>, such as <code>GROUP BY</code> and <code>WHERE ... IS NULL</code>. SQLite comes with Python, so there is nothing extra to install.</li>
<li><strong>A general idea of what a data pipeline does</strong>: reading data from a source, transforming it, and loading it somewhere else</li>
</ul>
<h2 id="heading-how-the-lab-works">How the Lab Works</h2>
<p>I intentionally kept the pipeline simple. It reads 1,000 game-inventory records from a JSONL file, flattens the nested <code>stats</code> and <code>attributes</code> objects, loads the data into SQLite, and calculates six metrics: total number of records, unequipped items, item counts by rarity, average attack per category, items acquired per day, and average durability. I also left out defensive checks so that each change could affect the pipeline in the same way it might affect a real pipeline when someone assumes the source data is reliable.</p>
<p>Each drift applies a deterministic function that takes one record and returns a changed one, so every result is repeatable. For instance, the timezone drift shifts each UTC timestamp to UTC+4 wall time and drops the offset:</p>
<pre><code class="language-python">def local_time_without_offset(row):
    timestamp = row["acquired_at"]
    if not timestamp.endswith("Z"):
        raise ValueError(f"Expected UTC timestamp ending in Z, got: {timestamp!r}")

    utc_time = datetime.fromisoformat(timestamp.removesuffix("Z") + "+00:00")
    local_time = utc_time + timedelta(hours=4)
    row["acquired_at"] = local_time.replace(tzinfo=None).isoformat()
    return row
</code></pre>
<p>After applying each drift, I ran the pipeline and saved its output in a separate SQLite database. I then compared it with the database generated from the original data, checking each row and column for differences. This step helped identify whether the pipeline had produced correct results, rather than simply checking whether it ran successfully.</p>
<p>The comparison placed each change into one of four categories:</p>
<ul>
<li>failed loudly (the error names a field the drift touched)</li>
<li>failed confusingly (the pipeline crashed but the error doesn't mention the cause)</li>
<li>passed with corruption (it finished, but values differ from the baseline)</li>
<li>or passed clean (the database matches the baseline exactly).</li>
</ul>
<p>I tested each drifted file with four detectors. Each detector follows the same structure: it takes the baseline file and the incoming file as input, checks for changes, and returns whether it found a problem along with a message. This shared interface keeps the setup simple and makes it easy to add more detectors.</p>
<p>The four detectors are:</p>
<ol>
<li><strong>Schema snapshot diff:</strong> Compares the fields and data types in the incoming file against the baseline to identify schema changes.</li>
<li><strong>Loose Pydantic model:</strong> Validates the data against a basic set of rules and restricts <code>rarity</code> to the five known values.</li>
<li><strong>Strict Pydantic model:</strong> Applies additional validation rules. It also restricts <code>rarity</code> to the five known values, requires timezone-aware timestamps, limits <code>equipped_slot</code> to five known values, and rejects unknown top-level fields. This refers to the validation rules in the model, not Pydantic's <code>strict=True</code> mode.</li>
<li><strong>Statistical profile:</strong> Compares the statistical profile of the incoming data with the baseline. It checks each field's mean, category shares, and null rate to identify changes that the other detectors might miss</li>
</ol>
<h2 id="heading-understanding-data-shape-and-meaning">Understanding Data Shape and Meaning</h2>
<p>The rest of this article uses two terms: <strong>data shape</strong> and <strong>data meaning</strong>. Data shape refers to the fields in a dataset, their paths, and the types of values they contain. The schema detector captures this information by checking each field and the types found in it.</p>
<pre><code class="language-python">def walk(obj, prefix, schema):
    for key, value in obj.items():
        field = f"{prefix}{key}"
        if isinstance(value, dict):
            walk(value, field + ".", schema)
        else:
            schema.setdefault(field, set()).add(type(value).__name__)
</code></pre>
<p>For the baseline, this returns entries like <code>acquired_at: {'str'}</code>, <code>equipped_slot: {'NoneType', 'str'}</code>, and <code>attributes.durability: {'int'}</code>.<br><br>Data meaning, however, includes everything the dictionary leaves out including the unit and time zone of a value, its expected range, the set of valid strings, and what a null value represents. This reveals a limitation of any detector that only looks at data shape. Since the schema detector bases its results on the dictionary of field paths and data types, it can't distinguish between datasets that produce the same dictionary but contain values with different meanings.</p>
<p>Three of the nine drifts left this dictionary unchanged: the timezone shift, the new enum value, and the durability scaling. As a result, the schema detector didn't flag any of them.</p>
<p>Detectors can check data at different levels:</p>
<ol>
<li>Shape: Checks which fields exist and the types of values they contain.</li>
<li>Value constraints: Checks whether individual values follow specific rules, such as requiring timezone information or limiting a field to a known set of values.</li>
<li>Distribution: Compares statistics, such as averages, category shares, and null rates, against a baseline. This can reveal changes in the data without defining exactly what each value should mean.</li>
<li>Semantics: Checks what the data actually represents, including units, timezone conventions, and the meaning of null values. These rules require knowledge of the data and must come from the people or systems that produce and use it.</li>
</ol>
<p>The four detectors cover the first three levels. The fourth level goes beyond what these detectors can establish because a dataset can pass checks on its structure, individual values, and statistical distributions while its meaning has changed. To know what the data means, we need context that can't be gotten from its values alone.</p>
<h2 id="heading-the-results-matrix">The Results Matrix</h2>
<p>The results matrix summarizes how each of the four detectors responded to the nine drift scenarios.</p>
<pre><code class="language-python">for drift in ALL_DRIFTS:
    dst = ROOT / "data" / "drifted" / f"{drift.name}.jsonl"
    drifted_db = DB_DIR / f"{drift.name}.db"

    apply_drift(drift, BASELINE, dst)
    result = run_pipeline(dst, db_path=str(drifted_db))
    outcome = classify(result, baseline_db, drifted_db, drift.fields)

    detected = {
        det.name: det.check(str(BASELINE), str(dst))["detected"]
        for det in DETECTORS
    }

    rows.append((drift, outcome, detected, result.get("error")))
</code></pre>
<p>It shows which changes they caught, which they missed, and whether the pipeline failed or continued with incorrect data.</p>
<table>
<thead>
<tr>
<th>Drift</th>
<th>What changed</th>
<th>Pipeline outcome</th>
<th>Rows corrupted</th>
<th>Schema diff</th>
<th>Pydantic (loose)</th>
<th>Pydantic (strict)</th>
<th>Statistical profile</th>
</tr>
</thead>
<tbody><tr>
<td><code>rename_rarity</code></td>
<td><code>rarity</code> renamed</td>
<td>Failed loudly</td>
<td>n/a</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td><code>drop_equipped_slot</code></td>
<td><code>equipped_slot</code> removed</td>
<td>Failed loudly</td>
<td>n/a</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td><code>stats_become_list</code></td>
<td><code>stats</code> object became an array</td>
<td>Failed confusingly</td>
<td>n/a</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td><code>add_column</code></td>
<td>New <code>guild</code> field</td>
<td>Passed clean</td>
<td>0</td>
<td>Yes</td>
<td>No</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td><code>type_change_level</code></td>
<td><code>level</code> int became string</td>
<td>Passed clean</td>
<td>0</td>
<td>Yes (only)</td>
<td>No</td>
<td>No</td>
<td>No</td>
</tr>
<tr>
<td><code>empty_equipped_slot</code></td>
<td>Null slots became <code>""</code></td>
<td>Passed with corruption</td>
<td>205</td>
<td>Yes</td>
<td>No</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td><code>new_enum_value</code></td>
<td>20 rows relabeled <code>mythic</code></td>
<td>Passed with corruption</td>
<td>20</td>
<td>No</td>
<td>Yes</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td><code>local_time_without_offset</code></td>
<td>Timestamps shifted to UTC+4, offset dropped</td>
<td>Passed with corruption</td>
<td>1,000</td>
<td>No</td>
<td>No</td>
<td>Yes (only)</td>
<td>No</td>
</tr>
<tr>
<td><code>durability_scaled</code></td>
<td>Durability multiplied by 10</td>
<td>Passed with corruption</td>
<td>1,000</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>Yes (only)</td>
</tr>
<tr>
<td><strong>Total caught</strong></td>
<td></td>
<td></td>
<td></td>
<td><strong>6 / 9</strong></td>
<td><strong>4 / 9</strong></td>
<td><strong>7 / 9</strong></td>
<td><strong>2 / 9</strong></td>
</tr>
</tbody></table>
<p>"Yes (only)” means that detector was the only one to catch the change. “n/a” means the pipeline crashed, so no rows were loaded.</p>
<h3 id="heading-pipeline-errors">Pipeline Errors</h3>
<table>
<thead>
<tr>
<th>Drift</th>
<th>Label</th>
<th>Error</th>
</tr>
</thead>
<tbody><tr>
<td><code>rename_rarity</code></td>
<td>Failed loudly</td>
<td><code>KeyError: 'rarity'</code></td>
</tr>
<tr>
<td><code>drop_equipped_slot</code></td>
<td>Failed loudly</td>
<td><code>KeyError: 'equipped_slot'</code></td>
</tr>
<tr>
<td><code>stats_become_list</code></td>
<td>Failed confusingly</td>
<td><code>TypeError: list indices must be integers or slices, not str</code></td>
</tr>
</tbody></table>
<p>The strict model detected the most changes (7 out of 9), followed by the schema diff (6), the loose model (4), and the statistical profile (2).</p>
<p>But each detector missed some changes. Three drifts were detected by only one detector: the strict model caught the timezone shift, the schema diff caught the <code>level</code> change, and the statistical profile caught the durability scaling.</p>
<h2 id="heading-detecting-changes-in-data-shape">Detecting Changes in Data Shape</h2>
<p>Five of the nine drifts changed the data structure. These included renaming <code>rarity</code>, removing <code>equipped_slot</code>, adding a <code>guild</code> field, replacing the <code>stats</code> object with an array, and changing <code>level</code> from an integer to a string.</p>
<p>The schema diff detected all five changes. The strict model detected four, and the loose model detected three. This shows that the schema diff was the most effective at detecting structural changes.</p>
<p>The pipeline failed on three of the drifts, but some error messages were more useful than others. Renaming <code>rarity</code> caused a <code>KeyError: 'rarity'</code>, which tells us exactly which field is missing. Replacing <code>stats</code> with <code>[attack, defense, speed]</code> caused a <code>TypeError: list indices must be integers or slices, not str</code>. This message explains the type of error but doesn't identify the field involved. To find the cause, the reader has to trace the error back through the transformation code.</p>
<p>The classifier labels the first error loud and the second <a href="http://confusing.It">confusing.It</a> makes this distinction by checking whether the error message mentions a field affected by the drift.</p>
<pre><code class="language-python">if result["status"] == "error":
    error_text = result["error"].lower()
    names_cause = any(f.lower() in error_text for f in drift_fields)
    return {
        "label": "failed_loudly" if names_cause else "failed_confusingly",
        "rows_affected": None,
        "loss_cells": 0,
        "corrupt_cells": 0,
        "extra_rows": 0,
    }
</code></pre>
<p>The Pydantic detectors ran on the same file and reported <code>stats: Input should be a valid dictionary</code>, directly identifying the field with the problem.</p>
<p>The <code>level</code> change reveals another way a structural change can go undetected. Both Pydantic models accepted it because Pydantic's default lax mode converts the string <code>"42"</code> into the integer <code>42</code>. The pipeline also accepted it because SQLite's INTEGER affinity converts text that looks like a number when inserting it into an INTEGER column. As a result, the database matched the baseline, and the classifier labeled the run <code>passed_clean</code>.</p>
<p>Only the schema diff detected the change because it checks the type of the raw value before any conversion takes place. This shows the limitation of checking data after coercion: once a value has been converted, the original type change is no longer visible. In this case, the simplest detector caught a change that both Pydantic models accepted.</p>
<p>The experiment also showed that not every detected change causes a problem. Adding <code>guild</code> and changing <code>level</code> from an integer to a string both triggered alerts. The schema diff detected both changes, while the strict model detected only the addition of <code>guild</code>. But neither change corrupted any rows because the load step reads only the fields it expects.</p>
<p>This highlights the need to distinguish between changes that should stop a pipeline and those that only need to be logged. If detectors flag too many harmless changes, users may start to ignore their warnings.</p>
<h2 id="heading-semantic-changes-what-the-wrong-numbers-looked-like">Semantic Changes: What the Wrong Numbers Looked Like</h2>
<p>The remaining drifts didn't change the fields or data types. They changed what the values represented. These changes were harder to detect but had real effects on the results. The table below shows the impact of each drift and whether it was visible in the pipeline's output.</p>
<table>
<thead>
<tr>
<th>Drift</th>
<th>Rows corrupted</th>
<th>Metric that moved</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Timestamps shifted to UTC+4, offset dropped</td>
<td>1,000</td>
<td>items acquired per day</td>
<td>Jan 2: 9, Jan 3: 3</td>
<td>Jan 2: 6, Jan 3: 6</td>
</tr>
<tr>
<td>Null slots replaced with <code>""</code></td>
<td>205</td>
<td>unequipped item count</td>
<td>205</td>
<td>0</td>
</tr>
<tr>
<td>New <code>mythic</code> rarity on 20 rows</td>
<td>20</td>
<td>item count per rarity</td>
<td>common: 619</td>
<td>common: 609, mythic: 20</td>
</tr>
<tr>
<td>Durability multiplied by 10</td>
<td>1,000</td>
<td>average durability</td>
<td>54.78</td>
<td>547.85</td>
</tr>
</tbody></table>
<p>A major thing I noticed is that most of the numbers still look normal. For example, moving timestamps forward by four hours shifts some items onto the next calendar day. Since four hours is one-sixth of a day, roughly one in six items moves to a different day. Each day loses some items to the next day but also gains items from the previous day.</p>
<p>The total number of items remains 1,000, and the daily counts change very little overall. With an average of about 5.5 items per day, the counts already vary by two or three items. So a drop from 9 items to 6 can easily look like typical variation rather than a data problem.</p>
<p>The pipeline's total-row-count check still passed because no items were lost. Any check that only compares totals would also miss the problem. The total is correct, but some items are now counted under the wrong day.</p>
<p>I also looked at why the pipeline didn't report a problem. The original timestamp, <code>2026-01-02T22:30:00+00:00</code>, changed to <code>2026-01-03T02:30:00</code> after the drift. The new timestamp has no timezone offset, so <code>datetime.fromisoformat</code> reads it as a naïve datetime. The <code>isoformat()</code> method then writes it back without an offset. SQLite's <code>DATE()</code> treats this offset-free timestamp as UTC, placing the item on January 3 instead of January 2.</p>
<p>The pipeline processed the timestamp without errors but assigned the item to the wrong day. If the producer had written <code>2026-01-03T02:30:00+04:00</code> instead, the timestamp would have represented the same instant as the original. SQLite would have converted it to UTC, and the item would still have been counted on January 2.</p>
<p>The problem was the missing timezone offset. Without it, the pipeline had no way to know which timezone the timestamp represented, so it treated the time as UTC.</p>
<p>The empty-string drift caused a different problem. Replacing <code>null</code> with <code>""</code> kept the field as a string, so checks that only verify the data type still passed.</p>
<p>The schema diff caught the change because the type set for <code>equipped_slot</code> changed from <code>{'NoneType', 'str'}</code> to <code>{'str'}</code>. The pipeline raised no errors, but the query <code>WHERE equipped_slot IS NULL</code> returned no rows. The dashboard would show 0 unequipped items instead of 205, making it look as though every player had equipped their gear.</p>
<p>The statistical profile showed what had happened: <code>equipped_slot: null rate 20% -&gt; 0%; equipped_slot='': share 0% -&gt; 20%</code>. The null values had been replaced with empty strings, not real values.</p>
<p>The new <code>mythic</code> rarity went through the pipeline without an error for a similar reason. The drift changed 20 records to <code>mythic</code>: 10 from <code>common</code> (619 to 609), 9 from <code>uncommon</code> (236 to 227), and 1 from <code>rare</code> (98 to 97). The query <code>GROUP BY rarity</code> accepted the new value without any error. It simply created a new row for <code>mythic</code> and reduced the counts for the other rarities. A dashboard that only knows the five original rarities might hide <code>mythic</code> or group it under "other", depending on how it handles unknown values.</p>
<p>The durability change shows most clearly how data can keep the same structure but mean something different. In the original data, durability ranges from 10 to 100, so an average of 547.85 is clearly wrong. But the field kept the same name and integer type, so the schema diff didn't detect the change.</p>
<p>The pipeline also accepted the values because nothing checked them against the expected range. Only a check that knows durability should be between 10 and 100 can catch this problem. To test this, I added a range constraint to the strict Pydantic model:</p>
<pre><code class="language-python">class RangedAttributes(Attributes):
    durability: int = Field(ge=10, le=100)


class StrictWithRange(StrictItem):
    attributes: RangedAttributes


flagged = 0
for row in drifted:
    try:
        StrictWithRange(**row)
    except ValidationError:
        flagged += 1
</code></pre>
<p>This caught 985 of the 1,000 drifted records. The remaining 15 had an original durability value of 10, which became 100 after scaling by 10. Since 100 still falls within the allowed range, those records passed validation.</p>
<h2 id="heading-damage-vs-visibility">Damage vs. Visibility</h2>
<p>The four drifts corrupted 1,000, 205, 20, and 1,000 rows, but each one changed only one of the six metrics the pipeline calculates. The other five metrics stayed the same. Anyone monitoring the pipeline could miss the damage unless they checked the affected metric and knew its expected value.</p>
<p>The main finding is that data can change across many rows without changing most of the results people monitor. People only notice the problem if they check the metric that the drift affects.</p>
<h2 id="heading-detecting-changes-in-data-meaning">Detecting Changes in Data Meaning</h2>
<p>To catch changes beyond the data structure, we need checks that examine the values themselves.</p>
<p>The strict model checks whether values follow specific rules. It caught the timezone drift because <code>AwareDatetime</code> rejects timestamps without a timezone offset (<code>acquired_at: Input should have timezone info</code>). It also caught the empty-string slot because its <code>Literal</code> rule only accepts five known slot names and rejects <code>""</code>.</p>
<p>The loose model has fewer restrictions. It accepts any string for <code>acquired_at</code>, any string or <code>None</code> for <code>equipped_slot</code>, and unknown fields. As a result, it missed the timezone shift, the empty-string slot, and the new column.</p>
<p>The strict model also missed the durability change because I hadn't defined an allowed range for that field. In a separate run, I added <code>Field(ge=10, le=100)</code> to a copy of the model. This caught 985 of the 1,000 changed rows. It missed the remaining 15 because their original durability was 10, which became exactly 100 after scaling and still passed the check.</p>
<p>The statistical profile compares data distributions with the baseline. It was the only detector to catch the durability scaling because it doesn't need a predefined range. It simply detected that the average durability rose from 54.8 to 547.9.</p>
<p>But it may also flag normal changes in the data. To test this, I scaled durability by factors from 1.02 to 1.3 and tried four mean-change thresholds. I also tested the detector on 200 clean files generated with different seeds to see how often it raised false alarms. I excluded the baseline seed because it would produce identical data.</p>
<table>
<thead>
<tr>
<th>Mean threshold</th>
<th>False alarms, durability only</th>
<th>False alarms, any numeric field</th>
<th>Shifts caught</th>
</tr>
</thead>
<tbody><tr>
<td>25%</td>
<td>0 / 200</td>
<td>0 / 200</td>
<td>1.3x only</td>
</tr>
<tr>
<td>10%</td>
<td>0 / 200</td>
<td>0 / 200</td>
<td>1.1x and above</td>
</tr>
<tr>
<td>5%</td>
<td>0 / 200</td>
<td>23 / 200 (11.5%)</td>
<td>1.05x and above</td>
</tr>
<tr>
<td>2%</td>
<td>37 / 200 (18.5%)</td>
<td>188 / 200 (94%)</td>
<td>every shift tested</td>
</tr>
</tbody></table>
<p>The 2% threshold shows how easily the detector can flag normal variation. Durability is roughly uniform from 10 to 100, so a 1,000-row sample has a standard deviation of about 26. That means the sample mean typically lands within about 0.8 of the true mean, which is roughly 1.5% of its value. A 2% threshold is only a little bigger than that, so it should fire on roughly 18% of clean files. It fired on 37 of 200 (18.5%). This shows a tradeoff that lowering the threshold helps catch small changes but also increases false alarms.</p>
<p>The results also show why one threshold doesn't work for every numeric field. <code>stats.attack</code>, <code>level</code>, and <code>player_id</code> vary more than durability relative to their averages. At a 2% threshold, at least one of these fields triggered an alarm in 188 of 200 clean files. At 5%, they caused all 23 false alarms, while durability caused none. <code>stats.attack</code> caused 15 of those alarms. Even <code>player_id</code> triggered alarms, although its average has no useful meaning.</p>
<p>Using a single threshold treats every numeric field the same, even though some vary more than others and some shouldn't be checked by their average at all.</p>
<p>The category-share check uses a fixed 5-percentage-point threshold, so changing the mean threshold doesn't affect its results. It raised 26 alarms across 22 of the 200 clean files (11%). Seventeen alarms involved <code>equipped_slot</code>, and 16 of those involved <code>chest</code> or <code>off_hand</code>.</p>
<p>There are two reasons for these alarms. First, the check calculates each slot's share using only the 795 non-null rows, not all 1,000. This gives each share a standard error of about 1.4 percentage points.</p>
<p>Second, my baseline doesn't match the intended shares exactly. <code>chest</code> makes up 17.2% of the baseline instead of the intended 20%. As a result, a clean file only needs to reach 22.2% to trigger an alarm. This happens in roughly 6% of files. <code>off_hand</code> has the opposite problem: its baseline share is higher than intended, at 22.3%. This shows that a baseline can contain its own sampling errors, which then affect every comparison.</p>
<p>One more limitation comes from the detector design, not from a measured result. I didn't test this case, but the code shows why it would go undetected. The strict model caught the timezone drift because the timestamp lost its offset. But a producer could shift the clock and keep the <code>Z</code> suffix. Every detector would then accept the data. The strict model checks only whether a timestamp has an offset, not whether the time is correct. The statistical profile skips timestamp strings because they have too many distinct values to treat as categories and aren't numeric.</p>
<p>This is a limitation of the fourth layer: the timestamp still looks valid, but it represents the wrong time. None of the detectors checks whether the timestamp matches the actual time it is supposed to represent.</p>
<h2 id="heading-what-this-experiment-suggests">What this Experiment Suggests</h2>
<p>The results suggest that combining all four detectors provides better coverage because each one catches changes the others miss.</p>
<p>The schema diff is simple and cheap to run on every load. It caught six of the nine drifts, including the <code>level</code> change that both Pydantic models missed because they converted the value before checking it. It also identified the field that caused an error when the pipeline's own error message did not. But it caught only one of the four drifts that corrupted data.</p>
<p>The strict model and statistical profile caught all four harmful drifts between them. I'd use the strict model for fields with known rules, such as timezone-aware timestamps, allowed values, and numeric ranges. I'd use statistical checks for important metrics that have no clear validation rules, setting a separate threshold for each field based on past data.</p>
<p>The lab showed why one threshold doesn't work for every field. A global 5% mean threshold raised 23 false alarms across 200 clean files, all from fields other than durability. I'd only apply statistical checks to fields where the average is meaningful and set thresholds based on how much each field normally varies.</p>
<p>The fourth layer requires the producer and consumers to agree on what the data means. If the producer changes a field's unit, time zone, or allowed values without telling anyone, consumers can't interpret it correctly. Both sides should agree on clear rules, such as the unit of measurement, the time zone, and the allowed values.</p>
<p>This is a recommendation based on what my detectors couldn't catch. I haven't tested this approach in the lab yet.</p>
<h2 id="heading-limitations">Limitations</h2>
<p>The data is synthetic and independent, so it's much simpler than real data, which often has seasonal patterns and natural growth. Real-world false-alarm rates may therefore be higher than the ones I measured. I also used one fixed baseline, so any sampling error in that sample affects every comparison. A production system would likely use a rolling baseline instead.</p>
<p>I used the same thresholds across fields. Setting a separate threshold based on how much each field normally varies might work better, but I didn't test this. I also tested only four detectors from a much larger set of available tools. These results show where the detectors have blind spots, not which tools are best.</p>
<p>Finally, I chose to group changes into "shape" and "meaning," but some changes, such as replacing <code>null</code> with an empty string, could fit into either group.</p>
<p>My first threshold test also contained a bug. I used the wrong variable name (<code>SEED</code> instead of <code>seed</code>) in the data generator, which caused all 20 clean files to match the baseline exactly. This produced zero false alarms. A result that good at a 2% threshold seemed unlikely, so I checked the code and found the mistake. I corrected the bug and excluded seed 42, the baseline's own seed, because it would generate identical data and could never trigger an alarm. All results in this article use the corrected run.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The experiment showed that schema changes are not the only source of pipeline failures. The schema diff caught all five structural changes but missed semantic changes that left field names and types untouched. For example, timestamps shifted to the wrong day, null values became empty strings, and durability values increased tenfold. In each case, the pipeline ran successfully but produced incorrect results.</p>
<p>The four detectors caught different types of changes, and using them together improved detection coverage. However, detecting changes is only part of the problem. Pipeline checks also need to validate whether values fall within expected ranges and whether they still represent what downstream calculations assume.</p>
<p>A pipeline completing successfully doesn't necessarily mean the data is correct. It’s just as important to check that the data retains its intended meaning and that downstream calculations still produce reliable results.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
