API Versioning Strategies: When and How to Break Things
Practical approaches to API versioning — URL paths, headers, query params — and strategies for managing breaking changes gracefully.
February 19, 2026 · 8 min · 1492 words · Rob Washington
Table of Contents
Every API eventually needs to change in ways that break existing clients. The question isn’t whether to version — it’s how to version in a way that doesn’t make your users hate you.
// Phase 1: Mark deprecated in docs, keep returning
{"id":1,"name":"Alice","email":"alice@example.com"}// Phase 2: Return null/empty (after warning period)
{"id":1,"name":"Alice","email":null}// Phase 3: Remove in next major version
{"id":1,"name":"Alice"}
defserialize_user(user,include_legacy=True):data={'id':user.id,'displayName':user.name,# New name}ifinclude_legacy:data['name']=user.name# Old name, deprecatedreturndata
// Before: price as number
{"price":19.99}// After: add structured price, keep old
{"price":19.99,"priceDetails":{"amount":1999,"currency":"USD","formatted":"$19.99"}}
# Before@app.route('/api/search')defsearch():query=request.args['q']returndo_search(query)# After - new parameter with default@app.route('/api/search')defsearch():query=request.args['q']limit=request.args.get('limit',20)# Optional with defaultinclude_archived=request.args.get('include_archived','false')# Optionalreturndo_search(query,limit,include_archived=='true')
importloggingfromcollectionsimportdefaultdictfromdatetimeimportdatetimedeprecation_warnings=defaultdict(int)deftrack_deprecated_usage(version,endpoint,client_id):"""Track who's still using deprecated versions"""key=f"{version}:{endpoint}:{client_id}"deprecation_warnings[key]+=1# Log periodically, not every requestifdeprecation_warnings[key]==1:logging.warning(f"Client {client_id} using deprecated {version} endpoint {endpoint}")@app.route('/api/v1/users/<int:user_id>')defget_user_v1(user_id):client_id=request.headers.get('X-Client-ID','unknown')track_deprecated_usage('v1','/users',client_id)# ... handle request
importrequestsfrompackagingimportversionclassAPIClient:def__init__(self,base_url,preferred_version='v2'):self.base_url=base_urlself.preferred_version=preferred_versionself.session=requests.Session()defrequest(self,method,path,**kwargs):url=f"{self.base_url}/api/{self.preferred_version}{path}"response=self.session.request(method,url,**kwargs)# Check for deprecation warningsifresponse.headers.get('Deprecation')=='true':sunset=response.headers.get('Sunset')successor=response.headers.get('Link')logging.warning(f"API version {self.preferred_version} deprecated. "f"Sunset: {sunset}. Upgrade to: {successor}")returnresponsedefget_user(self,user_id):response=self.request('GET',f'/users/{user_id}')data=response.json()# Handle field name changes gracefullyreturn{'id':data['id'],'name':data.get('displayName')ordata.get('name'),'avatar':data.get('avatarUrl')}
GraphQL sidesteps versioning by making all fields explicitly requested:
1
2
3
4
5
6
7
8
# Clients request exactly what they needquery{user(id:123){iddisplayName# New field# email not requested = no breaking change when removed}}
Deprecation is built into the schema:
1
2
3
4
5
6
typeUser{id:ID!displayName:String!name:String@deprecated(reason:"Use displayName instead")email:String@deprecated(reason:"Removed for privacy. Use contactEmail on Profile.")}
The trade-off: more complex tooling, potential for expensive queries.