Skip to content
About
Main content

Build a tiny schema-diff gate

Measure your machineNeeds Python

Problem

A decides whether a new schema may be published by comparing it with earlier ones. Some changes are safe (add an , widen an integer); some are not (add a without a default, drop a required field, narrow a type). The trap is history: a check against only the latest version can allow a field to be removed and then re-added with a different type, so that new readers misread old data. Transitive checking compares the new schema with every earlier version.

Take a classify function and extend it to cover the evolution operations: add-optional (allowed), add-required without default (fail), drop-required (fail), widen int32 to int64 (allow), narrow (fail). Write 10 test cases, including the A, B, C trap (note string, removed, then integer). Make it check against every earlier version, not only the last.

The question. Which changes does the gate allow, and does the A, B, C trap pass when only the last version is checked?


Predict

Checked only against the last version, does the A, B, C trap (note is a string, then removed, then an integer) pass the gate?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    build-a-tiny-schema-diff-gate.py59 linesDownload
    View the code
    build-a-tiny-schema-diff-gate.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Build a tiny schema-diff gate.
    
    A compatibility classifier for schema changes (add optional: allowed; add
    required without a default: fail; drop required: fail; widen int32 to int64:
    allowed; narrow: fail), ten test cases, and the A, B, C trap (a field is a
    string in A, removed in B, an integer in C). Each case is checked against the
    last version only and against every earlier version.
    """
    import argparse, json
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    WIDER = {("int32", "int64"), ("float32", "float64"), ("int32", "float64")}
    def classify(old, new):
        """Can data written with `old` be read with `new` (and the reverse)? Returns a list of problems."""
        problems = []
        for name, f in old.items():
            if name not in new:
                if f.get("required"): problems.append(f"drop required {name}")
            elif new[name]["type"] != f["type"]:
                if (f["type"], new[name]["type"]) not in WIDER: problems.append(f"{name}: {f['type']} -> {new[name]['type']} (narrow or incompatible)")
        for name, f in new.items():
            if name not in old and f.get("required") and "default" not in f: problems.append(f"add required {name} without default")
        return problems
    def gate(history, new, transitive):
        targets = history if transitive else history[-1:]
        return [pb for old in targets for pb in classify(old, new)]
    F = lambda t, req=False, **kw: {"type": t, "required": req, **kw}
    base = {"id": F("int64", True), "qty": F("int32", True)}
    cases = [
        ("add optional field", [base], {**base, "note": F("string")}),
        ("add required field with default", [base], {**base, "region": F("string", True, default="EU")}),
        ("add required field without default", [base], {**base, "region": F("string", True)}),
        ("drop optional field", [{**base, "note": F("string")}], base),
        ("drop required field", [base], {"id": F("int64", True)}),
        ("widen int32 to int64", [base], {**base, "qty": F("int64", True)}),
        ("narrow int64 to int32", [base], {**base, "id": F("int32", True)}),
        ("int32 to string", [base], {**base, "qty": F("string", True)}),
        ("no change", [base], dict(base)),
        ("A, B, C trap: note string, removed, then integer", [{**base, "note": F("string")}, base], {**base, "note": F("int64")}),
    ]
    rows = []
    for name, history, new in cases:
        last, allv = gate(history, new, False), gate(history, new, True)
        rows.append({"case": name, "last_version_only": "fail" if last else "pass", "all_versions": "fail" if allv else "pass",
                     "reason": "; ".join(allv) or "-"})
    for r in rows: print(f"{r['case']:<50} last only: {r['last_version_only']:<5} all versions: {r['all_versions']:<5} {r['reason']}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"cases": rows}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run build-a-tiny-schema-diff-gate.py
    uv run build-a-tiny-schema-diff-gate.py --help     # every knob

Our run

Ten schema changes

Ten schema changes
ChangeLast version onlyAll versionsWhy
add optional fieldpasspass-
add required field with defaultpasspass-
add required field without defaultfailfailadd required region without default
drop optional fieldpasspass-
drop required fieldfailfaildrop required qty
widen int32 to int64passpass-
narrow int64 to int32failfailid: int64 -> int32 (narrow or incompatible)
int32 to stringfailfailqty: int32 -> string (narrow or incompatible)
no changepasspass-
A, B, C trap: note string, removed, then integerpassfailnote: string -> int64 (narrow or incompatible)
Reference runApple M2 · 8 cores · 8 GB
Output · uv run build-a-tiny-schema-diff-gate.py
add optional field                                 last only: pass  all versions: pass  -
add required field with default                    last only: pass  all versions: pass  -
add required field without default                 last only: fail  all versions: fail  add required region without default
drop optional field                                last only: pass  all versions: pass  -
drop required field                                last only: fail  all versions: fail  drop required qty
widen int32 to int64                               last only: pass  all versions: pass  -
narrow int64 to int32                              last only: fail  all versions: fail  id: int64 -> int32 (narrow or incompatible)
int32 to string                                    last only: fail  all versions: fail  qty: int32 -> string (narrow or incompatible)
no change                                          last only: pass  all versions: pass  -
A, B, C trap: note string, removed, then integer   last only: pass  all versions: fail  note: string -> int64 (narrow or incompatible)
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What we found

The gate behaved as specified on the nine ordinary cases. The A, B, C trap passed with last-version checking, since against B the field is simply new, and failed with all-versions checking: note: string -> int64 (narrow or incompatible).


Read it

Data outlives schema versions, so a reader may meet data written under any of them. A compatibility check that looks only one version back protects the next deployment and not the archive. Use transitive modes for anything with long retention.