Ship a schema change without breaking readers
A schema change looks like one edit and lands on every reader of the data, including readers several versions behind and data written years ago. Compatibility is a property of the pair (the writer's schema, the reader's schema), never of a schema alone, and a registry only compares the new schema with the previous one. How do you ship this change so that no reader breaks, loudly or quietly?
You will end with a change plan that names the operation, the compatibility mode it needs, the steps and their order, and the checks that no registry will run for you.
Work through these in order.
Work through it
Name the operation and test it
A change is safe if and only if it is an information-preserving map over data already written, and its inverse is never required.
- Add an optional field with a default: safe in both directions.
- Add a required field: never; old data has no value for it.
- Drop an optional field: backward only; old readers may still reference it.
- Widen a type: safe. Narrow a type: never as a schema change; it is a data migration.
- Rename or reorder: decided by the identity rule (step 2).
Know how readers find a field
By position, by name or by ID. Under ID identity a rename is metadata only and a drop is safe, provided an ID is never reused. Under name identity a rename is a drop plus an add. Under position, reordering is fatal.
Choose the compatibility mode from the deployment order
Backward compatible lets you deploy readers first; forward compatible lets you deploy writers first, and only works if readers ignore unknown fields. If you do not control the order, you need both. Readers can be more than one version behind, so check against every prior version (transitive), not only the last: A→B and B→C can each pass while A→C breaks. Full transitive is the honest default.
Run a rename or a drop as expand/contract
Expand (add the new field), dual-write both, backfill old rows, move readers one by one, verify that nobody reads the old field, then contract (drop it). The last step needs read telemetry, query-log mining or column-level lineage. Without it, the contract phase never finishes and dead columns pile up.
If you add an enum value, check the consumers, not the type
A new value passes every registry check and breaks every consumer that matches exhaustively: a CASE with no default, a check constraint, an inner join to a lookup table. Model the value set as a reference table, or require every consumer to carry a visible "unknown" branch.
Check where defaults live, and which way nullability moves
A reader-side default is applied at read time: changing it rewrites the meaning of all historical data, with no file changed and no check triggered. Relaxing required to optional is backward-safe only. Tightening optional to required needs proof that no nulls exist, and that proof is a data check, not a schema check.
Ask whether the meaning changed
Compatibility checking compares shapes. A change from cents to dollars in the same integer field passes every mode, parses cleanly everywhere, and makes every downstream number wrong by 100×. Nothing in this mechanism sees it; it needs a contract between producer and consumers.
Worked example
Rename weight to weight_kg on a table with name identity, read by ten teams. Step 1: a rename under name identity is a drop plus an add, so it is unsafe as one edit. Step 4: expand and dual-write in week 1, backfill in week 2, move readers in weeks 3–6, see no read of weight in the query log for 30 days, drop it in week 8.