You shipped v1 of your API. Clients integrated. Now you need to make breaking changes. How do you evolve without breaking everyone?
API versioning is the answer—but there’s no single “right” approach. Let’s examine the tradeoffs.
What Counts as a Breaking Change?# Before versioning, understand what actually breaks clients:
Breaking changes:
Removing a field from responses Removing an endpoint Changing a field’s type ("price": "19.99" → "price": 19.99) Renaming a field Changing required request parameters Changing authentication methods Non-breaking changes:
Adding new optional fields to responses Adding new endpoints Adding optional request parameters Adding new enum values (usually) 1
2
3
4
5
6
7
8
// v1 response
{ "id" : 1 , "name" : "Widget" }
// v1.1 response (non-breaking: added field)
{ "id" : 1 , "name" : "Widget" , "created_at" : "2026-03-04" }
// v2 response (breaking: removed field, changed type)
{ "id" : "uuid-123" , "title" : "Widget" }
Strategy 1: URL Path Versioning# The most visible and common approach:
G G E E T T / / a a p p i i / / v 1 2 / / u u s s e e r r s s Implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from fastapi import APIRouter
v1_router = APIRouter ( prefix = "/api/v1" )
v2_router = APIRouter ( prefix = "/api/v2" )
@v1_router.get ( "/users/ {user_id} " )
def get_user_v1 ( user_id : int ):
user = db . get_user ( user_id )
return { "id" : user . id , "name" : user . name } # v1 format
@v2_router.get ( "/users/ {user_id} " )
def get_user_v2 ( user_id : int ):
user = db . get_user ( user_id )
return {
"id" : str ( user . uuid ), # v2: UUIDs instead of ints
"display_name" : user . name , # v2: renamed field
"created_at" : user . created_at . isoformat ()
}
app . include_router ( v1_router )
app . include_router ( v2_router )
Pros:
Crystal clear which version you’re using Easy to route at load balancer/CDN level Simple to document and test Cache-friendly (different URLs = different cache keys) Cons:
URLs feel “cluttered” Encourages big-bang version bumps instead of gradual evolution Can lead to significant code duplication Version in request headers, cleaner URLs:
G A E c T c e / p a t p : i / a u p s p e l r i s c a t i o n / v n d . m y a p i . v 2 + j s o n
Or with a custom header:
G X E - T A P / I a - p V i e / r u s s i e o r n s : 2
Implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from fastapi import Header , HTTPException
@app.get ( "/api/users/ {user_id} " )
def get_user ( user_id : int , x_api_version : int = Header ( default = 1 )):
user = db . get_user ( user_id )
if x_api_version == 1 :
return { "id" : user . id , "name" : user . name }
elif x_api_version == 2 :
return {
"id" : str ( user . uuid ),
"display_name" : user . name ,
"created_at" : user . created_at . isoformat ()
}
else :
raise HTTPException ( 400 , f "Unsupported API version: { x_api_version } " )
Pros:
Clean, resource-focused URLs Follows REST principles (URL = resource, headers = metadata) Easier to default to latest version Cons:
Less discoverable (version hidden in headers) Harder to test in browser Some proxies/caches don’t vary on custom headers properly Clients must remember to set headers Strategy 3: Query Parameter Versioning# Version as a query parameter:
G E T / a p i / u s e r s ? v e r s i o n = 2
Implementation:
1
2
3
4
5
6
7
8
@app.get ( "/api/users/ {user_id} " )
def get_user ( user_id : int , version : int = 1 ):
user = db . get_user ( user_id )
if version == 1 :
return { "id" : user . id , "name" : user . name }
elif version == 2 :
return { "id" : str ( user . uuid ), "display_name" : user . name }
Pros:
Easy to test (just add ?version=2 in browser) No header configuration needed Works everywhere Cons:
Pollutes query string Easy to forget (leads to accidental version mismatches) Mixes resource identification with versioning concerns Strategy 4: No Explicit Versions (Evolutionary Design)# Instead of versions, design for backward compatibility from day one:
1
2
3
4
5
6
7
8
9
10
@app.get ( "/api/users/ {user_id} " )
def get_user ( user_id : int ):
user = db . get_user ( user_id )
return {
"id" : user . id , # Keep forever
"uuid" : str ( user . uuid ), # Added in 2024
"name" : user . name , # Keep forever
"display_name" : user . name , # Added in 2025, preferred
"created_at" : user . created_at . isoformat () # Added in 2024
}
Rules for evolutionary APIs:
Never remove fields (deprecate instead) Never change field types Never rename fields (add new, keep old) Make new request parameters optional with sensible defaults Pros:
No version management overhead Clients upgrade gradually One codebase to maintain Cons:
Response payloads grow over time Can’t fix past mistakes Eventually accumulates cruft Hybrid Approach: Major + Minor Versions# Use URL versioning for major breaking changes, evolutionary design within versions:
/ / a a p p i i / / v 1 2 / / u u s s e e r r s s → → v v 1 2 . . 0 0 , , v v 1 2 . . 1 1 , ( v b 1 r . e 2 a k ( i a n l g l c b h a a c n k g w e a s r d f r c o o m m p v a 1 t ) i b l e ) 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# v1 evolves without breaking
@v1_router.get ( "/users/ {user_id} " )
def get_user_v1 ( user_id : int ):
return {
"id" : user . id ,
"name" : user . name ,
"email" : user . email , # Added in v1.1
"avatar_url" : user . avatar_url , # Added in v1.2
}
# v2 only when absolutely necessary
@v2_router.get ( "/users/ {user_id} " )
def get_user_v2 ( user_id : int ):
return {
"uuid" : str ( user . uuid ), # Breaking: replaced int id
"profile" : { # Breaking: restructured
"name" : user . name ,
"email" : user . email ,
}
}
Version Deprecation# Don’t maintain old versions forever. Establish a policy:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from datetime import datetime
from fastapi import Header , HTTPException
DEPRECATION_DATES = {
1 : datetime ( 2025 , 6 , 1 ), # v1 deprecated June 2025
2 : None , # v2 current
}
SUNSET_DATES = {
1 : datetime ( 2025 , 12 , 1 ), # v1 removed Dec 2025
}
@app.middleware ( "http" )
async def version_warnings ( request , call_next ):
response = await call_next ( request )
version = int ( request . headers . get ( "X-API-Version" , 2 ))
if version in DEPRECATION_DATES and DEPRECATION_DATES [ version ]:
response . headers [ "Deprecation" ] = DEPRECATION_DATES [ version ] . isoformat ()
response . headers [ "Link" ] = '</api/v2>; rel="successor-version"'
if version in SUNSET_DATES and SUNSET_DATES [ version ]:
response . headers [ "Sunset" ] = SUNSET_DATES [ version ] . isoformat ()
return response
Communicate clearly:
Deprecation : “This version is old, please migrate”Sunset : “This version will stop working on X date”Documentation Per Version# Each version needs its own docs:
1
2
3
4
5
6
7
# OpenAPI spec with version
openapi : 3.0.0
info :
title : My API
version : "2.0.0"
servers :
- url : https://api.example.com/v2
Tools like Swagger UI can host multiple versions:
/ / d d o o c c s s / / v 1 2 → → v v 1 2 A A P P I I r r e e f f e e r r e e n n c c e e My Recommendation# For most APIs:
Start with URL versioning (/api/v1/) — it’s explicit and well-understoodUse evolutionary design within versions — don’t bump for every changeBump major versions rarely — only for fundamental restructuringSet clear deprecation timelines — 6-12 months notice minimumMonitor version usage — know when it’s safe to sunset1
2
3
4
5
6
# Track which versions clients use
@app.middleware ( "http" )
async def track_version ( request , call_next ):
version = extract_version ( request . url . path )
metrics . increment ( "api_requests" , tags = { "version" : version })
return await call_next ( request )
When you see v1 traffic drop to near zero, you can safely remove it.
The best API version is the one your clients don’t have to think about. Make upgrades easy, deprecations gentle, and breaking changes rare.