Module 6: Optimistic Locking + Schema Versioning

Schema versioning: backward-compatible vs breaking changes

You're switching topics inside the module. Capsules 02-05 covered concurrency control between simultaneous writes. Now you go to the other kind of change you have to handle in production: the evolution of the API contract while clients keep consuming it.

The day your mobile app has 50,000 users with the version they installed 6 months ago, adding or changing fields in your API becomes a problem. Is the old client going to break? How do you evolve without coordinating with the 50k users? The answer isn't /v2/ — that forces you to maintain two parallel codebases and is almost always overkill. The answer is the discipline of backward-compatible changes: 99% of the modifications to your API can be made without breaking clients.

In this capsule you're going to learn the critical distinction between compatible and breaking changes, the subtle anti-patterns that look compatible but break, Pydantic v2's rules for forward-compatibility, and the discipline that lets you evolve your API for years without a single /v2/.


Categories of changes

Changes to an API's schema fall into three categories:

Compatible (always safe):

  • Adding an optional field with a default.
  • Adding a new endpoint.
  • Adding a new optional header.
  • Making required → optional.
  • Accepting an additional new format.

Compatible if the clients are well designed:

  • Adding a new enum value.
  • Adding a new status code (when it previously only returned a subset).

Breaking (always):

  • Removing a field.
  • Renaming a field.
  • Changing a field's type (int → string).
  • Making optional → required.
  • Changing a format (YYYY-MM-DDISO 8601).
  • Changing the behavior of an existing endpoint (one that was idempotent no longer is).

The discipline of "prefer compatible" requires creativity. Frequently you can achieve the same objective without a breaking change. The rule: breaking only when there's no alternative.


Compatible changes: examples

Adding an optional field

# v1
class TaskResponse(BaseModel):
    id: int
    title: str
    status: str

# v2 — add an optional priority
class TaskResponse(BaseModel):
    id: int
    title: str
    status: str
    priority: Optional[int] = None  # New, optional, default None

Old clients that ignore unknown fields keep working. They receive the extra priority but don't use it.

Adding a new endpoint

# v1
@router.get("/tasks")
@router.put("/tasks/{id}")

# v2 — add a new endpoint
@router.get("/tasks/{id}/history")

Old clients don't call the new endpoint, everything keeps working.

Accepting an additional new format

# v1: only accepts DATE
class TaskRequest(BaseModel):
    due_date: date

# v2: also accept datetime with backward-compat
class TaskRequest(BaseModel):
    due_date: Optional[date] = None
    due_at: Optional[datetime] = None  # The new format

    @model_validator(mode='after')
    def at_least_one(self):
        if self.due_date is None and self.due_at is None:
            raise ValueError("due_date or due_at required")
        return self

Old clients keep sending due_date. New clients can use due_at. The server accepts both.

Making required → optional

# v1: title required
class TaskRequest(BaseModel):
    title: str

# v2: title optional (accepts a default)
class TaskRequest(BaseModel):
    title: str = "Untitled"

Old clients always send the title — it keeps working. New clients can omit it.


Conditionally compatible changes

Adding an enum value (careful!)

# v1
class Status(str, Enum):
    PENDING = "pending"
    COMPLETED = "completed"

# v2: add 'archived'
class Status(str, Enum):
    PENDING = "pending"
    COMPLETED = "completed"
    ARCHIVED = "archived"  # New

Compatible if the clients ignore unknown enum values. But breaking if the clients do:

# A strict client that would break
match task.status:
    case 'pending': handle_pending()
    case 'completed': handle_completed()
    case _: raise UnknownStatusError(task.status)  # BREAKS with 'archived'

Mitigation:

  • Document the rule "clients must tolerate unknown enum values".
  • In Pydantic, extra='ignore' on the client.
  • In Pydantic v2, use_enum_values helps in serialization.

If your clients are strict, adding an enum value is breaking — you need a deprecation or a /v2/.

Adding a new status code

# v1: GET /tasks/{id} only returns 200 or 404
# v2: adds 410 Gone for deleted tasks (soft delete)

If the clients only handle 200/404, receiving a 410 can break their logic. Mitigation: clients should have a default handler for unknown status codes.


