Module 6: Optimistic Locking + Schema Versioning
Deprecation strategy: headers, metrics, communication
Sometimes you need to remove something. A field that no longer applies to the domain model. An endpoint replaced by a better one. A parameter that was ambiguous. Removing it overnight breaks clients; keeping it forever accumulates debt. The way out is gradual deprecation: you mark the field as obsolete, communicate to the clients with time to spare, measure the usage, and remove it when it's safe.
There are modern HTTP standards for this. The Deprecation: true header (an active Internet-Draft) and the Sunset: <date> header (RFC 8594) tell the client "this is going to disappear". Combined with usage metrics and communication through docs/changelogs, it lets you remove things with no surprises.
In this capsule you're going to learn the exact headers, how to add them in FastAPI, how to track usage metrics for deprecated fields, and the criteria for deciding when it's safe to remove. And you're going to see the strong bias against /v2/ — deprecation discipline makes /v2/ unnecessary in 99% of cases.
The Deprecation and Sunset headers
Deprecation (an Internet-Draft)
HTTP/1.1 200 OK
Deprecation: true
Content-Type: application/json
{...}
It tells the client the endpoint or field is deprecated. The header can have:
Deprecation: true: the whole endpoint is deprecated.Deprecation: <date>: deprecated since that date.
Deprecation: Sun, 01 Jan 2026 00:00:00 GMT
Sunset (RFC 8594)
HTTP/1.1 200 OK
Sunset: Tue, 01 Aug 2026 00:00:00 GMT
It tells you when the endpoint will stop working. The client knows it has until that date to migrate.
Combined
HTTP/1.1 200 OK
Deprecation: Sun, 01 Jan 2026 00:00:00 GMT
Sunset: Tue, 01 Aug 2026 00:00:00 GMT
Link: <https://api.example.com/docs/migration-v2>; rel="deprecation"
Link: rel="deprecation" points to documentation with the migration details. A powerful combination: the deprecation header (when it was marked), the sunset (when it disappears), the link (how to migrate).
Implementation in FastAPI
Approach 1: adding headers in specific endpoints
from datetime import datetime, timezone
from fastapi import APIRouter, Response
router = APIRouter()
@router.get("/legacy/users/{user_id}")
async def get_user_legacy(user_id: int, response: Response):
"""DEPRECATED: use /users/{user_id} instead."""
response.headers["Deprecation"] = "Sun, 01 Jan 2026 00:00:00 GMT"
response.headers["Sunset"] = "Tue, 01 Aug 2026 00:00:00 GMT"
response.headers["Link"] = (
'<https://api.example.com/docs/migration>; rel="deprecation"'
)
# ... the rest of the logic
return {...}
Approach 2: middleware for centralized deprecation
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
DEPRECATED_PATHS = {
"/legacy/users": {
"deprecation": "Sun, 01 Jan 2026 00:00:00 GMT",
"sunset": "Tue, 01 Aug 2026 00:00:00 GMT",
"link": "https://api.example.com/docs/migrate-users",
},
"/legacy/orders": {
"deprecation": "Mon, 15 Mar 2026 00:00:00 GMT",
"sunset": "Sat, 15 Sep 2026 00:00:00 GMT",
"link": "https://api.example.com/docs/migrate-orders",
},
}
class DeprecationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Match the path against the deprecated paths
for prefix, info in DEPRECATED_PATHS.items():
if request.url.path.startswith(prefix):
response.headers["Deprecation"] = info["deprecation"]
response.headers["Sunset"] = info["sunset"]
response.headers["Link"] = (
f'<{info["link"]}>; rel="deprecation"'
)
break
return response
app = FastAPI()
app.add_middleware(DeprecationMiddleware)
Centralizing deprecation in middleware lets you change dates without touching each endpoint.
Deprecation at the field level (not the whole endpoint)
If you only deprecate a specific field:
class UserResponse(BaseModel):
id: int
email: str
name: str
legacy_username: Optional[str] = None # Deprecated
username_handle: str # The replacement
@router.get("/users/{user_id}")
async def get_user(user_id: int, response: Response):
user = await fetch_user(user_id)
# If the endpoint isn't deprecated but it has deprecated fields,
# add an informative header
response.headers["Deprecation"] = (
'field "legacy_username" deprecated, use "username_handle"'
)
return UserResponse(
id=user.id,
email=user.email,
name=user.name,
legacy_username=user.username_handle, # backward compat
username_handle=user.username_handle,
)
A Deprecation header with a description isn't standard but it's useful. An alternative: comment it in the docs and use the Warning header (nearly obsolete now but still used).
Tracking usage to decide on removal
Before removing something deprecated, you need data: who uses it? how often? is it safe to remove?
Logging per endpoint
@router.get("/legacy/users/{user_id}")
async def get_user_legacy(user_id: int, request: Request, response: Response):
# Log the usage
logger.info(
"deprecated_endpoint_used",
extra={
"endpoint": "/legacy/users/{user_id}",
"client_ip": request.client.host,
"user_agent": request.headers.get("user-agent"),
"api_key_id": extract_api_key(request), # if you have auth
}
)
# A metric
metrics.increment(
"api.deprecated_usage",
tags={"endpoint": "/legacy/users"}
)
response.headers["Deprecation"] = "true"
# ... the rest
Logging per field
For individual fields, track when they're sent by the client (in requests) or when they're accessed explicitly:
@router.post("/users")
async def create_user(data: UserCreateRequest):
# Detect the usage of a deprecated field
if data.legacy_username is not None:
logger.info(
"deprecated_field_used",
extra={
"field": "legacy_username",
"endpoint": "POST /users",
}
)
metrics.increment(
"api.deprecated_field_usage",
tags={"field": "legacy_username"}
)
A deprecation dashboard
With metrics in Prometheus/Datadog:
# Deprecated endpoints
api.deprecated_usage{endpoint="/legacy/users"} = 234 calls/hr
api.deprecated_usage{endpoint="/legacy/orders"} = 12 calls/hr
# Deprecated fields
api.deprecated_field_usage{field="legacy_username"} = 1,234 calls/hr
api.deprecated_field_usage{field="user_id"} = 5 calls/hr # almost nobody uses it
A dashboard with that data tells you when it's safe to remove. The rule: usage < 0.1% of the total for 1-2 months → safe to remove.
Criteria for deciding when to remove
Don't remove if:
- Usage > 1% of the total calls to the parent endpoint.
- Some important client (high revenue, contractual) is still using it.
- The sunset date hasn't arrived.
- You didn't give enough time (a minimum of 3 months, ideally 6+).
- You didn't communicate through formal channels (changelog, email, docs).
Remove if:
- Usage < 0.1% for 1-2 consecutive months.
- The sunset date has passed.
- You communicated extensively.
- You've identified the remaining clients and contacted them personally.
A real case: a typical deprecation timeline
Month 0: The deprecation announcement (changelog, blog post, email to customers)
Deprecation + Sunset headers added
Month 0-1: Support answers questions, helps with the migration
Month 3: A reminder email
Month 5: A "1 month before the sunset" email
Month 6: The sunset date — the endpoint still works but you communicate that it will stop
Month 7: The endpoint starts returning `410 Gone` for new clients, keeps
working for identified historical clients
Month 8-9: The remaining clients contacted individually
Month 12: The endpoint removed completely (`410 Gone` for everyone)
12 months is typical for responsible deprecation. Large public APIs (Stripe, GitHub) are even more conservative — years, not months.
Communication: how to warn the clients
Headers alone aren't enough. Multiple channels:
1. A public changelog
# CHANGELOG
## 2026-01-15
### Deprecated
- `GET /legacy/users/{id}`: replaced by `GET /users/{id}` with new schema.
Sunset date: 2026-08-01. Migration guide: /docs/migrate-users.
### Added
- `GET /users/{id}` returning new schema with `username_handle` instead
of `legacy_username`.
2. An email to customers
Subject: API Deprecation Notice — Action Required by 2026-08-01
Hi [name],
We're deprecating `GET /legacy/users/{id}` in our API. Based on logs,
your account is using this endpoint. The endpoint will continue to work
until 2026-08-01.
To migrate:
1. Use `GET /users/{id}` instead.
2. Replace `legacy_username` with `username_handle`.
Migration guide: https://api.example.com/docs/migrate-users
Need help? Reply to this email.
The email is triggered only to clients who actually use the endpoint (using the usage logs).
3. A status page / blog post
For large deprecations (schema changes, the removal of whole endpoints), a blog post with:
- Why it's changing.
- What exactly changes.
- How to migrate.
- The timeline.
4. SDK warnings
If you have official SDKs in Python/JS/Ruby/etc., emit a DeprecationWarning when the deprecated thing gets used:
# In the SDK
import warnings
def get_user_legacy(user_id):
warnings.warn(
"get_user_legacy is deprecated, use get_user instead. "
"Will be removed in 2026-08-01.",
DeprecationWarning,
stacklevel=2,
)
# ... call the legacy API
Devs see the warning in their logs. More visible than headers alone.
Anti-patterns in deprecation
1. A sunset date without tracking usage
Setting a date without knowing whether the clients migrated. The day arrives, you remove it, you break apps in customers' production who never found out. Track first, decide afterward.
2. A deprecation with no sunset
Deprecation: true
With no Sunset, the clients don't know how much time they have. They can ignore the header indefinitely. Always include a sunset date.
3. A sunset that's too short
A sunset 1 month after announcing the deprecation isn't reasonable for clients with release processes of several weeks. A minimum of 3 months, ideally 6+.
4. Changing the sunset multiple times
If you extend the sunset 5 times, the clients stop taking deprecation seriously. Be conservative on the first date, and keep it.
5. No migration guide
"Use the new endpoint" isn't enough. The migration guide has to include:
- A diff between the old and new schema.
- Before/after code examples.
- A testing approach.
- An FAQ with common cases.
6. Removing with no prior warning
Removing something with no prior Deprecation/Sunset headers breaks any client that depends on it. This is what destroys trust in APIs.
7. Deprecation applied only to "public" usage
If your API has internal and external clients, both need a deprecation timeline. Assuming the internal ones "are fine" because they're internal isn't an excuse — their releases take time too.
8. Tracking only the endpoint, not the field
If you only deprecate a field inside the endpoint, endpoint-level metrics don't tell you whether removing it is safe. Log when that specific field gets used.
A special case: deprecation with auto-migration
For some changes, the server can auto-migrate transparently:
@router.post("/users")
async def create_user(data: dict): # Accept a raw dict
# Detect the old format
if "legacy_username" in data:
logger.info("deprecated_field_auto_migrated", extra={"field": "legacy_username"})
data["username_handle"] = data.pop("legacy_username")
# Continue with the new model
user_data = UserCreateNew(**data)
return await create_user_internal(user_data)
The client keeps sending the old format, the server translates. It allows deprecation without urgency. But careful:
- Eventually you remove the migration logic → that day it does break.
- Log it so you know how many clients depend on the old format.
- Don't abuse it — auto-migrate only for "easy" changes (a rename, a simple format).
Traps and common mistakes
1. Headers in hardcoded strings with the wrong format.
Sunset: 2026-08-01 isn't valid. The RFC requires the HTTP date format:
Sunset: Tue, 01 Aug 2026 00:00:00 GMT
Use email.utils.formatdate(datetime, usegmt=True):
from email.utils import formatdate
from datetime import datetime, timezone
sunset = datetime(2026, 8, 1, 0, 0, 0, tzinfo=timezone.utc)
sunset_header = formatdate(sunset.timestamp(), usegmt=True)
# 'Tue, 01 Aug 2026 00:00:00 GMT'
2. Not tracking the deprecated thing before putting the headers up.
Headers with no tracking don't give you the information to decide when to remove. Implement tracking BEFORE announcing the deprecation.
3. Forgetting the deprecation in the SDK but putting it in the API.
If your Python SDK doesn't emit a DeprecationWarning when it uses deprecated endpoints, the devs don't find out. Sync the SDK with the API headers.
4. Logging deprecated usage as ERROR.
It's expected behavior, not an error. INFO or a separate metric.
5. A sunset on the same day as the deprecation.
Deprecation: 2026-05-08
Sunset: 2026-05-08
It makes no sense — you're not giving the client time to migrate.
6. Using the Warning header instead of Deprecation.
Warning (RFC 7234) is for caches. Deprecation is the right one for announcing a deprecation. Don't confuse them.
7. Changing the migration guide's URL.
If the headers point to /docs/migration-v2 and you later rename that page, the old headers end up pointing at a 404. Keep the URL stable or use redirects.
8. Pretending the clients read the header.
Most clients don't inspect headers automatically. Headers are "available information" — direct communication (email, blog, SDK warnings) is what actually warns them.
Exercise: implement a complete deprecation
Setup: a FastAPI app with the endpoints /legacy/users/{id} (deprecated) and /users/{id} (the new one).
Step 1: add deprecation middleware to /legacy/*.
from email.utils import formatdate
from datetime import datetime, timezone
SUNSET_DATE = datetime(2026, 8, 1, 0, 0, 0, tzinfo=timezone.utc)
SUNSET_HEADER = formatdate(SUNSET_DATE.timestamp(), usegmt=True)
class DeprecationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
if request.url.path.startswith("/legacy"):
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = SUNSET_HEADER
response.headers["Link"] = (
'<https://api.example.com/docs/migrate>; rel="deprecation"'
)
return response
app.add_middleware(DeprecationMiddleware)
Step 2: add tracking.
@router.get("/legacy/users/{user_id}")
async def get_user_legacy(user_id, request, response):
logger.info("deprecated_endpoint_used", extra={
"endpoint": "/legacy/users/{id}",
"client": request.headers.get("user-agent"),
})
metrics.increment("api.deprecated_usage", tags={"endpoint": "/legacy/users"})
# ... the rest
Step 3: verify the headers with curl.
curl -i http://localhost:8000/legacy/users/1
# HTTP/1.1 200 OK
# Deprecation: true
# Sunset: Tue, 01 Aug 2026 00:00:00 GMT
# Link: <https://api.example.com/docs/migrate>; rel="deprecation"
Step 4: simulate a monitoring dashboard.
# A mock dashboard query
@router.get("/internal/deprecated-usage")
async def deprecated_usage_report():
"""Returns the usage of deprecated endpoints in the last 24h."""
return {
"/legacy/users": {"calls_24h": 234, "unique_clients": 12},
"/legacy/orders": {"calls_24h": 12, "unique_clients": 2},
}
Step 5: make the "go/no-go" call on removal.
Given the dashboard:
/legacy/users: 234 calls/24h, 12 unique clients./legacy/orders: 12 calls/24h, 2 unique clients.
Question: which one is safe to remove today if the sunset date is in 1 month? What do you do in both cases?
See discussion
The analysis:
/legacy/users: 234 calls/day with 12 active clients. Too much usage to remove yet. The steps:
- Identify the 12 clients (from the logs).
- An individual email to each one with a migration guide.
- Wait 1 month, re-measure.
- If it drops to <5 clients, contact them individually with a stricter timeline.
- Eventually when it's <0.1%, remove it.
/legacy/orders: 12 calls/day with 2 clients. Closer to removable. The steps:
- Identify the 2 clients.
- A personal email asking them to migrate.
- If they're willing, migrate before the sunset.
- After the sunset, consider a
410 Gonewith a redirect to the docs.
The key lessons:
- Headers are communication, not enforcement. You remove based only on real usage, not on an arbitrary sunset date.
- Identifying individual clients lets you make personal contact — more effective than a generic email.
- The sunset date can pass without removal if usage is still high. Better to extend than to break.
- Proactive communication (emails, calls with important clients) reduces the friction when you finally remove.
Summary and next step
What you learned:
- The
Deprecationheader: announces that something is deprecated. A boolean or a date. - The
Sunsetheader (RFC 8594): when it will stop working. Link: rel="deprecation": points to the migration guide.- The HTTP date format is mandatory:
formatdate(timestamp, usegmt=True). - Track the usage before putting the headers up — you need data to decide.
- Multi-channel communication: a changelog, an email to active clients, a blog post, SDK warnings.
- A typical timeline: the announcement → 6 months of transition → the sunset → removal.
- Anti-patterns: a sunset with no tracking, a short sunset, removing with no prior warning, no migration guide.
Before moving on, you should be able to:
- Implement
Deprecation/Sunsetheaders in FastAPI with middleware. - Track usage with logging and metrics.
- Decide when it's safe to remove based on data.
- Communicate a deprecation through the right channels.
In the next capsule you close the module with the integrative project: TaskFlow with optimistic locking + three schema versions (v1 base, v2 adds a compatible priority, v3 deprecates the old field). You're going to see everything in the module applied in a real app with tests that demonstrate that clients of each version work simultaneously.
Resources
- RFC 8594 — The Sunset HTTP Header Field — the official reference.
- Deprecation HTTP Header (Internet-Draft) — the active draft.
- Stripe — API Versioning — a real case of gradual deprecation.
- GitHub — API Deprecation — the real process.
- Twilio — API Deprecation Notices — public examples.
- Microsoft REST API Guidelines — the corporate process.
- Python
email.utils.formatdate— the reference for HTTP dates.
Capsule 07 of 08 — Module 6 — SQL Patterns for Production APIs Guide