API versioning is one of those problems that sounds simple until you're managing it across hundreds of multi-tenant clients in production. You ship a breaking change, half your users upgrade, the other half doesn't, and suddenly you're maintaining v1, v2, and v3 endpoints forever. I've lived this nightmare at CitizenApp with 9 AI features across dozens of tenants who upgrade on their schedule, not ours.
The traditional solutions all suck:
-
URL versioning (
/api/v1,/api/v2) = duplicate code and maintenance hell - Header versioning = clients forget to pass it, debugging becomes impossible
- Query params = same problem, messier
- Deprecation warnings = hope that users read them
What I prefer now is Pydantic V2's discriminated unions combined with strategic model inheritance. You can evolve your schemas within a single endpoint by making old fields optional, introducing new fields with defaults, and using field validators to bridge the gap. Your clients don't upgrade until they're ready, and new tenants get the latest schema automatically.
The Multi-Tenant Problem
Before I show you the code, let's frame the actual problem. In CitizenApp, we have:
- Tenants on different upgrade cycles
- 100+ downstream integrations pulling data from our API
- A product roadmap that doesn't pause for schema alignment
- Legal/compliance reasons some tenants can't upgrade immediately
When we added a new required field to our core DocumentAnalysis model last year, we couldn't just add it as required. We had to:
- Make it optional
- Backfill it for existing documents
- Wait 6 weeks for all clients to migrate
- Then finally enforce it
This is painful. Pydantic V2 lets us encode this semantically into the schema itself.
Model Versioning with Inheritance
Here's how I structure this now:
from pydantic import BaseModel, Field, field_validator
from typing import Optional, Literal
from datetime import datetime
# Base model - immutable core fields
class DocumentAnalysisBase(BaseModel):
id: str
document_id: str
created_at: datetime
content: str
# V1 schema - what existed before
class DocumentAnalysisV1(DocumentAnalysisBase):
model_config = {
"json_schema_extra": {
"version": 1,
"deprecated": False
}
}
summary: str
confidence: float
# V2 schema - new feature: structured entities
class DocumentAnalysisV2(DocumentAnalysisBase):
model_config = {
"json_schema_extra": {
"version": 2,
"deprecated": False
}
}
summary: str
confidence: float
entities: list[dict] = Field(default_factory=list)
entity_extraction_model: str = "claude-3-5-sonnet"
# V3 schema - new feature: multi-language support
class DocumentAnalysisV3(DocumentAnalysisV2):
model_config = {
"json_schema_extra": {
"version": 3,
"deprecated": False
}
}
language: str = Field(default="en")
language_confidence: float = Field(default=1.0)
The inheritance chain matters here. V3 includes everything from V2, which includes everything from V1. A V1 client gets a response with just summary and confidence. A V3 client sees everything, including the new language fields.
Discriminated Unions for Backward Compatibility
Now the trick: how do you accept any version on the way in, while always returning the latest on the way out? Discriminated unions.
from typing import Annotated, Union
from pydantic import Discriminator
def get_version(obj: dict | BaseModel) -> int:
"""Extract version from model or dict"""
if isinstance(obj, BaseModel):
return obj.model_config.get("json_schema_extra", {}).get("version", 1)
return obj.get("_version", 1)
# Discriminated union - this is the key
DocumentAnalysisInput = Annotated[
Union[DocumentAnalysisV1, DocumentAnalysisV2, DocumentAnalysisV3],
Discriminator(get_version)
]
class DocumentUpdateRequest(BaseModel):
analysis: DocumentAnalysisInput
When a client sends DocumentAnalysisV1 (the old schema), Pydantic deserializes it correctly. The missing entities and language fields stay None or use defaults.
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.post("/documents/{doc_id}/analysis")
async def update_analysis(
doc_id: str,
request: DocumentUpdateRequest
) -> DocumentAnalysisV3:
"""
Accept any version, return V3.
Clients always get the latest schema.
"""
analysis = request.analysis
# Normalize to V3
if isinstance(analysis, DocumentAnalysisV1):
# V1 → V3: entities and language are None, set defaults
normalized = DocumentAnalysisV3(
**analysis.model_dump(),
entities=[],
language="en",
language_confidence=1.0
)
elif isinstance(analysis, DocumentAnalysisV2):
# V2 → V3: just add language
normalized = DocumentAnalysisV3(
**analysis.model_dump(),
language="en",
language_confidence=1.0
)
else:
normalized = analysis
# Process with V3 schema
# All downstream code assumes DocumentAnalysisV3
await process_analysis(normalized)
return normalized
This is powerful because:
- Old clients still work without changes
- They gradually see new fields with sensible defaults
- Your business logic only handles the latest schema
- There's no parallel code path for v1, v2, v3 logic
Field Validators Bridge the Gap
Here's where I almost burned myself: sometimes old clients send data that doesn't fit the new schema. I added a language field that's a required ISO 639-1 code. Old clients send null. You need a validator:
class DocumentAnalysisV3(DocumentAnalysisV2):
language: str = Field(default="en")
language_confidence: float = Field(default=1.0)
@field_validator("language", mode="before")
@classmethod
def default_language(cls, v):
"""Ensure language is never null"""
if v is None or v == "":
return "en"
return v
@field_validator("language")
@classmethod
def validate_iso_639_1(cls, v):
"""Only valid ISO 639-1 codes"""
valid = {"en", "de", "fr", "es", "it", "pt", "ja", "zh", "ar"}
if v not in valid:
raise ValueError(f"Unsupported language: {v}")
return v
The mode="before" validator runs before type coercion, so you can catch nulls early.
Database and Serialization
Store the actual version in your database:
from sqlalchemy import Column, String, Integer, JSON
from datetime import datetime
class DocumentAnalysisRecord(Base):
__tablename__ = "document_analyses"
id = Column(String, primary_key=True)
document_id = Column(String, index=True)
schema_version = Column(Integer, default=3)
data = Column(JSON) # Stores the full DocumentAnalysisV3 dict
created_at = Column(DateTime, default=datetime.utcnow)
# When you fetch:
def get_analysis(doc_id: str) -> DocumentAnalysisV3:
record = db.query(DocumentAnalysisRecord).filter_by(document_id=doc_id).first()
# Always deserialize as V3, regardless of what's in the DB
return DocumentAnalysisV3(**record.data)
This way, old data from 2023 (stored as V1) deserializes fine into the V3 schema with defaults filled in.
Gotcha: The Client Assumption Problem
Here's what bit me at CitizenApp: I assumed old clients would ignore new fields. They don't. Some clients crash on unknown JSON keys. Test with your actual integrations before shipping.
I also missed that discriminators work differently when you're accepting input vs. returning output. Use explicit instance checks in your normalization logic—don't rely on Pydantic to guess which version a client sent.
This approach has let us ship 9 AI features without breaking a single tenant. New clients get V3 automatically. Old clients work forever. No deprecation emails, no forced upgrades, no parallel endpoint maintenance. That's worth the extra 50 lines of model inheritance code.
Top comments (0)