Breaking changes: why they hurt

Removing a field

# v1
{"id": 123, "title": "X", "legacy_field": "Y"}

# v2: legacy_field removed
{"id": 123, "title": "X"}

Old clients that expect legacy_field and do task.legacy_field break with a KeyError or AttributeError. If the client is Pydantic with extra='forbid', it breaks at deserialization.

Renaming a field

# v1: "user_id"
# v2: "owner_id"

Equivalent to removing user_id and adding owner_id. Doubly breaking.

Changing a type

# v1: "amount": "100.50" (string)
# v2: "amount": 100.50 (number)

A client that parses it as a string breaks.

Making optional → required

# v1: title optional
# v2: title required

Clients that never sent a title break.


Forward-compatibility: the client's responsibility

The server's compatible changes require that the clients ignore unknown fields. This is called the client's forward-compatibility — the client has to handle future additions from the server.

The Pydantic v2 default

By default, Pydantic v2 uses extra='ignore':

class TaskResponse(BaseModel):
    id: int
    title: str
    # You don't need to declare all the server's fields

response_data = {"id": 1, "title": "X", "future_field": "ignored"}
task = TaskResponse(**response_data)  # ✅ works, ignores future_field

The anti-pattern: extra='forbid'

Some teams use extra='forbid' for strictness:

class TaskResponse(BaseModel):
    model_config = ConfigDict(extra='forbid')  # ❌ Forward-incompatible
    id: int
    title: str

response_data = {"id": 1, "title": "X", "future_field": "X"}
TaskResponse(**response_data)  # ❌ ValidationError: extra inputs not permitted

This breaks forward-compatibility. Any new field from the server breaks the client. Don't use extra='forbid' in clients that consume external APIs.

