Module 4: Data Contracts As Versioned Artifacts

Versioning a contract that changes

Description

No real contract stays the same forever. Kiosko is going to grow, S04 is going to send more volume, and at some point someone is going to decide unit_price needs a clearer name after the ORD-9509 incident (the dollars-to-cents bug module 5 diagnoses in depth). This lesson faces that reality head-on: what "versioning" a contract means, with what convention, and what happens — with executed evidence — when a change breaks compatibility against data that already exists.

Connection to the module. Lessons 2 through 5 built orders_contract.yaml as if it were a static artifact, written once. This lesson adds the dimension every real artifact is missing: time. Lesson 7 takes this same versioning idea one step further, toward a governance question.

Semantic versioning: the convention this guide uses

contract_version, the first field you wrote in lesson 3, follows the semantic versioning (semver) format: three numbers separated by dots, MAJOR.MINOR.PATCH. Each position has a precise meaning, not an arbitrary one:

  • MAJOR (the first number) goes up when the change breaks compatibility — any data or code that already complied with the previous version could stop complying with the new one. Renaming a column, removing it, or making an existing rule stricter are MAJOR changes.
  • MINOR (the second number) goes up when the change is backward compatible — it adds something new (an optional column, a wider range), but nothing that already complied with the previous version stops complying with the new one.
  • PATCH (the third number) goes up for changes that don't affect validation behavior at all — fixing a typo in description, clarifying the owner, reformatting the YAML.

This convention isn't an invention of this guide — it's the same MAJOR.MINOR.PATCH scheme practically every Python package you installed in this guide already uses, with the same meaning (pandera==0.32.1, for instance). Applying it to a data contract is a direct extension of an idea the software industry has already used for over a decade.

Worked example: v1.1.0, a compatible change

Kiosko decides 20 rows per day is too tight a limit for a store that could grow fast. It raises row_count's maximum to 50 — a change that doesn't invalidate any file that already passed the previous version, it only allows more:

# orders_contract_v1_1_0.yaml
contract_version: "1.1.0"
dataset: orders_s04
owner: kiosko-data-team
description: >
  v1.1.0 (MINOR, backward compatible): raises row_count's upper bound
  from 20 to 50 rows per day, because S04 could grow faster than
  expected. No file that already passed v1.0.0 stops passing.
schema:
  - name: order_id
    type: string
    nullable: false
    unique: true
  - name: unit_price
    type: float
    nullable: false
    minimum: 0
  - name: quantity
    type: integer
    nullable: false
    exclusive_minimum: 0
sla:
  freshness_hours: 24
  row_count:
    min: 5
    max: 50
on_violation: quarantine

The column schema didn't change at all — the same three rules, exactly the same. Only sla.row_count.max went from 20 to 50, and contract_version reflects that change with a MINOR bump (1.0.01.1.0), not a MAJOR one: any file that was already valid under v1.0.0 remains valid under v1.1.0, with no exception.

Worked example: v2.0.0, a compatibility-breaking change

After the ORD-9509 incident — unit_price=60.00 for an Energy Bar, the dollars-to-cents bug module 5 diagnoses —, Kiosko decides the column name should make the currency explicit: unit_price becomes unit_price_usd. A name change is, almost always, a MAJOR change — any system still looking for unit_price stops finding it:

# orders_contract_v2_0_0.yaml
contract_version: "2.0.0"
dataset: orders_s04
owner: kiosko-data-team
description: >
  v2.0.0 (MAJOR, breaks compatibility): renames 'unit_price' to
  'unit_price_usd' to make the currency explicit in the column name --
  a direct decision from the ORD-9509 incident (P002 sold at 60.00,
  the dollars-to-cents bug module 5 diagnoses). Any consumer still
  expecting 'unit_price' breaks.
schema:
  - name: order_id
    type: string
    nullable: false
    unique: true
  - name: unit_price_usd
    type: float
    nullable: false
    minimum: 0
  - name: quantity
    type: integer
    nullable: false
    exclusive_minimum: 0
sla:
  freshness_hours: 24
  row_count:
    min: 5
    max: 50
