3 API Versioning Strategies That Won't Get You Yelled At (And the One I Regret)
Years ago I shipped a tiny endpoint: POST /users/{id}/status. Simple enough — clients set a user's status, done. Three months later, the product team needed that same call to also carry a reason field, and I made the classic mistake: I changed the request body in place, kept the same URL, and told exactly one customer about it. The other four integrations with auto-retry kept hammering the old shape. At 2am, one of them went down in production and the on-call rotation taught me a lesson I still think about: API versioning isn't about naming conventions. It's about a promise you make to people you've never met.
Here are the three strategies I've used in real services, what each one costs you, and the one approach I now regret ever touching.
Strategy 1: Path Versioning — The Boring Choice That Works
GET /v1/users/42
GET /v2/users/42
This is the versioning scheme everyone understands, because it's visible in every log line, every browser tab, and every Stack Overflow snippet.
What it looks like in FastAPI:
from fastapi import FastAPI, APIRouter
app = FastAPI()
def make_router(version: str):
return APIRouter(prefix=f"/{version}/users", tags=[f"users-{version}"])
# v1 router
v1 = make_router("v1")
@v1.get("/{user_id}")
def get_user_v1(user_id: int):
return {"id": user_id, "full_name": "Ada Lovelace"} # v1 shape
# v2 router — same route, new shape
v2 = make_router("v2")
@v2.get("/{user_id}")
def get_user_v2(user_id: int):
return {"id": user_id, "name": {"first": "Ada", "last": "Lovelace"}} # v2 shape
app.include_router(v1)
app.include_router(v2)
The good:
- Clients can see the version in a
curlcommand — no hidden state. - CDN and cache keys work naturally (
/v1/and/v2/never collide). - Debugging is trivial: "which version is the app calling?" → look at the URL.
The bad — and it's real:
- Every version is a code fork you maintain forever. v1 doesn't disappear because two customers refuse to migrate. Two years in, you're patching a bug in three versions of the same serializer.
- URLs get noisy, and documentation has to explain the same endpoint twice.
When to use it: any public API with third-party consumers — people you've never met, running code you can't see. This is 80% of the APIs I've built, and path versioning is what I'd reach for first every time.
Strategy 2: Media-Type (Header) Versioning — Clean URLs, Hidden Complexity
GET /users/42
Accept: application/vnd.myapi.v2+json
The resource stays at one URL; the client declares which representation it understands via the Accept header. This is what Stripe, GitHub, and a bunch of serious API shops do.
Minimal FastAPI implementation:
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
LATEST = "v2"
@app.get("/users/{user_id}")
async def get_user(request: Request, user_id: int):
accept = request.headers.get("accept", "")
if "vnd.myapi.v2+json" in accept:
return {"id": user_id, "name": {"first": "Ada", "last": "Lovelace"}}
if "vnd.myapi.v1+json" in accept or accept == "*/*":
return {"id": user_id, "full_name": "Ada Lovelace"}
if not accept:
# Missing Accept header = oldest stable version, never the newest
return {"id": user_id, "full_name": "Ada Lovelace"}
raise HTTPException(status_code=406)
The good:
- One URL per resource — no link rot, no duplicated cache entries.
- You can ship a v3 that changes only one field's semantics while everything else stays identical.
The bad — the mistake that bit me:
- Version is invisible in logs unless you deliberately log the
Acceptheader. - Browsers and casual tooling send
*/*, so you must define what "no version asked" means. -
My real mistake: I defaulted missing
Acceptto the newest version. Old clients that never sent the header silently received the new shape and broke in subtle ways — no error, just wrong payloads. The rule I follow now: missing Accept → serve the oldest stable version. It's the only choice that can't break anyone who was already working.
When to use it: you have a long-lived public resource model, few breaking changes per year, and clients sophisticated enough to set headers. Use it with the "oldest on missing" rule, or you will eat my 2am lesson.
Strategy 3: Query Parameter Versioning — The One I Regret
GET /users/42?v=2
It looks convenient — no header plumbing, no URL restructuring. I used it once for an internal admin tool with exactly one consumer team, and even there it annoyed me:
- Cache keys now include the query string, so CDN and HTTP caches treat
?v=1and?v=2as different resources — which means duplicate storage and cold misses for every version flip. - Shared links and bookmarks silently pin whatever version was current when the link was created.
- SEO and API discovery tools get confused by multiple URLs for the same resource.
- It encourages "just add a param" thinking, and that's how you end up with
?v=2&expand=all&legacy=true&experimental=1.
Where it's actually fine: internal services with one known consumer, feature-flag-style rollouts, or a private B2B integration where you control both ends. For a public API? I wouldn't do it again.
The Part Nobody Talks About: Deprecation Is a Product Decision
The URL scheme matters less than your exit policy. Breaking a client isn't a technical event — it's a customer-relationship event. My current playbook:
- Additive changes never get a version bump. New field? Add it. New endpoint? Add it. Old clients that ignore unknown fields keep working. This one rule eliminates most "breaking" changes before they happen.
- Breaking changes get an overlap window of at least 6 months. v2 and v1 run side by side. You eat the maintenance cost — that's the price of the promise.
- Deprecation is announced in the response itself, not just a blog post:
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: true
Sunset: Sat, 31 Jan 2027 23:59:59 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"
The Sunset header tells automated clients when the old version dies — their monitoring can read it and schedule the migration. A small FastAPI middleware makes this painless:
from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware
import datetime as dt
app = FastAPI()
V1_SUNSET = "Sat, 31 Jan 2027 23:59:59 GMT"
class DeprecationHeaders(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
if request.url.path.startswith("/v1/"):
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = V1_SUNSET
response.headers["Link"] = '<https://api.example.com/v2/users>; rel="successor-version"'
return response
app.add_middleware(DeprecationHeaders)
- Migration guides with before/after diffs. For each breaking change, I publish the exact payload change and a search-and-replace-level migration note. Sounds obvious — almost nobody does it.
What I'd Pick Again
- Public API, unknown consumers → path versioning, additive changes by default, 6+ month overlaps.
- Long-lived resource model, few breaks, header-savvy clients → media-type versioning with "oldest on missing Accept".
- Internal service, one consumer → query param is tolerable; otherwise skip it.
And the meta-lesson: the real cost of versioning is not choosing a URL scheme — it's maintaining N versions of the same code. So the best versioning strategy is to version as rarely as possible, by making additive changes the default and treating every breaking change like the deployment incident it can become.
If you've ever been woken up by an API client you didn't know existed, you already know what I mean.
Top comments (0)