Last year I was working with an enterprise – won’t name them, but big enough to have “platform” in their org chart. They had a REST API serving about forty external integrators. No versioning. None. They’d been making “backward-compatible” changes for two years and calling it fine.
Then they renamed a response field from userName to user_name because someone read a style guide.
Three integrators broke silently. One of them was processing payroll. That was a fun Thursday.
I got the call on Friday.
What actually counts as breaking
Before I get into strategies, this is worth spelling out because people consistently underestimate it. A breaking change is anything that makes a previously valid request or response stop working for an existing client. The obvious stuff: renaming fields, removing fields, changing types. But also the sneaky stuff.
Tightening validation on an input that used to accept garbage. Changing default sort order. Altering which status code an error returns. Switching from null to omitting the field entirely. I’ve seen all of these cause production incidents.
The safe zone is additive-only. New fields, new endpoints, new optional parameters. As long as you don’t touch existing contracts, you’re fine. In theory.
The strategies, ranked by how much I trust them
URL path versioning (my pick)
GET /api/v1/users/123
GET /api/v2/users/123
This is what I use at Decloud. This is what I recommend. Not because it’s elegant – it’s not. The URL changes when the version changes. You end up with duplicated routes and documentation sprawl. Your OpenAPI spec gets fat.
But here’s what it has going for it: it’s impossible to screw up. You can see the version in the URL. You can test it in a browser. You can grep your access logs for it. When an integrator calls you at 2am saying things are broken, you can ask “which version?” and they can actually answer.
For public APIs, I’ll take “obvious and slightly ugly” over “clean and easy to misconfigure” every single time.
Header versioning (fine for internal use)
GET /api/users/123
Accept: application/vnd.example.v2+json
Or the custom header variant:
GET /api/users/123
X-API-Version: 2
Clean URLs. Proper HTTP content negotiation. Very satisfying if you care about REST purity.
I used this at the fintech startup for some internal service-to-service communication and it worked well there. The key difference: we controlled both sides. We knew what headers were being sent. We had shared libraries handling the negotiation.
For external APIs? Forget it. I’ve watched developers spend an hour debugging because they forgot to set a header. Postman doesn’t make it obvious. Browser testing is a pain. And the moment someone shares an API URL in Slack, the version information is gone.
Query parameter versioning (just don’t)
GET /api/users/123?version=1
I’ve seen this in the wild. I’ve never seen it work well. It complicates caching because now your cache key includes query params. Clients forget to pass it and silently hit whatever the default is. It looks like an afterthought because it usually is one.
The only scenario where I’d consider it: internal tooling where you want a quick toggle during migration. Even then, I’d probably just use a header.
No versioning (the “we’ll just be careful” approach)
Additive-only changes forever. Never remove a field. Never rename anything. Just keep growing the response until it’s a museum of every decision you ever made.
This works if your API is small and stable. It doesn’t work if your API is actively developed by a team of more than two people. You accumulate debt fast. That legacy_user_type field from 2018? You can’t remove it because someone somewhere might depend on it. Probably. Maybe. Nobody knows for sure but nobody wants to find out.
The payroll company I mentioned earlier? This was their strategy. “Strategy.”
The part nobody talks about: deprecation
Picking a versioning scheme is the easy part. The hard part is killing old versions.
At Decloud, here’s what we do. When we release v2, we immediately publish a deprecation timeline for v1. Specific dates. Not “sometime in Q3.” An actual date.
We add deprecation headers to v1 responses:
HTTP/1.1 200 OK
Deprecation: Sat, 01 Aug 2020 00:00:00 GMT
Sunset: Fri, 01 Jan 2021 00:00:00 GMT
Link: </api/v2/users/123>; rel="successor-version"
Most consumers ignore these headers. That’s fine. The point is making our intent unambiguous. When we do flip the switch, nobody can say they weren’t warned.
We also ship a migration guide with every new version. Not just “here’s the new schema” but field-by-field mappings and behavior changes. It takes time to write. It saves ten times that in support tickets.
Keep the versions thin
One mistake I made early on: duplicating entire handlers per version. v1 handler, v2 handler, each with their own business logic. That’s a maintenance nightmare.
What works better is keeping one service layer and versioning only the serialization:
// Shared business logic
user := userService.GetUser(userID)
// Versioned response
switch version {
case "v1":
return serializeUserV1(user)
default:
return serializeUserV2(user)
}
Business rules in one place. The version boundary is just a translation layer. When you eventually kill v1, you delete one serializer function. Clean.
Test all of them
This sounds obvious but I’m going to say it anyway because I’ve seen teams skip it: test every supported version in CI. Not just the latest. Every single one.
Track usage by version in your analytics. When v1 traffic drops to zero, you have your evidence to sunset it. When it doesn’t drop to zero, you know who to call.
Monitor error rates per version after every deploy. A release that’s clean on v2 can easily break v1 if you’re not careful with shared code.
My actual advice
Pick URL path versioning. Put the version right there in the path where everyone can see it. Accept that it’s a little ugly. Accept the documentation overhead. The visibility is worth it.
If you’re building internal APIs where you control the clients, headers are fine. But for anything public-facing? Make it obvious. Make it impossible to accidentally call the wrong version.
And start thinking about deprecation on day one. Not when you’re three versions deep and running parallel infrastructure you can’t afford. Day one.