LearnThatStack Ace your next interview

System Design Concepts · API Design

Versioning an API: changing the contract you already published

Only a breaking change needs a new version, and most changes are additive. The deprecation lifecycle matters far more than where the version number goes.

api contractThe line holds until a renameadditive ships, a rename needs v2Clientu = get("/v1/users/42")u["name"]"Ada"your api/v2/v1 shape4 fieldsid: intfull_name: stremail: str?role: str = username: strtest passcontract test
api contractThe line holds until a renameadditive ships, a rename needs v2Clientu = get("/v1/users/42")u["name"]"Ada"your api/v2/v1 shape4 fieldsid: intfull_name: stremail: str?role: str = username: strcontract testtest pass

The line holds until a field is renamed

  1. The line is a client's dependency on /v1, and each strand is one field that the API publishes at /v1. The contract test checks that /v1 still returns those fields, and the test passes.
  2. The /v1 shape gains an optional field, email, so the client's line gets a third strand, one for email. The contract test runs again and still passes, and a client that ignores fields it does not know keeps working.
  3. The /v1 shape gains a required field, role, and the server fills in a default, so requests that never send role still work. The client's contract still holds, now with role as a fourth field, and /v1 is still /v1.
  4. The API renames the name field to full_name, so the contract test fails. The client, which cannot be redeployed, still reads u["name"] and now gets a KeyError because the name field no longer exists.
  5. The rename goes into /v2 and /v1 keeps its old shape, so the client reads name from /v1 again and the contract test passes. The breaking change needed a new version, and no other change should need one.

© LearnThatStack - diagrams may not be republished without permission.

A published response shape is code that other people already run in production, not documentation. You cannot redeploy that code for them. Each strand in the line stands for one field your API returns. The contract test checks those fields, and the test passes.

Adding an optional field such as email to the /v1 shape costs nothing. A tolerant reader ignores keys it does not recognise, so clients on /v1 skip the new email key. The contract test runs again and still passes. Additive changes like this one are most of the changes any API ever makes.

A new required input is still an additive change if the server can answer without it. Here the server fills in a default role for any client that does not send a role. The default keeps old clients working correctly. So the published contract still holds, now with a fourth additive change.

A rename is two changes: a removal and an addition. The removal is the part that breaks clients. The name field becomes full_name, so a client that reads u["name"] gets a KeyError. The contract test (the check against what that client expects) fails. So the rename has broken that client.

A new version exists to move a breaking change out of /v1. Here the rename moves to /v2, and /v1 goes back to the shape it published. So the old client works again, and the contract test passes. A new version is only for breaking changes, so a change that breaks nothing should never get one.

The first step is to sort six kinds of change into two trays, one for each possible answer. One tray is for changes that ship in place, in the version clients already use. The other tray is for changes that need a new version. So this sort decides whether v2 exists at all.

Old clients never asked for the new key, and they never read it. So nothing they run changes. The optional field ships in the current version, with no new version number. So far, one change has shipped this way. Additive changes are optional fields, new endpoints, and new inputs that the server can fill with a default value.

A new route is safe to add, because no client can already depend on its URL. So the new endpoint also ships in the current version. The list of changes that ship in place now holds 2 changes. Adding to what you offer is not the same as changing what you already offer.

Renaming a field is the first of the six changes that needs a new version. A rename looks like an edit. But it acts like a deletion, because every client that parses the old name stops finding that name the moment you ship.

A type change breaks clients, even when every value stays the same. Every client language parses the string "42" and the number 42 differently. A strict parser rejects a value of the wrong type outright. So this change needs a new version.

Making an optional field required is the change people get wrong. This change needs a new version. You added nothing to the response, but every client that has never sent the field now gets a 422 error. Tightening a rule breaks clients exactly like removing a field does.

A change in meaning is the breaking change that ships by accident. The total field now leaves out the tax that it used to include. Every schema test still passes, but every invoice that uses total is now wrong. So this change needs a new version too.

Four of the six changes break clients, so the API gets a v2. A version in the path shows up in every log and gives each version its own cache entries. A version in a header keeps URLs clean but needs a Vary header to stop the cache from serving v1 to a v2 client. Pick one of the four places for the version number and stop arguing.

Running /v1 and /v2 at the same time is normal for a healthy API. Two live versions are not a failure. Right now, all 100k calls a day still go to /v1. The question is never whether to run two versions. The question is how you get back to running one.

A blog post is not a deprecation. Every /v1 response now includes a Deprecation header and a link to the migration guide. The header's value is an RFC 9745 date that says when /v1 was deprecated. A client library can read that header and warn its own users, so warning users is no longer only your job.

Measuring usage is the step people skip. Skipping it is why a removal turns into an outage. Now you can see how many calls each of the twelve API keys sends a day. You cannot honestly name a sunset date, the day the old version stops working, until you can see who still uses the old version.

The sunset date comes after the traffic measurement, never before. The Sunset header now names the day /v1 stops answering. Five callers move to /v2, so /v1 traffic drops from 100k calls a day to 45k. Now the date is a promise you can keep, because you can see exactly who the date affects.

An email moves the last few callers faster than any header will. Four more callers have moved off /v1. So /v1 now gets only 8k calls a day, from three keys: acme-prod, billing and partner-x. Those keys are no longer anonymous traffic. They are three teams, and each team gets an email.

A late caller now gets an explanation instead of a timeout, because the old /v1 answers 410 Gone. The 410 response also includes a link to the version that replaces /v1. The lifecycle starts with announce and measure, then sunset and chase. Removal of /v1 comes last, and only once traffic to /v1 is zero.

Only breaking changes need a new version, and most changes, like new optional fields or endpoints, are not breaking. Removing or renaming a field, changing its type or meaning, or newly requiring an input breaks clients. Put the version in the path or a header, and stop arguing about which. Announce the change with the Deprecation header, and measure each client's usage before sending the Sunset header. Contact the last few clients by name, and remove the old version only when its traffic is zero.

In interviews · 7 questions

Related Questions

Also helps with

← All concepts