on_violation: quarantine

Worked example: running the three versions against the same data

# versioning_demo.py
import duckdb
import pandera.polars as pa
import yaml

from contract import DataContract
from gen_schema import contract_to_pandera_schema

con = duckdb.connect("kiosko.duckdb")
df = con.sql("SELECT * FROM orders_s04").pl()

for path in ["orders_contract.yaml", "orders_contract_v1_1_0.yaml", "orders_contract_v2_0_0.yaml"]:
    with open(path) as f:
        raw = yaml.safe_load(f)
    contract = DataContract.model_validate(raw)
    schema = contract_to_pandera_schema(contract)
    print(f"=== {path} (contract_version={contract.contract_version}) ===")
    try:
        schema.validate(df, lazy=True)
        print("  all rows passed")
    except pa.errors.SchemaErrors as exc:
        fc = exc.failure_cases
        print(f"  {fc.height} validation errors")
    print()

What to expect. Running python3 versioning_demo.py, with orders_s04 (the same twelve rows as always, unchanged) in kiosko.duckdb, the output is exactly this:

=== orders_contract.yaml (contract_version=1.0.0) ===
  4 validation errors

=== orders_contract_v1_1_0.yaml (contract_version=1.1.0) ===
  4 validation errors

=== orders_contract_v2_0_0.yaml (contract_version=2.0.0) ===
  4 validation errors

At first glance, all three report the same number — but that number, on its own, hides a real difference. Look at v2.0.0's full detail to see it:

# inspect_v2.py
try:
    schema.validate(df, lazy=True)
except pa.errors.SchemaErrors as exc:
    print(exc.failure_cases)

What to expect, run with the schema generated from orders_contract_v2_0_0.yaml:

shape: (4, 6)
┌────────────────┬─────────────────┬──────────┬─────────────────────┬──────────────┬───────┐
│ failure_case   ┆ schema_context  ┆ column   ┆ check               ┆ check_number ┆ index │
│ ---            ┆ ---             ┆ ---      ┆ ---                 ┆ ---          ┆ ---   │
│ str            ┆ str             ┆ str      ┆ str                 ┆ i32          ┆ i32   │
╞════════════════╪═════════════════╪══════════╪═════════════════════╪══════════════╪═══════╡
│ unit_price_usd ┆ DataFrameSchema ┆ null     ┆ column_in_dataframe ┆ null         ┆ null  │
│ ORD-9502       ┆ Column          ┆ order_id ┆ field_uniqueness    ┆ null         ┆ 1     │
│ ORD-9502       ┆ Column          ┆ order_id ┆ field_uniqueness    ┆ null         ┆ 9     │
│ -1             ┆ Column          ┆ quantity ┆ greater_than(0)     ┆ 0            ┆ 6     │
└────────────────┴─────────────────┴──────────┴─────────────────────┴──────────────┴───────┘

There's the real difference. The first two versions (v1.0.0, v1.1.0) report their four errors at the row level: ORD-9503 (completeness), ORD-9507 (validity), ORD-9502 x2 (uniqueness) — the same rows as always. v2.0.0 reports something qualitatively different: the first line has column: null, check: column_in_dataframe, index: null — it isn't a problem with any specific row, it's an error at the whole-schema level: the unit_price_usd column the contract now requires doesn't exist in orders_s04, because the real kiosko.duckdb table is still called unit_price, not yet updated. And, as a direct consequence, ORD-9503's error (which depended on checking unit_price) disappears from the report — not because that row is now fine, but because the column that would validate it isn't even being compared under that name anymore.

Diagram: what each kind of change protects

flowchart TD
    A["v1.0.0: original contract"] -->|"MINOR: raises row_count.max\n20 -> 50"| B["v1.1.0\nbackward compatible"]
    A -->|"MAJOR: renames\nunit_price -> unit_price_usd"| C["v2.0.0\nbreaks compatibility"]
    B --> D["orders_s04 (current data)\nstill validates the same"]
    C --> E["orders_s04 (current data)\ncolumn_in_dataframe:\nunit_price_usd does NOT exist"]

