API Design

Versioning, Compatibility and Evolving an API

Breaking versus safe changes, URL, header and date versions, deprecation, and how to change an API without breaking clients.

Lesson 0413 min read#api#versioning#compatibility

On this page

  1. Words used in this lesson
  2. Step 1: know what breaks and what does not
  3. Step 2: change without a new version (expand and contract)
  4. Step 3: when you cannot avoid a break, version on purpose
  5. Step 4: deprecate and sunset the old way
  6. A checklist before every API change
  7. Try it
  8. Interview questions
  9. Key takeaways
  10. What's next

In one line: versioning is not about putting /v1/ in the URL. It is about changing an API without breaking people who already use it. Most changes can be made safely by only adding things. When you must break something, give a new version, a clear deprecation date, and time to move.

The first version of an API is easy, because nobody uses it yet. The hard part starts when 300 clients depend on it and you need to rename a field. You cannot ask all of them to update on the same day. Some are mobile apps that users never update. Some belong to companies that no longer have a developer.

This is the second law of API design after "keep a contract": you do not control your clients' release schedule. Everything in this lesson follows from it.

This is lesson 4 of ten. Before it: Pagination, Filtering and Sorting. After it: Idempotency Keys, Retries, Webhooks and Rate Limits. If you want the short interview version of the same ideas, look at Backend Lesson 2 (REST design), which also has a versioning section.

What I ran / checked. I used Node.js v22.22.0, node:http and curl. I wrote an "old client" (a small script written against version 1 of the API), then released six different changes from a toy server, and ran the old client against each one. I then built a server that serves old and new shapes from one model in three styles (URL, date header, and side by side fields) and ran it with curl. All output is real. Sources I read (own words): the Zalando RESTful API Guidelines chapters on compatibility (the rules for input and output schemas, "avoid versioning", media type versioning, "do not use URL versioning", open-ended enums) and on deprecation (the Deprecation and Sunset headers, approval before shutdown, monitoring of usage); the Microsoft Azure REST API Guidelines sections on API versioning (api-version query parameter with a date, no breaking changes, no version in the path, preview rules) and the azure-deprecating header. Could not reach: the RFC Editor and IETF sites (so RFC 8594 Sunset and RFC 9745 Deprecation are described from the Zalando guideline that cites them, not from the RFCs), and I did not read Stripe's versioning write-up, so my remarks about how date-pinned versions work in payment APIs are from general knowledge, not from a source I read. I did not check GitHub star counts.

Words used in this lesson

  • Breaking change is a change that makes a correct, existing client fail or behave wrongly.
  • Compatible (non-breaking) change is a change that every existing client keeps working with, without any update.
  • Backward compatible means new server, old client: still works.
  • Forward compatible means old server, new client: still works (for example a client that ignores fields it does not know).
  • Version is a name for one fixed shape of the contract, such as v2 or 2026-03-01.
  • Deprecation means "still works, but we plan to remove it, please stop using it".
  • Sunset is the date on which a deprecated feature stops working.
  • Tolerant reader is a client that reads only the fields it needs and ignores everything else.
  • Open enum is a list of values that may grow later. Clients must expect unknown values.
  • Expand and contract (also called parallel change) is a way to rename something safely in three steps: add the new thing, move users over, remove the old thing.
  • Semantic versioning is the MAJOR.MINOR.PATCH numbering for libraries. It is a poor fit for web APIs, as we will see.

Step 1: know what breaks and what does not

Before choosing how to version, learn to see which changes are dangerous. I wrote a tiny "old client". It was written for version 1 of the API and never updated:

JavaScript
// client-old.mjsconst LABELS = { ACTIVE: "Active", SUSPENDED: "Suspended" }; // the client only knew two statusesconst res = await fetch("http://localhost:4500/users/1");const u = await res.json();try {  console.log("Hello,", u.name.toUpperCase(), "- status:", LABELS[u.status].toLowerCase());} catch (e) {  console.log("CLIENT CRASHED:", e.message);}

Then I changed the server six times (each "release" below) and ran the old client against each one. The server shapes:

TEXT
release 1: {"id":1,"name":"Ada Lovelace","status":"ACTIVE"}release 2: {"id":1,"name":"Ada Lovelace","status":"ACTIVE","email":"ada@example.com"}release 4: {"id":1,"name":"Ada Lovelace","status":"ACTIVE_TRIAL"}release 5: {"id":1,"first_name":"Ada","last_name":"Lovelace","status":"ACTIVE"}release 6: {"id":1,"name":"Ada Lovelace","status":1}

