Module 6: Optimistic Locking + Schema Versioning
The `If-Match` header and HTTP 412: optimistic concurrency at the HTTP level
So far, the client sends version in the request body: {"title": "New", "version": 5}. It works, but there's an alternative that fits HTTP better and has been standard since 1997: the If-Match header (RFC 7232) and a 412 Precondition Failed response.
With If-Match, the client sends the version in the headers, not the body. The server validates the HTTP condition before processing. If it doesn't match, it returns a 412 (not a 409). The difference seems subtle but it matters: it fits standard HTTP caches, it lets you use ETags, it separates metadata (the version) from the payload, and it makes your API more interoperable with standard tooling.
In this capsule you're going to learn the If-Match standard, the distinction between 412 and 409, how to implement it in FastAPI, and when to choose between the If-Match header and a body version. Most modern production-ready APIs use both in different contexts — you're going to understand which one fits each case.
The HTTP standard: ETags + If-Match
RFC 7232 defines two primitives:
The ETag header (response): an opaque identifier of the resource's current version. The server includes it in every GET/PUT response.
GET /tasks/123 HTTP/1.1
HTTP/1.1 200 OK
ETag: "5"
Content-Type: application/json
{"id": 123, "title": "Original", "version": 5}
The If-Match header (request): the client sends the ETag it had. The server validates: if it matches the current ETag, it proceeds; if not, it fails with a 412.
PUT /tasks/123 HTTP/1.1
If-Match: "5"
Content-Type: application/json
{"title": "New title"}
HTTP/1.1 412 Precondition Failed
{"error": "version_mismatch", "current_etag": "7", ...}
If the version matches:
PUT /tasks/123 HTTP/1.1
If-Match: "5"
Content-Type: application/json
{"title": "New title"}
HTTP/1.1 200 OK
ETag: "6"
Content-Type: application/json
{"id": 123, "title": "New title", "version": 6}
ETags are opaque to the client — typically strings in quotes. They can be:
- A counter as a string:
"5","6". - A hash of the content:
"a3b4c5d6". - A timestamp:
"2026-05-08T14:32:11Z".
For optimistic locking, a counter is the simplest and maps directly to your version column.
412 Precondition Failed vs 409 Conflict
Both are valid for optimistic locking, with a semantic nuance:
412 Precondition Failed: the client sent an HTTP precondition (If-Match) that wasn't met. The client can infer the resource changed.
409 Conflict: there's a conflict the client has to resolve. It can be from a version mismatch or from other reasons (a duplicate key, etc.).
The common convention:
412when the client usedIf-Matchand the condition failed — a specific code for that situation.409when the version goes in the body (not a header) or when there are other kinds of conflict.
Some APIs use only 409 (simpler). Others use both. The distinction is useful because clients can have separate handlers:
catch (e) {
if (e.status === 412) {
// The precondition failed — re-fetch and retry
} else if (e.status === 409) {
// A domain conflict — show a specific UI
}
}
For this module, we're going to use 412 when If-Match is used and 409 when the version is in the body.
Implementation in FastAPI
from fastapi import APIRouter, Depends, HTTPException, Header, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.exc import StaleDataError
from typing import Optional
router = APIRouter()
@router.get("/tasks/{task_id}")
async def get_task(
task_id: int,
response: Response,
db: AsyncSession = Depends(get_db),
):
task = await db.get(Task, task_id)
if not task:
raise HTTPException(404, "Not found")
# Set the ETag header
response.headers["ETag"] = f'"{task.version}"'
return {
"id": task.id,
"title": task.title,
"status": task.status,
# Don't include the version in the body — it's in the ETag header
}
@router.put("/tasks/{task_id}")
async def update_task(
task_id: int,
update_data: TaskUpdateBody, # No version here
response: Response,
db: AsyncSession = Depends(get_db),
if_match: Optional[str] = Header(None, alias="If-Match"),
):
if not if_match:
raise HTTPException(
status.HTTP_428_PRECONDITION_REQUIRED,
detail="If-Match header required for updates",
)
# Parse the ETag (strip the quotes)
requested_version = int(if_match.strip('"'))
# Fetch
task = await db.get(Task, task_id)
if not task:
raise HTTPException(404, "Not found")
# A pre-check to return a 412 (earlier = better)
if task.version != requested_version:
raise HTTPException(
status.HTTP_412_PRECONDITION_FAILED,
detail={
"error": "version_mismatch",
"current_etag": f'"{task.version}"',
"your_etag": if_match,
"current_state": {
"id": task.id,
"title": task.title,
"status": task.status,
}
}
)
# Apply the changes
for field, value in update_data.model_dump(exclude_unset=True).items():
setattr(task, field, value)
try:
await db.commit()
except StaleDataError:
# A race between the check and the commit (rare but possible)
await db.rollback()
current_task = await db.get(Task, task_id)
raise HTTPException(
status.HTTP_412_PRECONDITION_FAILED,
detail={
"error": "version_mismatch_race",
"current_etag": f'"{current_task.version}"',
# ... the rest of the rich response
}
)
# Set the new ETag in the response
response.headers["ETag"] = f'"{task.version}"'
return {
"id": task.id,
"title": task.title,
"status": task.status,
}
Important things:
428 Precondition Required: if the client doesn't sendIf-Match, return a 428 (RFC 6585). This forces the client to use the pattern.- Strip the quotes from the ETag:
If-Match: "5"comes literally with quotes. Strip before parsing. - A pre-check + a post-check: the pre-check returns a 412 immediately without touching the UPDATE; the post-check (
StaleDataError) covers the race between the check and the commit. - The ETag in both the GET and PUT responses: the client gets the updated ETag in every response and uses it in the next request.
The complete client pattern
class TaskClient {
constructor() {
this.etags = new Map();
}
async get(taskId) {
const response = await fetch(`/tasks/${taskId}`);
const etag = response.headers.get("ETag");
if (etag) {
this.etags.set(taskId, etag);
}
return await response.json();
}
async update(taskId, changes) {
const etag = this.etags.get(taskId);
if (!etag) {
throw new Error("Must GET before PUT");
}
const response = await fetch(`/tasks/${taskId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"If-Match": etag,
},
body: JSON.stringify(changes),
});
if (response.status === 412) {
const body = await response.json();
throw new ConflictError(body);
}
if (response.status === 200) {
const newEtag = response.headers.get("ETag");
this.etags.set(taskId, newEtag);
return await response.json();
}
throw new Error(`Unexpected status: ${response.status}`);
}
}
The client keeps the ETag automatically. The user never sees a "version" — only a "task". The protocol is transparent.
Cases where a body version is preferable to If-Match
Case 1: a complex partial PATCH
If your PATCH accepts complex operations (JSON Patch RFC 6902), the body already has a lot of structure. Adding the version to the body is natural:
PATCH /tasks/123 HTTP/1.1
Content-Type: application/json-patch+json
{
"version": 5,
"patches": [
{"op": "replace", "path": "/status", "value": "completed"},
{"op": "add", "path": "/tags/-", "value": "urgent"}
]
}
Case 2: non-HTTP APIs (WebSocket, gRPC)
If your app uses WebSocket or gRPC, there are no "HTTP headers". A version in the payload is the only option.
Case 3: bulk operations
For a bulk update of multiple resources, each one has its version:
POST /tasks/bulk-update HTTP/1.1
Content-Type: application/json
{
"updates": [
{"id": 1, "version": 3, "status": "completed"},
{"id": 2, "version": 5, "status": "completed"},
{"id": 3, "version": 1, "status": "completed"}
]
}
If-Match is per-request, it doesn't support this naturally.
Case 4: GraphQL
GraphQL doesn't use HTTP semantics richly. A version in the input is standard.
An ETag with a hash instead of a version counter
Some APIs prefer an ETag based on a hash of the content:
import hashlib
import json
def compute_etag(task: Task) -> str:
canonical = json.dumps({
"title": task.title,
"status": task.status,
"assigned_to": task.assigned_to,
}, sort_keys=True)
return hashlib.sha256(canonical.encode()).hexdigest()[:16]
Advantages:
- You don't need a
versioncolumn in the DB. - The ETag only changes if the content changed (idempotent updates don't change the ETag).
Disadvantages:
- Computation on every GET (cacheable but more logic).
- No monotonic counter — you can't order versions chronologically.
- Harder to debug ("what version does the row have?").
The counter is the default. A hash only if you have specific reasons (HTTP caches that depend on an exact hash, idempotency considerations).
HTTP caches and If-None-Match
ETags also enable HTTP caches. The client can ask "did it change since last time?":
GET /tasks/123 HTTP/1.1
If-None-Match: "5"
HTTP/1.1 304 Not Modified
ETag: "5"
If the version is the same, the server returns a 304 with no body — bandwidth saved. Useful for apps that poll resources frequently.
If-None-Match (cache) and If-Match (concurrency) are complementary. The same ETag, different preconditions.
@router.get("/tasks/{task_id}")
async def get_task(
task_id: int,
response: Response,
db: AsyncSession = Depends(get_db),
if_none_match: Optional[str] = Header(None, alias="If-None-Match"),
):
task = await db.get(Task, task_id)
current_etag = f'"{task.version}"'
if if_none_match == current_etag:
# The client has the latest version — 304 with no body
response.status_code = status.HTTP_304_NOT_MODIFIED
response.headers["ETag"] = current_etag
return None
response.headers["ETag"] = current_etag
return {...}
Traps and common mistakes
1. Forgetting to set the ETag in GET responses.
With no ETag in the GET, the client doesn't have the value to use in If-Match. Validate that the GET always returns an ETag.
2. An ETag with no quotes.
The RFC requires quotes: ETag: "5". With no quotes (ETag: 5), some clients ignore it. Always with quotes.
3. Confusing If-Match with If-None-Match.
If-Match: "execute only if it matches" (concurrency).
If-None-Match: "execute only if it does NOT match" (cache).
The same ETag, opposite semantics.
4. Returning a 409 instead of a 412 with If-Match.
Technically valid but it wastes the 412 code that exists specifically for this. Use 412 when the failure is from the If-Match header.
5. Not validating the header's format.
# ❌
requested_version = int(if_match.strip('"'))
# Crashes if if_match is "abc" or "" or None
# ✅
try:
requested_version = int(if_match.strip('"'))
except (ValueError, AttributeError):
raise HTTPException(400, "Invalid If-Match header format")
6. An ETag exposing the internal version value.
A counter as an ETag is fine. But if your version is something sensitive (e.g. information about how many modifications there have been — competitors could infer activity), use an anonymized hash.
7. An ETag for listings.
For GET /tasks/ (a list), the ETag is for the entire listing. Any change in any item invalidates the cache. Consider whether it's worth it — for dynamic listings, it isn't.
8. Mixing If-Match with a body version for no clear reason.
Decide on one or the other and keep it consistent across the API. Mixing confuses clients.
The decision: the If-Match header vs a body version
| Criterion | If-Match | Body version |
|---|---|---|
| HTTP standard | ✅ RFC 7232 | Custom |
| HTTP caches | ✅ Compatible | No |
| Standard tooling | ✅ Postman, curl, etc | More manual |
| Bulk updates | Hard | ✅ Natural |
| WebSocket / gRPC | Doesn't apply | ✅ |
| GraphQL | Doesn't apply | ✅ |
| Visibility for devs | "What's that header?" | More explicit in the body |
Recommendation: for typical REST APIs, If-Match. For specific cases (bulk, GraphQL, WebSocket), a body version. Document it consistently.
Exercise: implement If-Match end-to-end
Setup: the Task model with version_id_col (capsule 03).
Step 1: implement the GET endpoint with an ETag.
@router.get("/tasks/{task_id}")
async def get_task_with_etag(
task_id: int,
response: Response,
db: AsyncSession = Depends(get_db),
):
# ... implement it
pass
Verify with curl:
curl -i http://localhost:8000/tasks/1
# HTTP/1.1 200 OK
# ETag: "5"
# ...
Step 2: implement the PUT with If-Match.
@router.put("/tasks/{task_id}")
async def update_task_with_if_match(
task_id: int,
update_data: TaskUpdateBody,
response: Response,
db: AsyncSession = Depends(get_db),
if_match: Optional[str] = Header(None, alias="If-Match"),
):
# ... implement it
pass
Step 3: test the complete flow.
# GET
curl -i http://localhost:8000/tasks/1
# Capture the ETag
# PUT with the correct ETag
curl -i -X PUT http://localhost:8000/tasks/1 \
-H "If-Match: \"5\"" \
-H "Content-Type: application/json" \
-d '{"title": "Updated"}'
# 200 OK, a new ETag
# PUT with the old ETag
curl -i -X PUT http://localhost:8000/tasks/1 \
-H "If-Match: \"5\"" \
-H "Content-Type: application/json" \
-d '{"title": "Stale update"}'
# 412 Precondition Failed
Step 4: test without If-Match.
curl -i -X PUT http://localhost:8000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"title": "No header"}'
# 428 Precondition Required
Step 5: implement If-None-Match for caching.
@router.get("/tasks/{task_id}")
async def get_task_cacheable(
task_id: int,
response: Response,
db: AsyncSession = Depends(get_db),
if_none_match: Optional[str] = Header(None, alias="If-None-Match"),
):
# ... a 304 if it matches
pass
Test it:
curl -i http://localhost:8000/tasks/1 \
-H "If-None-Match: \"5\""
# 304 Not Modified (no body)
See discussion
Step 1 — the GET with an ETag:
@router.get("/tasks/{task_id}")
async def get_task(task_id, response, db):
task = await db.get(Task, task_id)
if not task:
raise HTTPException(404)
response.headers["ETag"] = f'"{task.version}"'
return {"id": task.id, "title": task.title, "status": task.status}
Step 2 — the PUT with If-Match:
@router.put("/tasks/{task_id}")
async def update_task(task_id, update_data, response, db, if_match=Header(None, alias="If-Match")):
if not if_match:
raise HTTPException(428, "If-Match required")
try:
requested_version = int(if_match.strip('"'))
except ValueError:
raise HTTPException(400, "Invalid ETag")
task = await db.get(Task, task_id)
if not task:
raise HTTPException(404)
if task.version != requested_version:
raise HTTPException(412, detail={
"error": "version_mismatch",
"current_etag": f'"{task.version}"',
"current_state": {...}
})
for field, value in update_data.model_dump(exclude_unset=True).items():
setattr(task, field, value)
try:
await db.commit()
except StaleDataError:
# A race
await db.rollback()
# ... the same 412 response but with a race flag
...
response.headers["ETag"] = f'"{task.version}"'
return {...}
Step 3 — the complete flow:
It works. A 200 with an updated ETag, a 412 with detailed info when it's stale.
Step 4 — without If-Match:
The 428 forces the client to use the pattern.
Step 5 — If-None-Match:
if if_none_match == f'"{task.version}"':
response.status_code = 304
return None
A 304 with no body. The client doesn't transfer bytes unnecessarily.
The key lessons:
- HTTP is rich — using the primitives that already exist is cleaner than reinventing them.
- Correct status codes matter: 412 vs 409, 428 to force the usage, 304 for caching.
- Quotes on the ETag are mandatory.
- A pre-check + a post-check covers both the normal case and the race condition.
Summary and next step
What you learned:
- The
ETagheader in responses: an opaque identifier of the resource's current state. The client uses it in subsequent requests. If-Match: <etag>in requests: an HTTP precondition. The server validates it and returns412 Precondition Failedif it doesn't match.428 Precondition Requiredto force the client to useIf-Matchon mutations.If-None-Match+304for HTTP caching — complementary toIf-Match.412vs409: 412 for an HTTP header failure; 409 for a body version or complex conflicts.- A body version vs
If-Match:If-Matchfor typical REST; a body version for bulk, GraphQL, WebSocket. - An ETag counter vs a hash: the counter is the default, a hash for specific cases.
Before moving on, you should be able to:
- Implement
If-Match+412in FastAPI with a rich response. - Combine
If-MatchandIf-None-Matchin the same endpoint. - Decide between a header and a body version according to the context.
- Configure the client to maintain ETags automatically.
In the next capsule we switch to the module's other side: schema versioning. How do you evolve your API without breaking clients in production? You're going to learn which changes are backward-compatible (adding an optional field, adding a safe enum value), which ones are breaking (changing a type, renaming, removing a field), and the typical anti-patterns that look compatible but break subtly. Capsule 07 covers the formal deprecation strategy with the Deprecation and Sunset headers.
Resources
- RFC 7232 — HTTP Conditional Requests — the official standard.
- RFC 6585 — Additional HTTP Status Codes (
428) —428 Precondition Required. - MDN —
If-Match— the reference with examples. - MDN —
ETag— the reference and patterns. - GitHub API — Conditional requests — a real example with ETags.
- Stripe API — Idempotency keys — an alternative for specific cases.
- PayPal API — Optimistic concurrency — a real case with
If-Match.
Capsule 05 of 08 — Module 6 — SQL Patterns for Production APIs Guide