Going deeper: a loud, schema-level error beats a silent, row-level one

It's worth stopping on something that might, at first glance, look like a system problem: v2.0.0, when validated against data that hasn't been updated yet, produces an immediate, visible error — column_in_dataframe, with no ambiguity about the cause —, instead of simply "finding no error" on unit_price_usd because that column doesn't exist. This is, precisely, a well-designed contract's correct behavior: if v2.0.0 simply ignored the missing column and validated only what it does find, the real problem — that the data producer (S04, or the process loading orders_s04) hasn't been updated yet to comply with the contract's new version — would remain hidden, indistinguishable from "everything's fine." A contract that breaks compatibility must fail loudly when the data wasn't updated along with it — the alternative, a silent failure, is exactly the kind of "lying green checkmark" this entire guide opened against in module 1.

This also explains why a real contract's MAJOR change should never get deployed with no coordination: someone has to update, at the same time, both the contract and the process that produces the data that contract describes. An uncoordinated MAJOR isn't a flaw in the contract — it's, precisely, the alarm signal semantic versioning exists to produce on purpose.

Common mistakes

Bumping the MAJOR version for any change, "just to be safe." What happens: someone, unsure whether a change is compatible or not, always bumps the MAJOR number as a precaution, even for changes that are actually compatible (like adding an optional column). Why it happens: it seems like the "safer" option — nobody can accuse you of underestimating a change's impact. How to spot it: if your version history has lots of MAJOR jumps with no real consumer ever breaking, you're probably over-flagging compatible changes as if they broke something. How to fix it: apply this lesson's exact question to every change — "could any data or code that already complied with the previous version stop complying with the new one?" If the answer is no (as with v1.1.0, which only widens a range), it's MINOR, not MAJOR — bumping MAJOR unnecessarily makes the whole convention lose trust, because it stops communicating useful information about the real risk of updating.

Updating orders_s04 in the database before coordinating with the contract, or the other way around. What happens: someone renames the unit_price column to unit_price_usd directly in kiosko.duckdb, without having published the new contract version yet — or the other way around, publishes v2.0.0 of the contract before the pipeline that loads the data gets updated. Why it happens: on a small team, it's easy to treat "update the code" and "update the contract" as two independent tasks that can happen in any order. How to spot it: exactly the error this lesson's worked example reproduced — column_in_dataframe showing up out of nowhere, with nobody having touched S04's data directly. How to fix it: a MAJOR contract change and the corresponding change in the real data must be coordinated as a single operation — the real industry practice (documented by GoCardless and by PayPal itself in its template) is publishing the new contract version first, notifying consumers, and only then updating the data, never the other way around with no coordination.

Deleting or overwriting earlier contract versions when publishing a new one. What happens: someone, reaching v2.0.0, deletes orders_contract.yaml (v1.0.0) and orders_contract_v1_1_0.yaml, leaving only the most recent file. Why it happens: it feels like "cleanup" — why keep old versions around if they're no longer used? How to spot it: if you can't reconstruct, with evidence, exactly which rules applied to an S04 file that arrived last week under an earlier contract version, you lost information you might need to audit a past incident. How to fix it: versioning a contract means, precisely, keeping its complete history — every version, with its own file or its own entry in a version control system like git —, the same way you'd never delete earlier versions of a software package just because it isn't the latest anymore. This guide keeps all three versions (orders_contract.yaml, orders_contract_v1_1_0.yaml, orders_contract_v2_0_0.yaml) for exactly that reason.

Exercises

Exercise 1 — Classify three hypothetical changes as MAJOR, MINOR, or PATCH. With no code, classify each of these three changes to S04's contract, with a one-sentence justification: (a) fixing a typo in description; (b) lowering sla.freshness_hours from 24 to 12 (a stricter SLA); (c) adding a new, optional column, notes: string, with no additional rule.

See solution