And the results (release 3 added a nickname field, with the same result as release 2):

TEXT
release 1 -> Hello, ADA LOVELACE - status: activerelease 2 -> Hello, ADA LOVELACE - status: activerelease 4 -> CLIENT CRASHED: Cannot read properties of undefined (reading 'toLowerCase')release 5 -> CLIENT CRASHED: Cannot read properties of undefined (reading 'toUpperCase')release 6 -> CLIENT CRASHED: Cannot read properties of undefined (reading 'toLowerCase')

So:

  • Release 2 (added a field): safe. The old client ignores email.
  • Release 4 (new enum value): crashed. The client had a lookup table for two statuses. The server sent a third. This is the quiet breaking change that bites everyone. Zalando's compatibility rules say it plainly: for values you return, extending an enum is not compatible, because clients may not be ready for it. Their fix is to document returned enums as open from the start (use an "extensible enum" and tell clients to handle unknown values).
  • Release 5 (renamed name into first_name and last_name): crashed. Renaming is removing plus adding.
  • Release 6 (type changed from string to number): crashed. Never change a type.

The compatibility rules, in one table

These come from the Zalando compatibility chapter, which treats input (what the client sends) and output (what the client reads) separately. Think of yourself as the server owner:

ChangeIn a response (output)In a request (input)
Add a new optional fieldSafeSafe
Add a new required fieldSafe for output (old clients ignore it)Breaking (old clients do not send it)
Remove a fieldBreakingBreaking by their rules (clients may still send it; better to accept and ignore it)
Rename a fieldBreaking (remove plus add)Breaking
Change a type or the meaning of a fieldBreakingBreaking
Make an optional field requiredSafe for outputBreaking
Make a required field optionalBreaking for output (client may rely on it)Safe
Add an enum valueBreaking unless the enum was documented as openSafe
Remove an enum valueSafeBreaking unless you still accept it
Make validation strictern/aBreaking
Add a new endpoint or a new optional query parameterSafeSafe
Change an error code or status code for the same situationBreakingn/a
Change default sort, default page size, or rate limitCan break (clients depend on behaviour)n/a
Add pagination to a list that had noneBreaking (see lesson 3)n/a

A good rule to remember: adding is almost always safe, and removing or changing is not. The few exceptions (a new required input, a new enum value in output) are where design mistakes come from.

Two more types of breaking changes that do not show in the schema:

  • Behaviour changes. The response shape is the same, but a list is now sorted a different way, or a timestamp changed from UTC to local time, or a call that used to be instant now takes 5 seconds. Clients depend on these things. Hyrum's Law says that with enough users, every observable behaviour of your system will be depended on by somebody. Be careful with changes you think of as "bug fixes".
  • Performance and limits. A new rate limit or a smaller maximum page size can break a client that did not break before.

Postel's law, and why Zalando writes it down

There is a famous rule: "be conservative in what you send, be liberal in what you accept". The Zalando guideline uses it on both sides:

  • Servers design conservatively: do not add things you might remove, keep names stable, add optional fields only.
  • Clients must be tolerant readers: ignore unknown fields, and handle unknown enum values with a fallback. The Zalando guidelines explicitly say that clients must be prepared to accept compatible extensions, and that OpenAPI schemas are open for extension by default.

Here is what the tolerant reader looks like in code. This one survived two of the releases that crashed the old client:

JavaScript
const name = u.name ?? [u.first_name, u.last_name].filter(Boolean).join(" ");const status = LABELS[u.status] ?? "Unknown (" + u.status + ")";
TEXT
release 4  old: CLIENT CRASHED: Cannot read properties of undefined (reading 'toLowerCase')           tolerant: Hello, ADA LOVELACE - status: Unknown (ACTIVE_TRIAL)release 5  old: CLIENT CRASHED: Cannot read properties of undefined (reading 'toUpperCase')           tolerant: Hello, ADA LOVELACE - status: Active

The tolerant client shows an "Unknown" label for the new status and a correct name for the renamed fields. Nobody phoned support. You cannot force your clients to write this way, but you can document it, and you can give your own SDKs and front-ends the same habit.

Step 2: change without a new version (expand and contract)

Zalando's guidelines say to avoid versioning when possible, and prefer compatible extensions. How do you rename name into first_name and last_name without a break? In three steps:

  1. Expand. Add the new fields and keep the old one. Both are in the response.
  2. Migrate. Tell clients about the new fields (docs, a deprecated: true mark in your OpenAPI file, a Deprecation header). Watch usage until the old field is not used.
  3. Contract. Remove the old field, only after the usage is zero or you have clients' consent.

