Overview
Schema evolution accepts that schema and code never change atomically. Every change must be safe when old and new versions coexist, so changes are staged rather than done in one step.
How it works
Renaming a column safely, in five releases.
1. expand: ADD COLUMN full_name (nullable)
2. dual-write: app writes name and full_name
3. backfill: UPDATE in batches of 1000
4. read new: app reads full_name only
5. contract: DROP COLUMN name (after old code is gone)
For Protobuf or Avro, add optional fields with defaults, never change a field's type or number, and enforce backward and forward compatibility in a schema registry.
Compatibility has a direction. Backward compatible means new readers can read old data, forward compatible means old readers can read new data, and full compatibility means both. During rolling deploys and with consumers you do not control you need full compatibility, which in practice means only adding optional fields with defaults and only removing fields that were optional. Document the deprecation window, and record the contract date in the ticket that opened the expand step so that it does not get forgotten.
Pitfalls
- Running a blocking ALTER on a huge table; use online schema change tools or concurrent index builds.
- Adding a NOT NULL column without a default, which fails or rewrites the table.
- Never finishing the contract step, leaving permanent clutter.
- Reusing a deleted field number in Protobuf, which corrupts old data interpretation.
Key takeaways
- Make each step independently deployable and reversible.
- Add optional first, remove last.
- Backfill in small batches with throttling.
- Automate compatibility checks in CI.