Module 6: Optimistic Locking + Schema Versioning
Mini-project: TaskFlow with optimistic locking + 3 schema versions
You close the module by applying everything you learned in a real app. TaskFlow is a task-management API you're going to build with two production-ready features: optimistic locking via the If-Match header + a version column, and schema versioning with three evolving versions — v1 base, v2 adds priority (compatible), v3 gradually deprecates legacy_status (keeping backward-compat).
By the end you have a demo app that demonstrates:
- Two concurrent clients editing the same task — the second gets a
412with rich info to resolve it. - A client with the v1 schema (installed months ago) consuming the app with the v3 schema without breaking — new fields ignored, deprecated fields still present.
DeprecationandSunsetheaders correctly set on the v3 endpoints.- Automated tests that verify that clients of each version work.
This is the canonical pattern you're going to reuse every time you build an endpoint with writes and a schema that can evolve. And it's a deliverable that demonstrates mastery in code reviews and interviews.
Project structure
taskflow/
├── README.md
├── docker-compose.yml
├── requirements.txt
├── alembic.ini
├── alembic/
│ └── versions/
│ ├── 001_initial.py
│ ├── 002_add_priority.py
│ └── 003_add_due_at_deprecate_legacy_status.py
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── database.py
│ ├── models.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── v1.py
│ │ ├── v2.py
│ │ └── v3.py
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── tasks_v1.py
│ │ ├── tasks_v2.py
│ │ └── tasks_v3.py
│ └── middleware.py
└── tests/
├── test_optimistic_locking.py
├── test_schema_v1_compat.py
├── test_schema_v2_compat.py
└── test_schema_v3_deprecation.py
The model and the migrations
The final model (after the 3 migrations)
# app/models.py
from datetime import date, datetime, timezone
from typing import Optional
from sqlalchemy import String, Integer, Date, DateTime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
status: Mapped[str] = mapped_column(String(50), default="pending")
legacy_status: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # Deprecated in v3
priority: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Added in v2
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
due_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) # Added in v3
version: Mapped[int] = mapped_column(default=1)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
__mapper_args__ = {
"version_id_col": "version",
}
Migration 001: the base schema
# alembic/versions/001_initial.py
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table(
'tasks',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('title', sa.String(200), nullable=False),
sa.Column('status', sa.String(50), nullable=False, server_default='pending'),
sa.Column('legacy_status', sa.String(50), nullable=True),
sa.Column('due_date', sa.Date, nullable=True),
sa.Column('version', sa.Integer, nullable=False, server_default='1'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
)
def downgrade():
op.drop_table('tasks')
Migration 002: add priority (compatible)
# alembic/versions/002_add_priority.py
def upgrade():
op.add_column(
'tasks',
sa.Column('priority', sa.Integer, nullable=True)
)
def downgrade():
op.drop_column('tasks', 'priority')
Migration 003: add due_at, deprecate legacy_status
# alembic/versions/003_add_due_at_deprecate_legacy_status.py
def upgrade():
op.add_column(
'tasks',
sa.Column('due_at', sa.DateTime(timezone=True), nullable=True)
)
# legacy_status stays nullable — the deprecation is at the API level, not the DB
# Backfill: copy legacy_status → status if status is null (defensive)
op.execute("""
UPDATE tasks
SET status = legacy_status
WHERE status IS NULL AND legacy_status IS NOT NULL
""")
def downgrade():
op.drop_column('tasks', 'due_at')
The Pydantic schemas per version
v1 (the original schema)
# app/schemas/v1.py
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class TaskBase(BaseModel):
"""The v1 base — the original essential fields."""
model_config = ConfigDict(extra='ignore') # Forward-compatible
title: str
status: str = "pending"
legacy_status: Optional[str] = None
due_date: Optional[date] = None
class TaskCreateV1(TaskBase):
pass
class TaskUpdateV1(BaseModel):
model_config = ConfigDict(extra='ignore')
title: Optional[str] = None
status: Optional[str] = None
legacy_status: Optional[str] = None
due_date: Optional[date] = None
class TaskResponseV1(TaskBase):
id: int
created_at: datetime
v2 (adds priority — compatible)
# app/schemas/v2.py
from typing import Optional
from app.schemas.v1 import TaskBase, TaskCreateV1, TaskUpdateV1, TaskResponseV1
class TaskCreateV2(TaskCreateV1):
priority: Optional[int] = None # New, optional, default None
class TaskUpdateV2(TaskUpdateV1):
priority: Optional[int] = None
class TaskResponseV2(TaskResponseV1):
priority: Optional[int] = None # New in the response
v3 (adds due_at, deprecates legacy_status)
# app/schemas/v3.py
from datetime import datetime
from typing import Optional
from pydantic import ConfigDict, Field
from app.schemas.v2 import TaskCreateV2, TaskUpdateV2, TaskResponseV2
class TaskCreateV3(TaskCreateV2):
"""v3 — adds due_at, keeps legacy_status for backward-compat."""
due_at: Optional[datetime] = None
class TaskUpdateV3(TaskUpdateV2):
due_at: Optional[datetime] = None
class TaskResponseV3(TaskResponseV2):
"""v3 — includes due_at, keeps legacy_status (deprecated)."""
due_at: Optional[datetime] = None
# legacy_status is still present in the response, inherited from v1
# but the endpoint marks the field as deprecated in the docs
The v3 endpoint with optimistic locking + deprecation
# app/routers/tasks_v3.py
from datetime import datetime, timezone
from email.utils import formatdate
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.exc import StaleDataError
from app.database import get_db
from app.models import Task
from app.schemas.v3 import TaskCreateV3, TaskUpdateV3, TaskResponseV3
router = APIRouter(prefix="/v3/tasks", tags=["tasks-v3"])
# The sunset date for the legacy_status field
SUNSET_DATE = datetime(2026, 12, 1, 0, 0, 0, tzinfo=timezone.utc)
SUNSET_HEADER = formatdate(SUNSET_DATE.timestamp(), usegmt=True)
def _to_response(task: Task) -> TaskResponseV3:
return TaskResponseV3(
id=task.id,
title=task.title,
status=task.status,
legacy_status=task.legacy_status,
priority=task.priority,
due_date=task.due_date,
due_at=task.due_at,
created_at=task.created_at,
)
def _add_deprecation_headers_if_needed(task: Task, response: Response):
"""Add Deprecation/Sunset headers if the client is using deprecated fields."""
if task.legacy_status is not None:
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = SUNSET_HEADER
response.headers["Link"] = (
'<https://api.example.com/docs/v3-migration>; '
'rel="deprecation"'
)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_task(
data: TaskCreateV3,
response: Response,
db: AsyncSession = Depends(get_db),
) -> TaskResponseV3:
# Track whether the client is using legacy_status
if data.legacy_status is not None:
# In production: a log + a metric
print(f"[deprecated] legacy_status used in POST /v3/tasks")
task = Task(
title=data.title,
status=data.status,
legacy_status=data.legacy_status,
priority=data.priority,
due_date=data.due_date,
due_at=data.due_at,
)
db.add(task)
await db.commit()
response.headers["ETag"] = f'"{task.version}"'
_add_deprecation_headers_if_needed(task, response)
return _to_response(task)
@router.get("/{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"),
) -> Optional[TaskResponseV3]:
task = await db.get(Task, task_id)
if not task:
raise HTTPException(404, "Task not found")
current_etag = f'"{task.version}"'
# If-None-Match for caching
if if_none_match == current_etag:
response.status_code = status.HTTP_304_NOT_MODIFIED
response.headers["ETag"] = current_etag
return None
response.headers["ETag"] = current_etag
_add_deprecation_headers_if_needed(task, response)
return _to_response(task)
@router.put("/{task_id}")
async def update_task(
task_id: int,
data: TaskUpdateV3,
response: Response,
db: AsyncSession = Depends(get_db),
if_match: Optional[str] = Header(None, alias="If-Match"),
) -> TaskResponseV3:
if not if_match:
raise HTTPException(
status.HTTP_428_PRECONDITION_REQUIRED,
detail="If-Match header required for updates"
)
try:
requested_version = int(if_match.strip('"'))
except ValueError:
raise HTTPException(400, "Invalid If-Match header format")
task = await db.get(Task, task_id)
if not task:
raise HTTPException(404, "Task not found")
# Capture the original state for the diff in case of a conflict
original_state = {
"title": task.title,
"status": task.status,
"legacy_status": task.legacy_status,
"priority": task.priority,
"due_date": task.due_date.isoformat() if task.due_date else None,
"due_at": task.due_at.isoformat() if task.due_at else None,
}
# The pre-check
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": _to_response(task).model_dump(mode='json'),
}
)
# Track deprecated field usage
changes = data.model_dump(exclude_unset=True)
if "legacy_status" in changes:
print(f"[deprecated] legacy_status used in PUT /v3/tasks/{task_id}")
# Apply the changes
for field, value in changes.items():
setattr(task, field, value)
try:
await db.commit()
except StaleDataError:
# A race between the check and the commit
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}"',
"current_state": _to_response(current_task).model_dump(mode='json'),
"your_changes": changes,
}
)
response.headers["ETag"] = f'"{task.version}"'
_add_deprecation_headers_if_needed(task, response)
return _to_response(task)
Optimistic locking tests
# tests/test_optimistic_locking.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_concurrent_updates_second_fails_with_412(client: AsyncClient):
# Create a task
create_response = await client.post("/v3/tasks", json={
"title": "Concurrent test",
"status": "pending"
})
assert create_response.status_code == 201
task_id = create_response.json()["id"]
initial_etag = create_response.headers["ETag"]
# Client A reads the task
get_a = await client.get(f"/v3/tasks/{task_id}")
etag_a = get_a.headers["ETag"]
# Client B also reads it (the same etag)
get_b = await client.get(f"/v3/tasks/{task_id}")
etag_b = get_b.headers["ETag"]
assert etag_a == etag_b
# Client A updates first
update_a = await client.put(
f"/v3/tasks/{task_id}",
json={"title": "Updated by A"},
headers={"If-Match": etag_a},
)
assert update_a.status_code == 200
# Client B tries with the old etag
update_b = await client.put(
f"/v3/tasks/{task_id}",
json={"title": "Updated by B"},
headers={"If-Match": etag_b},
)
assert update_b.status_code == 412
assert update_b.json()["detail"]["error"] == "version_mismatch"
assert update_b.json()["detail"]["current_etag"] == update_a.headers["ETag"]
@pytest.mark.asyncio
async def test_update_without_if_match_returns_428(client: AsyncClient):
create = await client.post("/v3/tasks", json={"title": "T"})
task_id = create.json()["id"]
# With no If-Match
response = await client.put(
f"/v3/tasks/{task_id}",
json={"title": "Updated"}
)
assert response.status_code == 428
@pytest.mark.asyncio
async def test_get_with_if_none_match_returns_304(client: AsyncClient):
create = await client.post("/v3/tasks", json={"title": "T"})
task_id = create.json()["id"]
etag = create.headers["ETag"]
# A GET with the current etag
response = await client.get(
f"/v3/tasks/{task_id}",
headers={"If-None-Match": etag}
)
assert response.status_code == 304
Multi-version compatibility tests
A v1 client consuming a v3 endpoint
# tests/test_schema_v1_compat.py
import pytest
from pydantic import BaseModel, ConfigDict
from httpx import AsyncClient
# A simulation of a v1 client that only knows the v1 fields
class TaskResponseV1Client(BaseModel):
"""The schema a client with the v1 SDK would have."""
model_config = ConfigDict(extra='ignore') # Forward-compatible
id: int
title: str
status: str
legacy_status: str | None = None
due_date: str | None = None
@pytest.mark.asyncio
async def test_v1_client_consumes_v3_endpoint_successfully(client: AsyncClient):
# The v3 server creates a task with v3 fields (priority, due_at)
create = await client.post("/v3/tasks", json={
"title": "Test",
"status": "pending",
"priority": 5,
"due_at": "2026-12-01T00:00:00Z",
})
assert create.status_code == 201
task_id = create.json()["id"]
# The v1 client consumes the v3 endpoint
get_response = await client.get(f"/v3/tasks/{task_id}")
response_body = get_response.json()
# The v1 client parses with its schema (which ignores the new fields)
task_v1 = TaskResponseV1Client(**response_body)
# It works — the v1 client sees the fields it knows
assert task_v1.id == task_id
assert task_v1.title == "Test"
assert task_v1.status == "pending"
# The v1 client does NOT crash even though the response has priority and due_at
@pytest.mark.asyncio
async def test_v1_client_ignores_new_v3_fields(client: AsyncClient):
"""Verify that extra='ignore' works correctly."""
create = await client.post("/v3/tasks", json={
"title": "T",
"priority": 3,
"due_at": "2026-06-01T00:00:00Z",
})
response_body = create.json()
# These fields are in the v3 response
assert "priority" in response_body
assert "due_at" in response_body
# But the v1 client ignores them
task_v1 = TaskResponseV1Client(**response_body)
assert not hasattr(task_v1, "priority")
assert not hasattr(task_v1, "due_at")
The deprecation headers working
# tests/test_schema_v3_deprecation.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_deprecation_headers_set_when_using_legacy_status(client: AsyncClient):
# Create a task using legacy_status
create = await client.post("/v3/tasks", json={
"title": "Test",
"legacy_status": "open", # a deprecated field
})
assert create.status_code == 201
assert create.headers.get("Deprecation") == "true"
assert "Sunset" in create.headers
assert "deprecation" in create.headers.get("Link", "")
@pytest.mark.asyncio
async def test_no_deprecation_headers_when_not_using_legacy_status(client: AsyncClient):
# With no legacy_status
create = await client.post("/v3/tasks", json={
"title": "Modern task",
"status": "pending",
})
assert create.status_code == 201
assert "Deprecation" not in create.headers
@pytest.mark.asyncio
async def test_legacy_status_still_works_for_backward_compat(client: AsyncClient):
# An old client sends legacy_status — it has to work but with the header
create = await client.post("/v3/tasks", json={
"title": "Legacy client",
"legacy_status": "in_progress",
})
assert create.status_code == 201
body = create.json()
assert body["legacy_status"] == "in_progress"
assert create.headers.get("Deprecation") == "true"
The BENCHMARKS.md and the deliverables
# TaskFlow — Optimistic Locking + Schema Versioning
A demo API that demonstrates the patterns of:
1. Optimistic locking with `If-Match`/`412`
2. Evolutionary schema versioning (v1 → v2 → v3)
3. Gradual deprecation with `Deprecation`/`Sunset` headers
## Setup
```bash
docker-compose up -d
alembic upgrade head
uvicorn app.main:app --reload
Tests
pytest tests/ -v
# tests/test_optimistic_locking.py: 3 passed
# tests/test_schema_v1_compat.py: 2 passed
# tests/test_schema_v3_deprecation.py: 3 passed
Demo scenarios
Scenario 1: A concurrent edit (optimistic locking)
# Browser A
curl -i http://localhost:8000/v3/tasks/1
# ETag: "5"
# Browser B
curl -i http://localhost:8000/v3/tasks/1
# ETag: "5" (the same)
# A updates first
curl -i -X PUT http://localhost:8000/v3/tasks/1 \
-H 'If-Match: "5"' \
-d '{"title": "A"}'
# 200 OK, ETag: "6"
# B tries with the old etag
curl -i -X PUT http://localhost:8000/v3/tasks/1 \
-H 'If-Match: "5"' \
-d '{"title": "B"}'
# 412 Precondition Failed
# {"error": "version_mismatch", "current_etag": "\"6\"", ...}
Scenario 2: A v1 client consuming v3
Without installing anything new in the v1 client, it just keeps consuming:
- It receives extra fields (priority, due_at) that it ignores.
- It keeps working with the v1 fields (title, status, due_date, legacy_status).
Scenario 3: Gradual deprecation
If the client uses legacy_status:
curl -i http://localhost:8000/v3/tasks/1
# If the task has legacy_status:
# Deprecation: true
# Sunset: Tue, 01 Dec 2026 00:00:00 GMT
# Link: <https://...>; rel="deprecation"
Key takeaways
If-Match+412is an HTTP standard, it fits with caches and standard tooling.extra='ignore'in Pydantic clients enables forward-compatibility automatically.- Gradual deprecation with metrics + headers + communication is the only responsible way to remove features.
- A single API version (v3) can serve v1, v2, and v3 clients simultaneously.
---
## Module wrap-up
By completing this project you have:
- A public repo (upload it to GitHub) with optimistic locking + schema versioning code.
- Automated tests that verify concurrency and compatibility.
- A `BENCHMARKS.md` with reproducible demo scenarios.
- A mental pattern to apply in any real API.
What you learned in module 6:
- **Capsule 02:** The optimistic vs pessimistic decision with a matrix.
- **Capsule 03:** The native SQLAlchemy `version_id_col` implementation.
- **Capsule 04:** `StaleDataError` → a `409` response with rich info.
- **Capsule 05:** The `If-Match` header + `412 Precondition Failed`.
- **Capsule 06:** Backward-compatible vs breaking changes.
- **Capsule 07:** The deprecation strategy with `Deprecation`/`Sunset` + metrics.
- **Capsule 08:** The integrative project combining everything.
Before moving on to module 7 you should be able to:
- Implement optimistic locking end-to-end in any mutating endpoint.
- Design an evolution path for an API that's going to live for years.
- Argue against `/v2/` when somebody proposes it.
- Apply gradual deprecation with the right headers and tracking.
---
## We start in the next module
**Module 7** closes the patterns before the complete final project. Topic: **Bulk Operations**. Until now we've talked about "normal" queries — one row at a time, one concurrent UPDATE. But there are cases where you need to insert 100k rows at once (imports, ETL, backfills). You're going to learn PostgreSQL's patterns for doing it at real speed (`COPY`, batch inserts), atomic upserts with `ON CONFLICT`, and when to go bulk vs when to iterate individually.
Before moving on:
- Upload the TaskFlow repo to public GitHub.
- Link it in your portfolio.
- Document the `README.md` well with reproducible instructions.
---
## Resources
1. [SQLAlchemy 2.0 — Versioning](https://docs.sqlalchemy.org/en/20/orm/versioning.html) — the official reference.
2. [RFC 7232 — Conditional Requests](https://datatracker.ietf.org/doc/html/rfc7232) — `If-Match` and ETags.
3. [RFC 8594 — Sunset](https://datatracker.ietf.org/doc/html/rfc8594) — the deprecation header.
4. [Stripe API Versioning](https://stripe.com/blog/api-versioning) — a real case at scale.
5. [Pydantic v2 — `model_config`](https://docs.pydantic.dev/latest/concepts/config/) — `extra='ignore'`.
6. [GitHub Templates — REST API repo](https://github.com/othneildrew/Best-README-Template) — README templates for a portfolio.
7. [HTTPX — async testing](https://www.python-httpx.org/async/) — the library used in the tests.
---
*Capsule 08 of 08 — Module 6 — SQL Patterns for Production APIs Guide*
*End of module 6. Continue with module 7 for Bulk Operations.*