extra='forbid' is OK for:

  • Request bodies (validating that the client isn't sending garbage).
  • Internal configuration (catching typos).

extra='ignore' (the default) is the right choice for:

  • Response models that consume external APIs.
  • Shared models between client and server.

Document the responsibility

In your API docs, mention it explicitly:

## API Forward-Compatibility

This API may add new fields to responses without notice. Clients **must**:

1. Ignore unknown fields in JSON responses (default in Pydantic, Jackson, etc.).
2. Tolerate unknown enum values when consuming `status`, `type`, etc. fields.
3. Handle status codes outside of the documented list with a default handler.

Failure to follow these guidelines will result in clients breaking on
non-breaking server updates.

Strategies for avoiding breaking changes

Frequently "I need to make a breaking change" is an illusion — there are compatible alternatives.

I want to rename user_id to owner_id

# v1
class TaskResponse(BaseModel):
    user_id: int

# v2: add owner_id without removing user_id (transitional)
class TaskResponse(BaseModel):
    user_id: int  # legacy, deprecated
    owner_id: int  # new, the same value

# v3 after the deprecation period: remove user_id

The v2 is compatible. After months with usage metrics, when user_id usage is < 1%, remove it in v3 (which IS breaking — but nobody uses it anymore).

I want to change amount's type from string to number

# v1
{"amount": "100.50"}

# v2: add a parallel field
{"amount": "100.50", "amount_decimal": 100.50}

# v3 later: if only the new format is used, deprecate the old one

I want to remove the /legacy/foo endpoint

1. Mark `/legacy/foo` as deprecated in the docs.
2. Add `Deprecation: true` and `Sunset: <date>` headers in the responses.
3. Metrics: track the endpoint's usage.
4. When usage is < 1%, remove it.

Capsule 07 covers the headers in detail.

I want to require tenant_id, which was optional

# v1: tenant_id optional, the server infers it from auth if it isn't sent
# v2: tenant_id required but you keep inferring it if it isn't sent (compatible)
# v3 (after the deprecation): an error if it isn't sent

# Compatible as long as you infer it server-side

When /v2/ IS justifiable

There are cases where there's no compatible alternative. The rule: /v2/ only if you've exhausted every compatible option.

Valid cases:

  1. A fundamental change to the domain model. E.g. going from "a user owns tasks" to "a team owns tasks, users have permissions". The whole schema changes.

  2. A fundamental auth change. E.g. going from OAuth2 with bearer tokens to mTLS. The headers, the flow, everything changes.

  3. A protocol change. E.g. going from REST to GraphQL. It's a different API.

  4. The sunset of v1 after a long deprecation period. v1 deprecated for 2 years, metrics show 0% usage, you can finally remove it.

Cases that are NOT justifiable:

  • Adding new features (compatible).
  • Changing a field's name (it can be compatible with parallel fields).
  • Changing a field's type (it can be compatible with parallel fields).
  • "Cleaning up the API" (subjective, not a real breaking change).

Stripe is the famous example: they maintain API versions for years. The most recent version is the default, but clients can pin to specific versions with a header. Major changes get different versions, but most changes are backward-compatible within the same version.


Three ways to version (if you decide to version)

If you've decided you need a /v2/, there are three approaches:

URL versioning

/v1/tasks/123
/v2/tasks/123

Pros: explicit, easy to understand, works with HTTP caches. Cons: complex routing, two codebases.

Header versioning

GET /tasks/123 HTTP/1.1
Accept: application/vnd.myapi.v2+json

Pros: a "clean" URL, semantically more correct. Cons: less visible, tools like Postman are less friendly with it.

Query param versioning

/tasks/123?version=2

Pros: simple. Cons: breaks with URL caches, gets mixed in with other params.

Recommendation: if you have to version (rarely), URL versioning for maximum clarity. A header for cases where URL stability is critical.


Practical implementation with FastAPI

If you need multiple simultaneous versions, FastAPI supports separate routers:

from fastapi import APIRouter, FastAPI

v1_router = APIRouter(prefix="/v1")
v2_router = APIRouter(prefix="/v2")


# v1 endpoints
@v1_router.get("/tasks/{task_id}")
async def get_task_v1(task_id: int):
    return {"id": task_id, "user_id": 42}  # The old schema


# v2 endpoints
@v2_router.get("/tasks/{task_id}")
async def get_task_v2(task_id: int):
    return {"id": task_id, "owner_id": 42}  # The new schema


app = FastAPI()
app.include_router(v1_router)
app.include_router(v2_router)

But ideally, you avoid this. The complexity of maintaining both only pays off in extreme cases.


Traps and common mistakes

1. "I'm going to ship /v2/ because I added a field."

Unnecessary. Adding a field is compatible (with well-designed clients). Document the field, add it. If somebody breaks, it's because their client is badly designed.

2. Assuming clients ignore unknown fields without verifying.

In Pydantic v2, by default yes. In Java/Kotlin with Jackson, by default yes. In Go with encoding/json, by default yes. But in some badly configured clients or specific languages, no. Verify with your main clients.

3. Changing behavior without changing the schema.

# v1: GET /tasks/123 returns the task even if it's soft-deleted
# v2: GET /tasks/123 returns a 404 if it's soft-deleted

The schema didn't change but the behavior did. This is breaking even though it doesn't look like it. Clients that depended on the old behavior break.

4. Renaming enum values.

# v1: status='in_progress'
# v2: status='in-progress'  (the separator changed)

Clients that compare strings break. Treat it as breaking.

5. Changing default values.

# v1: page_size default = 20
# v2: page_size default = 50

Clients that depended on the specific default (rare but it happens) see different behavior. A subtle breaking change.

6. Changing the error response format.

# v1: {"detail": "Error"}
# v2: {"error": {"message": "Error", "code": "X"}}

Clients that parsed detail break. Breaking.

7. Reusing fields with a new meaning.

# v1: status can be 'open', 'closed'
# v2: status reused for 'pending_review', which is like 'open' but distinct

Clients that assumed "open or closed" break with a third state. Documenting the addition of an enum value is essential.

8. Schema versioning with no tests.

Having /v1/ and /v2/ requires tests for both. With no tests, evolution inevitably breaks v1 when you modify shared code.


Exercise: classify changes

For each proposed change to the Task schema, classify it as:

  • Compatible (safe)
  • Compatible if the clients are well designed
  • Breaking

And for the breaking ones, propose a compatible alternative if one exists.

Change 1: add the field tags: list[str] = [].

Change 2: change priority from int (1-5) to string ('low', 'medium', 'high').

Change 3: add the enum value status='archived' when it used to be pending/completed.

Change 4: make due_date required (it was optional).

Change 5: rename assigned_to to assignee.

Change 6: change created_at from 2026-05-08 (DATE) to 2026-05-08T14:32:11Z (ISO 8601).

Change 7: add a new endpoint POST /tasks/{id}/archive.

Change 8: change the behavior of DELETE /tasks/{id} from hard-delete to soft-delete (the response is still a 200, but now it's recoverable).

See solutions

Change 1 — add tags: list[str] = []: ✅ Compatible. An empty default, old clients ignore it.

Change 2 — int → string: ❌ Breaking. A type change. Alternative: add priority_label with a string, keep priority with an int. After the deprecation, remove the old one.

Change 3 — add an enum value: ⚠️ Compatible if the clients handle unknown enum values. Document the responsibility. If your clients are strict, treat it as breaking.

Change 4 — optional → required: ❌ Breaking. Clients that didn't send the field break. Alternative: a server-side default if it isn't sent. Keep compatibility, eventually migrate the clients.

Change 5 — rename: ❌ Breaking. Alternative: add a new field assignee with the same value, keep assigned_to as deprecated. After the deprecation, remove it.

Change 6 — DATE → ISO 8601 datetime: ❌ Breaking (a type and format change). Alternative: add created_iso with the new format, keep created_at with the old format. Eventually sunset the old one.

Change 7 — a new endpoint: ✅ Compatible. New endpoints are always safe.

Change 8 — the DELETE behavior: ⚠️ Subtle. The response schema didn't change, but the behavior did. It depends:

  • If the client only checked 200 OK and moved on: compatible.
  • If the client relied on the resource disappearing permanently: breaking.
  • Document the change and verify with the clients.

The key lessons:

  1. Most "breaking" changes have a compatible alternative if you think creatively.
  2. Parallel fields (keeping the old + adding the new) is the most common pattern for evolution.
  3. Behavior with no schema change can be breaking — contract tests don't catch it, but it breaks anyway.
  4. Document aggressively when you add enum values or change behavior.

Summary and next step

What you learned:

  • Three categories of changes: compatible (safe), conditionally compatible (depends on the client), breaking.
  • Compatible: adding an optional field, adding an endpoint, making required → optional.
  • Compatible if the clients ignore unknowns: adding an enum value, adding a status code.
  • Always breaking: removing, renaming, changing a type, making optional → required, changing behavior.
  • The client's forward-compatibility: Pydantic v2's default is extra='ignore', don't use extra='forbid' in consumers.
  • The strategy for avoiding breaking changes: parallel fields (keep the old + add the new), gradual deprecation.
  • /v2/ only when you've exhausted the compatible options: fundamental changes to the domain, auth, or protocol.

Before moving on, you should be able to:

  • Classify any proposed change as compatible/breaking.
  • Propose compatible alternatives for changes that look breaking.
  • Configure Pydantic v2 correctly for forward-compat.
  • Defend in code review why /v2/ is almost never the answer.

In the next capsule we go to the formal deprecation strategy: when you need to remove a field or an endpoint, how do you warn the client with enough time? You're going to learn the Deprecation: true and Sunset: <date> headers (RFC 8594), how to track usage of deprecated fields to know when it's safe to remove them, and patterns for communicating with clients (changelogs, emails, metrics).


Resources

  1. Stripe API — Versioning Philosophy — a real case of evolution at scale with no /v2/.
  2. Pydantic v2 — model_config — the reference for extra and other options.
  3. GitHub API — Schema preview — a real case of versioning with headers.
  4. Microsoft REST API Guidelines — Versioning — a standard corporate guide.
  5. JSON Schema — Best practices — schema docs.
  6. Roy Fielding — REST and Versioning — the perspective of REST's author on versioning.
  7. PostgreSQL Wiki — Schema Versioning — schema versioning in the DB (relevant for the data layer).

Capsule 06 of 08 — Module 6 — SQL Patterns for Production APIs Guide