The problem: apps you can't update
Say you run a notes app for phones on a notes API: GET /notes/42 returns note 42 as JSON, and you ship a new version of the app every month. But updates are up to each user. Auto-update can be off, an old phone may not take the new version, and some people rarely go online, so some users are still on last year's app.
Every copy calls the same API. A web page loads fresh code on every visit, but a phone app's code is installed on the phone, so whatever the server sends, last year's code must understand it. That is the rule behind every versioning decision: old clients must keep working. An API that keeps that promise is backward compatible.
Safe and breaking changes
Adding a field: safe
The new app adds colored notes, so the server adds a field to every reply:
{: 42,
: "Groceries",
: "milk, eggs",
: "orange"}
Last year's app, written before colors existed, reads the fields it needs by name:
note = get_json("/notes/42")
show(note.get("title", ""))
show(note.get("text", ""))
It never looks at color, so it shows the note as before. A client that ignores fields it doesn't know is a tolerant reader, and that habit is what makes adding fields safe. A strict client that rejects unknown fields would break on the first addition.
Renaming a field: breaking, and silent
Next the team renames text to body and ships a new app that reads body. Last year's app still asks for text, finds none, and shows the empty default: a blank note. The server answered 200 OK, so no error shows up in any log; users just see their notes go blank. That is a breaking change.
the reply has new app shows old app shows
id, title, text milk, eggs milk, eggs
id, title, text, color milk, eggs milk, eggs
id, title, body, color milk, eggs (blank)
The rule: add, never remove or change
- Safe: adding a field to a reply (
"pinned": true), adding an endpoint (GET /notes/42/history), accepting a new optional field in a request. - Breaking: removing or renaming a field, or changing its type. If
"id": 42becomes"42", a typed app may reject the whole reply, and in JavaScript"42" + 1is"421". Making an optional request field required, or requiring a new one, breaks too: old apps don't send it and get400 Bad Request. - Can break: a new value in a field clients switch on, such as a new color,
"purple". An app that picks a paint for each color it knows may crash on one it has never seen, unless it was written with a default.
Renaming without breaking: send both
To get the clearer name anyway, add instead of renaming: send body beside text, holding the same words, and accept either name when a note is saved.
{: 42,
: "Groceries",
: "milk, eggs",
: "milk, eggs",
: "orange"}
Each app reads the name it knows. A "please update" field wouldn't help, since the old tolerant reader skips it too, and renaming the database column changes nothing, because apps only see the API's replies. The old name can go once no app reads it.
When you must break: a new version
Some changes can't be made by adding. GET /notes returns every note in one bare list, and some users have thousands. Sending a page at a time needs a pointer to the next page, and a bare list has no room for one:
[{: 42, …}, {: 43, …}]
{: [{: 42, …}], : "p2"}
Last year's app loops over the reply as a list, so an object breaks it. The change ships as a new version beside the old one: old apps keep calling /v1/notes and get the list, new apps call /v2/notes and get pages. Switching everyone on a set date breaks the users who miss the message, and sending pages only to users with many notes breaks exactly those users.
In the path or in a header
GET /v2/notes
GET /notes
API-Version: 2
- Path: visible in every link, log line and bug report, easy to try in a browser, and load balancers and caches tell versions apart by the URL alone. The cost: a note has a different URL in each version. Many public APIs do this.
- Header (a custom one, or a media type in
Accept): clean URLs,/notesin every version. But it's easy to forget: a request without it gets the default version, and a cache that stores replies by URL must be told to keep versions apart (theVaryheader). GitHub's REST API, for one, takes its version in a header.
Keeping v1 cheap: a thin layer
Two versions with their own code means every fix is made twice. Instead, make v1 a thin layer over v2: it calls v2 and turns the reply back into the old shape. For the list, it collects v2's pages into the one list v1 always returned:
def v1_list_notes(user):
notes, cursor = [], None
while True:
page = v2_list_notes(user, cursor)
notes += page["notes"]
cursor = page["next"]
if cursor is None:
return notes
A fix made in v2 reaches v1's callers too. Stripe has described doing this at scale, with small modules that turn the newest reply back into each older shape.
Deprecating and turning off v1
Deprecated means "still works, but going away". Each step needs the one before it:
- Announce it, in the docs and in a
Deprecationheader on every v1 reply. - Count v1 calls per API key, to see who hasn't moved yet.
- Email the owners of those keys, with how to move to v2.
- Set a date once few keys are left, and send it in a
Sunsetheader. - Turn v1 off on that date, with an error that points to v2 (
410 Goneis a common choice).
Here v1 was deprecated on November 1, 2026 (the header counts seconds since 1970) and turns off on May 1, 2027:
HTTP/1.1 200 OK
Deprecation: @1793491200
Sunset: Sat, 01 May 2027 00:00:00 GMT
Link: ://notes.example.com/docs/v2; rel="deprecation"
Last year's app can only show its generic error, which is why many apps ship a minimum-version check from their first release: a future server can then ask an outdated app to update.
Where it shows up in design interviews
Versioning is usually a sentence or two in the API step of the framework (Scope, Data, API, Busy path, Numbers, Services, Scale, Deep dives). Write the calls with a version in the path, GET /v1/notes/42, and say how they will change: as adds that old clients ignore, with any break shipped as /v2 beside v1. If the interviewer pushes, add that v1 becomes a thin layer over v2 and is retired by counting callers per API key, announcing a date, and turning it off.
Common interview questions
What is a breaking change?
Any change that stops an existing client working unless it changes too: removing or renaming a field, changing its type, requiring more in a request, or sending values old code doesn't expect. Adding fields and endpoints isn't one, as long as clients skip what they don't know.
How do you rename a field without breaking old clients?
Add the new name beside the old one, holding the same value, and keep both until no client reads the old one.
Path or header versioning: which would you choose?
The path, for most public APIs: it is visible in links and logs, and load balancers and caches handle it by URL alone. A header keeps URLs clean, but callers can forget it and caches must be told to vary on it.
How do you retire an old version?
Announce it with a Deprecation header, count its calls per API key, email the owners, set a date in a Sunset header once few are left, then turn it off with an error that points to the new version.
Summary
- Old clients stay installed for years, so every change must keep them working.
- Safe: adding fields and endpoints, read by tolerant clients.
- Breaking: removing, renaming, changing a type, requiring more, unexpected values.
- Versions: a break ships as
/v2beside/v1, and v1 becomes a thin layer over v2. - Deprecation: announce, count by API key, email, set a date, turn it off.