An API is a promise. The moment another team writes code against your endpoints, you've signed a contract — and the day you quietly change that contract, something out there breaks, usually at the worst possible time and usually someone else's production. Yet APIs have to evolve: new fields appear, a response shape improves, a clumsy early design needs fixing. Versioning is how you square that circle — how you keep moving forward without stranding the clients you already have. This is a practical, vendor-neutral guide to doing it well: what actually counts as a breaking change, the three backwards-compatible strategies worth knowing, and how to retire an old version without leaving anyone holding a 500. Code is in FastAPI, but the ideas are framework-agnostic.
First: What Actually Counts as a Breaking Change
You can't version sensibly until you can tell a breaking change from a harmless one, because the whole game is avoiding the former. A change breaks a client when it can make code that worked yesterday fail today:
- Removing or renaming a field in a response — anything reading
user.namedies when it becomesuser.full_name. - Changing a field's type or meaning — a string that becomes an object, an
amountthat switches from dollars to cents. - Making an optional parameter required, or removing an endpoint, or changing a URL.
- Tightening validation so requests that used to pass now get rejected.
The flip side is just as important: a change is non-breaking when an existing client can ignore it. Adding a new optional response field, a new endpoint, or a new optional query parameter changes nothing for code that doesn't ask for it. That asymmetry is the foundation of everything below.
The Tolerant Reader: Half the Battle Is the Client
The single most useful habit isn't on the server at all — it's writing clients as tolerant readers that ignore fields they don't recognize instead of choking on them. When your consumers don't break just because a new field appeared, the server gets to make additive changes freely, and the need for a hard version bump shrinks dramatically. A brittle client that validates every key strictly turns harmless additions into breakages; a tolerant one turns most of your evolution into a non-event.
Strategy 1: Version in the URL Path
The most common and most visible approach: put the version right in the path — /v1/users, /v2/users. Its virtues are all about obviousness: anyone can read it, you can paste it into a browser, it's trivial to route, log, cache, and document. In FastAPI, each version is just a router with a prefix:
from fastapi import APIRouter, FastAPI
app = FastAPI()
v1 = APIRouter(prefix="/v1")
v2 = APIRouter(prefix="/v2")
@v1.get("/users/{user_id}")
async def get_user_v1(user_id: int):
u = fetch_user(user_id)
return {"id": u.id, "name": u.full_name} # old shape: single "name"
@v2.get("/users/{user_id}")
async def get_user_v2(user_id: int):
u = fetch_user(user_id)
return {"id": u.id, "first_name": u.first, "last_name": u.last} # new shape
app.include_router(v1)
app.include_router(v2)
The cost is honesty about its downsides: it arguably violates REST purity (the resource didn't change, only its representation did), it clutters every URL, and a major bump can mean duplicating a lot of routes. Still, for a public API where discoverability and ease of use win, path versioning is the pragmatic default — which is why most APIs you've used adopt it.
Strategy 2: Version in a Header
If you'd rather keep URLs clean and stable, move the version into a header instead — either a simple custom header like API-Version: 2 or proper content negotiation through Accept (for example Accept: application/vnd.myapi.v2+json). The URL now identifies the resource, and the header negotiates which representation you get — which is the more REST-pure model. In FastAPI you read the header, often in a dependency, and branch:
from fastapi import FastAPI, Header
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(user_id: int, api_version: int = Header(default=1, alias="API-Version")):
u = fetch_user(user_id)
if api_version >= 2:
return {"id": u.id, "first_name": u.first, "last_name": u.last}
return {"id": u.id, "name": u.full_name} # default stays v1 forever
The trade-off is invisibility. You can't share a “v2 URL,” it's harder to test from a browser or curl without remembering the header, caches need to vary on it, and clients can simply forget to set it — which is why defaulting an unset version to v1 (never the latest) matters: a client that forgets should keep getting the behavior it was written against, not be silently upgraded into a breakage.
Path or Header?
Use path versioning when discoverability and developer ergonomics matter most — public APIs, broad or unknown client bases, anything people will explore by hand. Use header versioning when you want pristine, stable URLs and you control the clients closely. When you're genuinely unsure, pick the path: the version a developer can see is the version they remember to handle. Whatever you choose, be consistent across the whole API — mixing schemes is its own kind of breakage.
Strategy 3: Don't Version at All
The most underrated strategy is the one teams skip right past: many APIs never need a v2. If you commit to additive-only changes and your clients are tolerant readers, you can evolve on “v1” almost indefinitely. The gold-standard example is Stripe, which pins each account to the API version that was current when it integrated and transforms newer internal responses back into that older shape — so the vast majority of integrators never have to migrate at all, even as the API changes underneath them. You don't need that machinery to borrow the principle: prefer the additive change, reshape on the server, and spend a real version number only when there is genuinely no backwards-compatible way to make the change.
Every Version You Ship Is Forever
The moment v2 goes live, you are maintaining v1 and v2 — two code paths, two test suites, two sets of bugs, two things to reason about on every change — until the day you can finally kill the old one. That's the real cost of a version, and it's why you should never cut one casually. A breaking change you could have made additive is a maintenance tax you volunteered to pay indefinitely. Version when the change leaves you no choice, not when a fresh /v2/ just feels tidy.
Deprecating a Version, Responsibly
You can't carry every version forever, so retiring one gracefully is its own craft — and the difference between a smooth migration and a furious support inbox. Do it like a good API citizen:
- Announce early, in writing, with a date. A changelog entry, an email to integrators, and updated docs — with a real sunset date months out, not a surprise. People need time to schedule the work.
- Signal it in the response itself. Send the
Deprecationheader and aSunsetheader (the date the version stops working, standardized in RFC 8594), plus aLinkto the migration guide. Now both humans and their monitoring get the warning automatically. - Provide a real migration path. A clear old-to-new guide with concrete before/after examples turns “we're dropping v1” from a threat into a task.
- Watch the traffic before you pull the plug. Track who's still on the old version and reach out to the stragglers. Sunsetting a version that still serves real customers is how you turn a deprecation into an outage.
# Attach to every response from a deprecated version:
DEPRECATION_HEADERS = {
"Deprecation": "true",
"Sunset": "Wed, 31 Dec 2026 23:59:59 GMT", # RFC 8594 — when it stops working
"Link": '<https://docs.example.com/migrate-to-v2>; rel="deprecation"',
}
When the sunset date finally arrives, fail clearly rather than mysteriously: a 410 Gone with a message pointing at the current version beats a vague 404 that leaves a developer guessing whether they have the URL wrong or the service is down.
A Versioning Plan You Can Trust
1. Default to additive changes and write clients as tolerant readers — most evolution never needs a version. 2. When you truly must break, pick path (discoverable) or header (clean URLs) and stay consistent. 3. Default any unset version to the oldest supported one, never the latest. 4. Keep the number of live versions as small as you can stand — each is forever. 5. Deprecate with Deprecation + Sunset headers, a written timeline, and a migration guide. 6. Monitor old-version traffic before you sunset. 7. A contract-first workflow makes every step safer, because the shape is agreed and changed on purpose.
The Bottom Line
Versioning is how you keep a promise while still being allowed to grow. Most of the time the right version is no new version at all — additive changes and tolerant clients carry you remarkably far, and the cheapest version is the one you never had to cut. When a genuine breaking change does arrive, version it on purpose: path when developers need to see it, header when URLs must stay clean, and as few live versions as you can bear. Then retire the old ones with clear signals and a humane timeline. The goal was never to freeze your API in amber. It's to make sure that when it changes, the clients you worked so hard to win don't wake up to a failure they did nothing to cause.
Versioned, Contract-First, From Day One
Versioned routing, a contract-first request/response layer, and the deprecation headers above ship pre-wired in ShipKit, our production-ready FastAPI boilerplate. See inside ShipKit's architecture for how it fits together.
Explore ShipKit