Build a tiny schema-diff gate
Measure your machineNeeds Python
Problem
A schema registry decides whether a new schema may be published by comparing it with earlier ones. Some changes are safe (add an optional field, widen an integer); some are not (add a required field 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
With last-version-only checking, the A, B, C trap passes. With all-versions checking, it fails. That is the justification for transitive modes, shown in code.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide 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)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
| Change | Last version only | All versions | Why |
|---|---|---|---|
| add optional field | pass | pass | - |
| add required field with default | pass | pass | - |
| add required field without default | fail | fail | add required region without default |
| drop optional field | pass | pass | - |
| drop required field | fail | fail | drop required qty |
| widen int32 to int64 | pass | pass | - |
| narrow int64 to int32 | fail | fail | id: int64 -> int32 (narrow or incompatible) |
| int32 to string | fail | fail | qty: int32 -> string (narrow or incompatible) |
| no change | pass | pass | - |
| A, B, C trap: note string, removed, then integer | pass | fail | note: string -> int64 (narrow or incompatible) |
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)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.