API Versioning Strategies: Breaking Changes Without Breaking Clients
Every API eventually needs breaking changes. Here's how to make them without making enemies.
February 24, 2026 · 7 min · 1384 words · Rob Washington
Table of Contents
Your API will change. Features will be added, mistakes will be corrected, and sometimes you’ll need to break things. The question isn’t whether to version — it’s how.
More RESTful (same resource, different representation)
Cons:
Less visible (easy to forget)
Harder to test in browser
Some proxies/caches don’t handle custom headers well
Implementation:
1
2
3
4
5
6
7
8
9
10
fromfastapiimportHeader,HTTPException@app.get("/users/{id}")defget_user(id:int,accept_version:str=Header(default="v1")):ifaccept_version=="v2":return{"id":id,"full_name":"Alice Smith"}elifaccept_version=="v1":return{"id":id,"name":"Alice"}else:raiseHTTPException(400,"Unknown API version")
# Internal model - always latestclassUserInternal:id:intfull_name:stremail:strcreated_at:datetime# Version adaptersdefuser_to_v1(user:UserInternal)->dict:return{"id":user.id,"name":user.full_name.split()[0]# v1 only had first name}defuser_to_v2(user:UserInternal)->dict:return{"id":user.id,"full_name":user.full_name,"email":user.email}@app.get("/users/{id}")defget_user(id:int,version:str="v2"):user=fetch_user(id)# Returns UserInternalifversion=="v1":returnuser_to_v1(user)returnuser_to_v2(user)
@app.post("/users")defcreate_user(data:dict):# Accept both "name" (old) and "full_name" (new)name=data.get("full_name")ordata.get("name")...@app.get("/users/{id}")defget_user(id:int):user=fetch_user(id)return{"id":user.id,"name":user.full_name,# Old field"full_name":user.full_name# New field}
// Bad: changing "address" from string to object
v1:{"address":"123 Main St"}v2:{"address":{"street":"123 Main St","city":"NYC"}}// Good: new field, deprecate old
{"address":"123 Main St","address_details":{"street":"123 Main St","city":"NYC"}}
API reference: Complete docs for each supported version
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
## Changelog: v2 (2024-02-01)
### Breaking Changes
-`GET /users/{id}`: Field `name` renamed to `full_name`-`POST /orders`: Field `items` now required (was optional)
### New Features
-`GET /users/{id}`: Added `email` field
- New endpoint: `GET /users/{id}/preferences`### Deprecations
-`GET /users/{id}`: Field `name` deprecated, use `full_name` (will be removed in v3)
### Migration Guide
1. Update client to use `full_name` instead of `name`2. If using `POST /orders`, ensure `items` is always provided
importpytest@pytest.fixturedefclient():returnTestClient(app)deftest_user_v1(client):response=client.get("/v1/users/123")data=response.json()assert"name"indataassert"full_name"notindata# v1 shouldn't have thisdeftest_user_v2(client):response=client.get("/v2/users/123")data=response.json()assert"full_name"indataassert"email"indatadeftest_v1_deprecation_header(client):response=client.get("/v1/users/123")assertresponse.headers.get("Deprecation")=="true"assert"Sunset"inresponse.headers
API versioning is a contract with your users. Do it thoughtfully, communicate clearly, and remember: the goal isn’t to never change — it’s to change without surprises.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.