Step 1 in action ("release 7" in my lab):

TEXT
release 7: {"id":1,"name":"Ada Lovelace","first_name":"Ada","last_name":"Lovelace","status":"ACTIVE"}release 7  old: Hello, ADA LOVELACE - status: active           tolerant: Hello, ADA LOVELACE - status: Active

Both clients work. The new fields are available for anyone who wants them. This is the cheapest way to evolve an API and you should reach for it first. It costs you a few extra bytes and a clean-up task later.

The same idea works for other changes:

You want to...Expand and contract way
Rename a fieldAdd the new name, keep the old, remove later
Change a type (string id to number id)Add a new field with the new type, deprecate the old
Move an endpointServe both URLs, redirect or alias the old one, remove later (Zalando has a separate rule for URL changes)
Replace an endpoint with a better oneAdd the new endpoint, mark the old one deprecated in OpenAPI
Add a required inputMake it optional with a default, so old clients keep working
Stricter validationAccept old values for existing clients, enforce the new rule only on new ones (or by version)

Step 3: when you cannot avoid a break, version on purpose

Sometimes expand and contract is not enough. You redesign the data model, change how pagination works, or change what an operation means. Then you need two versions alive at the same time. There are four common ways to say which version you want. All four have been used by large companies.

1. Version in the URL path

TEXT
GET /v1/users/1GET /v2/users/1

This is the most common style, and it is easy to understand, easy to test in a browser, easy to route in a gateway, and easy to log. The cost: a version number is baked into every URL, so a new version means new URLs for everything, even for resources that did not change. It also makes links stored by clients go stale.

The Zalando guideline is firm against it: URL versioning ties the consumer to provider deployments, makes hypermedia links complicated, and makes coordinated upgrades harder. The Microsoft guideline also says not to put a version segment in the operation path. Many other major APIs do use /v1/, so you will see it everywhere. Know the trade-off.

