Backwards Compatibility
Date: 2026-08-17
Changing something other people depend on without breaking them. The half people forget is forward compatibility — old code reading new data — and during any rolling deploy you need both simultaneously.
Backwards compatible: new code works with old data and old callers. Forward compatible: old code tolerates new data.
BACKWARD new reader ← old data
needed on every deploy
FORWARD old reader ← new data
needed whenever a client is
out of your control
Why you need both at once
Nothing deploys atomically:
ROLLING DEPLOY
server A: new code
server B: old code ← for minutes
both hitting one database
MOBILE APPS
users on versions from three years ago
← for years
CACHED JAVASCRIPT
a browser holding yesterday's bundle
calling today's API
QUEUED MESSAGES
written by old code, read by new
During any deploy, old and new code coexist. A change that’s backwards compatible but not forwards compatible breaks the old instances that are still running — which presents as intermittent errors that stop on their own, and gets dismissed as a blip.
What counts as breaking
Wider than most people assume:
CLEARLY BREAKING
removing a field or endpoint
renaming anything
changing a type
adding a required field
tightening validation
BREAKING IN PRACTICE
changing a default
changing the ORDER of a list
adding a field that a strict client
rejects
changing an error code
making a sync operation async
changing precision or rounding
changing what NULL means
Adding a field can break a strict consumer, which is why “ignore unknown fields” is the single most valuable convention in schema design — it makes additive change safe by default — Serialisation Formats.
The expand–contract pattern
The technique for making any breaking change safe. It costs three deploys instead of one:
1 EXPAND
add the new thing alongside the old
write to BOTH, read from the old
2 MIGRATE
backfill; switch readers to the new
both still work
3 CONTRACT
stop writing the old; verify nothing
reads it; remove
Applied to a column rename:
1 add `email_address`; write both
2 backfill; switch reads
3 drop `email`
Each step is independently deployable and reversible. A single-step rename is not — Database Migrations.
Deprecation that works
1 ANNOUNCE — with a replacement named
and a date
2 WARN — response header, console
warning, or a log line
3 MEASURE — who is still calling it
4 CONTACT the remaining callers
5 REMOVE
← never skip 3. Removing something
without knowing who uses it is a
guess
A deprecation warning must name the replacement. “This endpoint is deprecated” costs the reader a search; “use /v2/orders, see the migration guide” doesn’t — Design System Versioning.
Designing for it upfront
- Additive by default. Add fields, don’t repurpose them
- Never reuse a name for a different meaning. A field that meant pence and now means pounds is the worst kind of break: silent, and wrong by 100×
- Ignore unknown fields on every consumer you write
- Version at the boundary, not internally — Versioning
- Make new fields optional with a sensible default
- Keep enums open. A consumer that crashes on an unrecognised status value blocks you from ever adding one
The judgement
Every compatibility guarantee is a constraint you accept forever. Supporting an API version indefinitely means carrying its assumptions into every future design.
The realistic position: strong guarantees at public boundaries, weaker ones internally. A public API needs long support; a service consumed only by your own front end, deployed together, needs much less — and treating both with the same ceremony is a common source of unnecessary work.