Learn / APIs, auth and backend architecture
Versioning and backward compatibility
Lesson 17 of 37 · 8 min read ·
The core problem
Once someone else's code depends on your API, you cannot change it freely. Mobile apps are the sharpest version of this: a user on an eighteen-month-old build may never update, and you cannot force them. Your v1 contract effectively lives forever.
So the real skill is not "how do I version?" It is how do I change things without needing a new version at all?
What is actually breaking
Breaking — removing a field or endpoint; renaming anything; changing a type ("5" → 5); adding a required request field; tightening validation; changing an error code a client branches on; changing default sort order or page size; making a nullable field non-nullable.
Non-breaking — adding an optional request field; adding a response field; adding a new endpoint; adding a new optional query parameter; adding a new enum value if clients were told to ignore unknown ones.
That last caveat matters. If your docs never said "ignore unknown enum values", some client did a switch with no default and adding a value will break them. Contract expectations must be stated to be relied on.
Versioning strategies
URL path — /v1/orders. Obvious, easy to route, trivially debuggable, works in a browser. Purists dislike it because the resource is the same resource. Everyone uses it anyway, and it is the right default.
Header — Accept: application/vnd.api.v2+json. Cleaner in theory; harder to test, harder to explain, easy to get wrong in caching layers.
Date-based — Stripe's approach. A client pins a version date at signup; every backwards-incompatible change is a new dated version, and the API transforms responses to match the caller's pinned date. It is genuinely the best design and it costs the most: you maintain a chain of transformations forever. Worth it at Stripe's scale, overkill for most.
Expand and contract
This is the pattern that avoids most versions entirely, and it applies to database migrations too.
- Expand. Add the new field alongside the old. Write both, read the old.
- Migrate. Backfill; move readers to the new field; watch usage of the old one drop.
- Contract. When usage of the old field is zero — and you have the metrics to prove it — remove it.
Renaming name to full_name becomes: add full_name, populate both, wait, then drop name in the next major version. No client breaks at any point.
Deprecating without an incident
- Announce with a date, in the changelog and to registered developers.
- Return a
Deprecationheader and aSunsetheader with the removal date on every response from the old surface. - Instrument it. You need per-client usage of the deprecated endpoint. Without that number you are guessing about impact.
- Contact the remaining heavy users directly. There will be fewer than you fear.
- Consider brownouts — return errors for one hour on an announced day. Nothing surfaces a forgotten integration like a brief, scheduled failure.
- Remove it.
Skipping step 3 is how deprecations turn into outages.
Gotchas
- Version the API, not each endpoint. Per-endpoint versions produce a combinatorial mess.
- Two versions is a maintenance burden; five is a full-time job. Have a policy for how long a version is supported and hold to it.
- Additive changes must be safe by contract: document that clients must tolerate unknown fields.
- Your database schema is an API too, if anything else reads it directly. Same rules apply.
- Internal APIs need this less — you can deploy all the clients. Do not import public-API ceremony into a monorepo.
Prove you know it
Take an endpoint you own and write the exact plan to rename one field, with zero client breakage and no new version. If your plan has fewer than three deploys, check it again.