2. Version in a query parameter (Microsoft's way)

TEXT
GET /users/1?api-version=2026-03-01

Microsoft requires an api-version query parameter on every operation, with a date as the value (YYYY-MM-DD, with -preview for previews). A missing version gets 400 with a specific error code, and an unsupported one gets 400 with the list of supported ones. The URL path stays the same forever. Dates tell you at a glance which version is newer.

3. Version in a header

TEXT
GET /users/1Api-Version: 2026-03-01

The path and the query stay clean. A header version can be a date or a number. The cost is that the version is less visible: it is easy to forget in a quick curl, and caches must be told that the response depends on the header (Vary: Api-Version).

4. Version in the media type (content negotiation)

TEXT
GET /users/1Accept: application/x.example.user+json;version=2

This is the style Zalando requires if you must version: a custom media type with a version, plus Content-Type in the Vary header. It is the "purest" REST option, because the URL names the resource and the representation is negotiated. It is also the one fewest developers are used to, and many tools, caches and browsers handle it less well. It versions the payload shape only, not URL or method meanings.

A server that does URL and date headers

Let me show a real server where the versions are only views of one internal model. The code that matters:

JavaScript
const model = { id: 1, first_name: "Ada", last_name: "Lovelace", status: "ACTIVE" };// each function turns the newest shape into an older shapeconst views = {  "2025-06-01": m => ({ id: m.id, name: `${m.first_name} ${m.last_name}`, status: m.status === "ACTIVE_TRIAL" ? "ACTIVE" : m.status }),  "2026-03-01": m => ({ id: m.id, first_name: m.first_name, last_name: m.last_name, status: m.status }),};

The old view builds name from the two new fields. It also maps a value that the old version never knew (ACTIVE_TRIAL) onto one it did (ACTIVE), so an old client never sees a status it cannot handle. That is the key design trick: the code has one model and many small translators, not many copies of the application. Translators are easy to test.

Now the calls:

TEXT
$ curl -si localhost:4501/v1/users/1HTTP/1.1 200 OKdeprecation: @1772323200sunset: Wed, 31 Dec 2026 23:59:59 GMT{"id":1,"name":"Ada Lovelace","status":"ACTIVE"}$ curl -si localhost:4501/v2/users/1HTTP/1.1 200 OK{"id":1,"first_name":"Ada","last_name":"Lovelace","status":"ACTIVE"}$ curl -si localhost:4501/users/1HTTP/1.1 200 OKapi-version: 2025-06-01vary: Api-Versiondeprecation: @1772323200sunset: Wed, 31 Dec 2026 23:59:59 GMT{"id":1,"name":"Ada Lovelace","status":"ACTIVE"}$ curl -si localhost:4501/users/1 -H 'Api-Version: 2026-03-01'HTTP/1.1 200 OKapi-version: 2026-03-01vary: Api-Version{"id":1,"first_name":"Ada","last_name":"Lovelace","status":"ACTIVE"}$ curl -si localhost:4501/users/1 -H 'Api-Version: 2027-01-01'HTTP/1.1 400 Bad Request{"title":"Unsupported api-version","status":400,"detail":"Supported: 2025-06-01, 2026-03-01"}

Notes:

  • A caller who says nothing gets the oldest supported version, so existing clients do not break. They have to opt in to the new one. That is a deliberate choice for the header style. (Microsoft chooses the opposite: no version means 400, forcing every caller to be explicit. Both are valid, but pick one on purpose. Silently moving everybody to the newest version is the one choice that always breaks someone.)
  • The server echoes the version it used in an api-version response header, so a developer reading logs knows what they got.
  • Vary: Api-Version tells caches that two requests for the same URL with different headers can have different answers.
  • An unknown version gets a 400 that lists the valid ones.

And the old client, which was written for the shape of version 1:

TEXT
$ node client-old.mjs http://localhost:4501 /v1/users/1Hello, ADA LOVELACE - status: active$ node client-old.mjs http://localhost:4501 /v2/users/1CLIENT CRASHED: Cannot read properties of undefined (reading 'toUpperCase')$ node client-old.mjs http://localhost:4501 /users/1Hello, ADA LOVELACE - status: active$ node client-old.mjs http://localhost:4501 /users/1 '{"Api-Version":"2026-03-01"}'CLIENT CRASHED: Cannot read properties of undefined (reading 'toUpperCase')

As designed, the old client works when it keeps the old version and breaks only when it asks for the new one on purpose, which is its own decision to make.

Every version you keep alive is a promise to test, secure and run it. Security fixes must be applied to every version. Two live versions are manageable. Six is a maintenance disaster. This is why both big guidelines say to avoid new versions when a compatible change will do.

Which style should I choose?

StyleGoodBadSeen in
/v1/ in URLObvious, easy to route and testNew URLs for everything, stale linksMany public APIs
?api-version=datePath stays stable, dates are orderedEasy to forget, must be on every callMicrosoft Azure
Header (date or number)Clean URLsHidden, needs VarySome payment APIs pin an account to a date version
Media typeMost "REST-pure", per-resourceTool support is weakerZalando (when needed)

My advice for a new API: use no version at all in v1, follow the compatible-change rules, and decide your versioning style before the first external customer, not after. If you must pick fast, /v1/ in the path is the simplest thing that works and what most developers expect. If you expect to change often and have many customers, a date-based version (query or header) is easier to reason about than v1, v2, v3.

Major versions vs dates, and semantic versioning

Libraries use semantic versioning (1.4.2: major.minor.patch). For web APIs it is a poor fit, because you should never make a breaking change in a minor or patch, and compatible changes are not worth a version bump that clients must opt into. So you only ever need to name breaking versions. Dates do that well and avoid arguments about "is this change big enough for v3". A date-based version can also be pinned to a customer account: the account's version is the date of their first call, and they upgrade when they choose. I know of payment APIs that do this, but I did not read their documentation for this lesson, so check it if you want the details.

Step 4: deprecate and sunset the old way

Removing an old version is a breaking change for anyone still using it. The rules come from the Zalando deprecation chapter:

  1. Mark deprecation in the spec. Set deprecated: true on the endpoint, field or parameter in the OpenAPI file, and explain what to use instead and how to migrate.
  2. Tell clients in each response. Zalando recommends two headers on every response touched by the deprecated thing:
    • Deprecation: @1772323200, a timestamp (RFC 9745) saying when the replacement became available and new users must not start using this. It can also be just true.
    • Sunset: Wed, 31 Dec 2026 23:59:59 GMT, an HTTP date (RFC 8594) saying when it will stop working. The two use different time formats for historic reasons. You can see them in my server output above. Microsoft's approach is a single human-readable azure-deprecating header with text like "API version 2009-27-07 will retire on 2022-12-01 (URL)", which they say is for people reading logs and not for code to parse.
  3. Get consent. Zalando says you must obtain the approval of clients before shutting an API down, and agree on the dates with external partners. A header is not enough to say you "told" them.
  4. Monitor usage. Log which clients still call the deprecated version (by API key, user agent or client id). Zalando asks producers to monitor usage of anything scheduled for sunset, and asks clients to monitor the headers and alert on them. The sunset date only moves when the numbers say it can.
  5. Give time. Microsoft's text on previews mentions at least 90 days for a retired preview; real production APIs usually give much longer, often 6 to 12 months. Choose a period that fits your customers, not your roadmap.
  6. Use a clear failure. When the sunset date arrives, respond with 410 Gone and a problem details body that says what replaced it. A 404 looks like a bug.

Do not announce a sunset date you cannot keep. If a big customer is still on the old version one week before, you will either break them or move the date. Both cost trust. Measure usage first.

A checklist before every API change

Run through this before you merge a change to an API:

  1. Is it only an addition (new endpoint, optional field, optional parameter)? Then it is safe. Ship it.
  2. Does it remove, rename, or change the type or meaning of anything? It is breaking. Use expand and contract.
  3. Does it add a required input or tighten validation? Breaking for old clients. Make it optional with a default.
  4. Does it add a value to an enum that clients read? Check that the enum was documented as open. If not, treat it as breaking.
  5. Does it change behaviour: ordering, defaults, limits, time zones, error codes? Treat it as breaking and ask who relies on it.
  6. Can you prove it is compatible? Run an automated check: compare the old and new OpenAPI files with a diff tool, and keep contract tests from your main clients (lesson 7).
  7. If it is breaking: who are the clients, what is the migration path, what are the deprecation and sunset dates, and how will you measure usage?

Try it

  1. Which of these changes are breaking for a client that reads the response? (a) adding middle_name, (b) changing price from 19.99 to "19.99", (c) returning 404 instead of 200 with an empty list, (d) adding the value REFUNDED to the status of an order, (e) making GET /orders return 50 items instead of 100 by default, (f) removing the unused fax field.
  2. Write the three expand-and-contract steps to rename zip into postal_code in an address.
  3. Run the versioned.mjs idea: write a function viewFor(version, model) and a test that proves the 2025 view never contains first_name.
  4. Your API has /v1/ and 20% of traffic still comes from /v1/. What do you need to know before you pick a sunset date? List three things.
  5. Look at any public API you use. Is there a Deprecation or Sunset header on its responses? Find out how it announces breaking changes.

Answers

  1. (a) safe (an added field). (b) breaking (a type change). (c) breaking (the same situation gets a different status). (d) breaking unless the enum was documented as open. (e) can break a client that expected 100, so treat it as a behaviour change. (f) breaking by the strict rule, even if you believe it is unused, because you cannot see every client; check usage first.
  2. Expand: return both zip and postal_code, and accept either on input. Migrate: mark zip as deprecated in the spec, send a Deprecation header or a note in the docs, and watch how many clients still read or send zip. Contract: when usage is zero (or the agreed date has passed), remove zip.
  3. For example const v = viewFor("2025-06-01", model); assert.ok(!("first_name" in v)); and a second assertion that v.name === "Ada Lovelace".
  4. Which clients they are and whether they have been contacted; whether the usage is decreasing; whether any of them are contractual partners with their own release cycle; whether there are mobile apps in the field that cannot be updated quickly. Then agree on dates and publish them.
  5. It depends on the API. Many do not use the headers and rely on release notes or email instead. The useful part is seeing how they handle it.

Interview questions

What is a breaking change in an API? Give examples. (Must know)

It is a change that makes a correct existing client fail or behave wrongly. Examples: removing or renaming a field, changing a type, making an optional request field required, tightening validation, changing a status code or error code, adding a value to an enum that clients read, adding pagination to a list that had none. Adding an optional field or a new endpoint is normally safe.

How can you change an API without creating a new version? (Must know)

With compatible changes and expand and contract. I add the new field next to the old one, mark the old one deprecated, watch usage until it falls to zero or the agreed date arrives, then remove it. Zalando's guidelines prefer this over versioning, because every extra version is more to test, secure and operate.

Compare URL versioning with header or media-type versioning. (Must know)

URL versioning (/v1/users) is simple, visible and easy to route and test, but the version is in every URL, so a new version means new URLs for everything. A header or media type keeps URLs stable and can version a single resource, but it is hidden, easy to forget, and needs Vary for caches. Zalando forbids URL versioning and requires media type versioning when a version is unavoidable. Microsoft uses a date in a query parameter and bans a version in the path. Many public APIs still use /v1/. I say that there is no universal right answer, and that being consistent matters most.

Why is adding an enum value a breaking change? (Senior signal)

Because clients often write code that handles each known value, with a lookup table or a switch. When a new value arrives they may crash or show nothing. In my test, adding ACTIVE_TRIAL crashed the old client. The remedy is to document returned enums as open from day one and tell clients to handle unknown values with a default. If the enum was not open, I treat adding a value as a breaking change.

What is a tolerant reader and why does it matter? (Senior signal)

It is a client that reads only the fields it needs, ignores unknown fields, and has a safe fallback for unknown enum values. It makes servers free to add things without breaking the client. It is the client-side half of compatibility: the server promises to add and not remove, and the client promises not to break on additions.

How would you roll out a breaking change to an API with thousands of clients? (Senior signal)

I would first ask if it can be done by expand and contract. If not, I would add a new version next to the old, with one internal model and a small translator per version. I would mark the old one deprecated in the spec and in Deprecation and Sunset headers, publish a migration guide, contact the largest clients directly, and monitor usage by client. I set a sunset date based on that data, and after it I return 410 Gone with a pointer to the new version. I do not remove anything while a client still depends on it, without their consent.

What do the Deprecation and Sunset headers do? (Must know)

Deprecation tells clients that a feature is deprecated, and carries a timestamp (or true). Sunset gives the date when it will stop working, in HTTP date format. Together they let client teams and monitoring tools see the problem in normal responses. As the Zalando guideline notes, the headers alone are not consent: I still need to talk with the owners of the clients.

Should an unversioned request get the oldest or the newest version? (Trap)

Never silently the newest, because that breaks every existing client at the moment you release. Either default to a fixed, old version (existing clients keep working and opt in to new ones), or require the version and reject requests without it with a clear 400, as Microsoft does. I choose one on purpose and document it.

Is semantic versioning (1.2.3) right for a web API? (Trap)

Not really. A web API should not make breaking changes in minor or patch versions, and compatible changes do not need a version number at all. So the only thing worth naming is a breaking version, and a major number or a date does that. Dates also avoid debate about whether a change is "big enough" to be v3.

Beyond fields, what other things can break a client? (Senior signal)

Behaviour: sort order, default page size, rate limits, time zone, response time, error codes and messages that clients parse, and rules for ids. Hyrum's Law says that with enough users, somebody depends on every observable behaviour. I keep the documented contract small and clear, and I treat undocumented behaviour changes with care, testing against recorded traffic or contract tests from key clients.

How do you keep several API versions from doubling your code? (Senior signal)

I keep one internal model and one business logic, and write each version as a thin translator at the edge that converts the request into the internal shape and the response back to the old shape. Translators are small, pure functions that are easy to test. I also keep the number of live versions low and remove old ones on a published schedule.

When is it OK to make a breaking change without a new version? (Trap)

When nobody uses the thing, and I can prove it. For example, a field that appears in no client in months of usage logs and that no partner has agreed to depend on, or an endpoint behind an internal-only gateway where I control every consumer. Even then I deprecate first and watch the numbers. Security fixes are a real exception: if the old behaviour is dangerous, I may change it quickly, but I tell clients as early and clearly as I can.

Key takeaways

  • You do not control your clients' release schedule, so the contract must change in a compatible way.
  • Adding is almost always safe. Removing, renaming, retyping, tightening validation and adding to a returned enum are breaking. My old client crashed on three of the six changes I tried.
  • Use expand and contract first: add the new, keep the old, migrate, then remove. Version only when you cannot avoid a break.
  • Choose a versioning style on purpose (URL, date in query or header, or media type), and never move unversioned callers to the newest version silently.
  • Keep one internal model and write each version as a small translator.
  • Deprecate with the spec (deprecated: true), the Deprecation and Sunset headers, a migration guide, usage monitoring and client consent. After the sunset date, answer 410 Gone.
  • Clients should be tolerant readers: ignore unknown fields and handle unknown enum values.

What's next

Next: Idempotency Keys, Retries, Webhooks and Rate Limits, Lesson 5. We come back to the double-charge problem from lesson 1 and build the real solution: an idempotency key that stores the first response, retries with backoff, signed webhooks, rate-limit headers and long-running jobs. Practise now: take an API you know and write down three changes it made over time, and for each decide if it was compatible.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series