(a) PATCH — it doesn't change any validation behavior, only human-readable text. (b) MAJOR — a file that used to have up to 24 hours to arrive and complied with the SLA could now, with a 12-hour limit, stop complying with nothing else having changed; making a rule stricter is always a compatibility-breaking change, even if it doesn't touch the column schema. (c) MINOR — a new, optional column (no nullable: false or any other rule) requires nothing new from any file that already complied with the previous version; existing files, which never had that column, would keep passing fine if add_missing_columns isn't enabled, and any new file that does include it benefits from the extra information with no penalty to anyone.

Exercise 2 — Run versioning_demo.py with a fourth version that also updates orders_s04 to match. Modify kiosko.duckdb so orders_s04's table has the renamed unit_price_usd column (repeat module 2's lesson 4 loading step, changing the name in read_csv()'s columns={...}), and run orders_contract_v2_0_0.yaml again against that updated data.

See solution

With orders_s04 reloaded using unit_price_usd as the column name (instead of unit_price), the column_in_dataframe error disappears from the report, and orders_contract_v2_0_0.yaml goes back to reporting the four row-level errors — ORD-9502 x2, ORD-9503, ORD-9507 — exactly like the earlier versions. This confirms, with evidence, this lesson's Going deeper section: the column_in_dataframe error wasn't a flaw in the contract or in contract_to_pandera_schema() — it was the correct, expected signal that the data hadn't yet been coordinated with the contract's new version. Once coordinated, the whole system works again with no surprise.

Exercise 3 — Argue why on_violation changing from quarantine to reject would be a MAJOR change, even though it touches not a single schema column. In 2-3 sentences, explain why a change that modifies no validation rule at all — only the policy for what to do if something fails — can still classify as a compatibility-breaking change.

See solution

Semantic versioning doesn't just protect the data's shape — that only covers the contract's schema component —; it protects the agreed-upon behavior in its entirety, and on_violation is, with the same validity as any column rule, part of that agreement. A system that already built its error-handling logic assuming quarantine (say, a process expecting to get back both clean rows and separated ones, as this guide's module 7 builds) would genuinely break if the contract switched to reject with no warning — the entire file would start getting discarded, instead of partially processed, a behavior change as disruptive as any column rename. Any change that alters what a contract's consumer can safely assume, whether in the schema, the SLA, or the policy, deserves the same MAJOR versioning discipline.

Summary and next step

In this lesson you added the dimension every real artifact needs to the contract: time. You applied semantic versioning (MAJOR.MINOR.PATCH) to two concrete changes — v1.1.0, compatible, only widening a range; v2.0.0, breaking compatibility by renaming a column — and confirmed, with executed evidence, that Pandera reports the break immediately and loudly (column_in_dataframe), never silently. You also saw why that loudness is exactly the correct behavior, and why deleting earlier contract versions eliminates information a future incident might need.

Before moving on you should be able to: classify a hypothetical contract change as MAJOR, MINOR, or PATCH; and explain why column_in_dataframe is a sign the contract system is working correctly, not a flaw.

You have the contract versioned, with evidence of what happens when it changes in two different ways. Lesson 7 takes this same versioning discipline one step further, toward a more uncomfortable question: if a contract can decide what's valid after data arrives, should it also be able to decide whether data can arrive at all in the first place?

Resources

  • Semantic Versioning 2.0.0 — the official MAJOR.MINOR.PATCH specification this lesson applies to a data contract. semver.org. In English.
  • Pandera — official documentation, SchemaErrors and its columns (schema_context, check, column_in_dataframe as one of the whole-schema-level checks). pandera.readthedocs.io/en/stable/lazy_validation.html. In English.
  • Andrew Jones (GoCardless) — "Data Contracts at GoCardless — 6 Months On" (May 2022, includes real experience coordinating contract changes with data-producing teams). medium.com/gocardless-tech/data-contracts-at-gocardless-6-months-on-bbf24a37206e. In English.
  • This guide's module 5 (next in the map) — the source of the ORD-9509 incident that, in this lesson, motivates renaming unit_price to unit_price_usd. src/guides/data-reliability-and-governance-guide/DISENO.md (module 5's section). In Spanish.
  • This guide's DESIGN. src/guides/data-reliability-and-governance-guide/DISENO.md. In Spanish.