| name | pydantic-backend |
| description | Enforce Pydantic best practices in Python backend code. Use when writing new models,
refactoring code that uses raw dicts, reviewing PRs, or when manual parsing/validation
could be replaced by Pydantic. Also trigger when the user says "pydantic", "model validation",
"type coercion", or "schema cleanup".
|
Pydantic Best Practices for Python Backends
Apply these rules when writing or reviewing Python backend code. The goal is to replace
manual dict parsing, type coercion, and validation with declarative Pydantic models.
Core Rules
1. Never manually parse what Pydantic can validate
Bad — manual .get() chains and type coercion:
def parse_item(data: dict) -> Response:
return Response(
name=data.get("name", ""),
score=float(data.get("score", 0)),
created_at=datetime.fromisoformat(data.get("created_at", "")),
)
Good — let Pydantic handle it:
class ItemData(BaseModel):
name: str = ""
score: float = 0.0
created_at: datetime | None = None
data = ItemData.model_validate(raw_dict)
Pydantic v2 auto-coerces: str → datetime (ISO format), str → int/float, int → bool, etc.
2. Use field_validator for custom per-field logic
Use mode="before" to transform raw input pre-coercion, mode="after" (default) for
business-rule checks on already-coerced values. Prefer after unless you need to reshape input.
from pydantic import field_validator
class MemoryValue(BaseModel):
content: str = ""
@field_validator("content", mode="before")
@classmethod
def unwrap_nested_content(cls, v):
"""Handle LangMem format: {"content": {"content": "text"}}."""
if isinstance(v, dict):
return v.get("content", "")
return v
3. Use model_validator for cross-field logic
When validation depends on multiple fields:
from pydantic import model_validator
class DateRange(BaseModel):
start: datetime
end: datetime
@model_validator(mode="after")
def check_order(self):
if self.end < self.start:
raise ValueError("end must be after start")
return self
4. Use discriminated unions for polymorphic data
Use a Literal tag field + Field(discriminator=...) for variant payloads.
This uses O(1) Rust-level dispatch and produces clearer error messages.
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field
class LangMemValue(BaseModel):
kind: Literal["Memory"] = "Memory"
content: dict[str, str]
class DirectValue(BaseModel):
kind: Literal["direct"] = "direct"
content: str
MemoryPayload = Annotated[
Union[LangMemValue, DirectValue],
Field(discriminator="kind"),
]
For cases without a uniform tag field, use a callable Discriminator:
from pydantic import Discriminator, Tag
def detect_format(v: dict | BaseModel) -> str:
if isinstance(v, dict):
return "langmem" if isinstance(v.get("content"), dict) else "direct"
return "langmem" if isinstance(getattr(v, "content", None), dict) else "direct"
MemoryPayload = Annotated[
Union[
Annotated[LangMemValue, Tag("langmem")],
Annotated[DirectValue, Tag("direct")],
],
Discriminator(detect_format),
]
5. Use reusable Annotated types
Define reusable validation logic as type aliases with BeforeValidator/AfterValidator:
from typing import Annotated
from pydantic.functional_validators import BeforeValidator, AfterValidator
def parse_flexible_datetime(v: object) -> object:
if isinstance(v, (int, float)):
return datetime.fromtimestamp(v)
return v
PastDatetime = Annotated[
datetime,
BeforeValidator(parse_flexible_datetime),
]
PositiveScore = Annotated[float, Field(ge=0.0, le=1.0)]
NonEmptyStr = Annotated[str, Field(min_length=1)]
6. Prefer model_config over manual settings
from pydantic import BaseModel, ConfigDict
class MyModel(BaseModel):
model_config = ConfigDict(
from_attributes=True,
populate_by_name=True,
extra="forbid",
str_strip_whitespace=True,
)
| Setting | When to use |
|---|
extra="forbid" | API request schemas — catches typos early |
extra="ignore" | Response schemas or third-party data you don't control |
from_attributes=True | Building models from ORM rows or dataclasses |
str_strip_whitespace=True | User-facing input models |
7. Use Pydantic BaseSettings for configuration
Bad — manual os.getenv() with type casting:
def get_config() -> dict:
return {
"PORT": int(os.getenv("PORT", "8000")),
"DEBUG": os.getenv("DEBUG", "false").lower() == "true",
}
Good — Pydantic Settings:
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppConfig(BaseSettings):
port: int = 8000
debug: bool = False
model_config = SettingsConfigDict(env_prefix="APP_")
8. Always annotate adapter schemas with Field(description=...) and Literal
Every field on API-facing Pydantic models (request/response schemas in adapters/inbound/schemas/)
must be self-documenting. This generates accurate OpenAPI docs and makes specs reviewable
without reading the implementation.
Rules:
- Use
Literal[...] for any string field with a known set of values — never bare str.
- Use
Field(description="...") on every non-obvious field.
- Use
Field(ge=..., le=...) for numeric bounds.
- Use
Field(min_length=...) for list/string minimums.
- Add a class-level docstring on every schema.
Bad — bare types, no descriptions:
class ProceedRequest(BaseModel):
action: str
feedback: str | None = None
class ResearchReportResponse(BaseModel):
overall_quality: str
sources_found: int
Good — self-documenting, constrained:
class ProceedRequest(BaseModel):
"""Body for resuming generation after research report."""
action: Literal["proceed", "refine"] = Field(
description="Accept research and start writing, or refine with feedback"
)
feedback: str | None = Field(
default=None,
description="User feedback for refinement — required when action is 'refine'",
)
class ResearchReportResponse(BaseModel):
"""Compiled research report across all sections."""
overall_quality: Literal["sufficient", "needs_refinement"] = Field(
description="Whether the source material is adequate for generation"
)
sources_found: int = Field(description="Number of relevant sources retrieved")
9. Use precise HTTP status codes — never generic 500
Every except block in an endpoint must map to a specific HTTP status code. Unhandled
exceptions becoming 500s are bugs, not acceptable behavior.
Rules:
- Catch expected failure modes BEFORE they reach FastAPI's default handler.
- Map each exception type to the most precise status code.
- Include actionable detail in the error message.
- Roll back state transitions if the operation that follows them fails.
Status code reference for this project:
| Status | When to use | Example |
|---|
400 Bad Request | Invalid input that Pydantic didn't catch | "Theme has no linked collection" |
404 Not Found | Resource doesn't exist | "Essay not found" |
409 Conflict | State precondition failed | "Essay must be in 'draft' status" |
422 Unprocessable Entity | Pydantic validation / LLM returned bad JSON | Automatic from FastAPI, or GenerationJSONParseError |
502 Bad Gateway | Upstream service failed (LLM, Qdrant, embedder) | "Failed to initialize generation infrastructure" |
503 Service Unavailable | Required infrastructure not configured | "Essay generation requires a checkpointer" (RuntimeError) |
Bad — unhandled exception becomes opaque 500:
@router.post("/essays/{id}/generate")
async def generate(id: str):
graph = factory.build_generation_graph(provider, collection)
...
Good — catch and map to precise status:
@router.post("/essays/{id}/generate")
async def generate(id: str):
try:
graph = factory.build_generation_graph(provider, collection)
except RuntimeError as e:
await essay_repo.update_status(id, "draft")
raise HTTPException(status_code=503, detail=str(e)) from e
except Exception as e:
await essay_repo.update_status(id, "draft")
raise HTTPException(
status_code=502, detail=f"Failed to initialize generation infrastructure: {e}"
) from e
...
10. Declarative over imperative
Prefer Literal, Field(ge=0), Annotated[str, StringConstraints(...)] before
reaching for Python validators. The Rust core is significantly faster.
When to Apply
- System boundaries: API input/output, external service responses, config, store/DB row mapping.
- Refactoring: Replace manual
.get() chains, isinstance checks, and try/except parsing.
- Don't over-apply: Internal function args with simple primitives don't need Pydantic.
Anti-Patterns to Watch For
| Pattern | Replace With |
|---|
data.get("key", default) chains | Pydantic model with defaults |
Manual datetime.fromisoformat() | datetime field (auto-coerced) |
isinstance(x, dict) branching | field_validator(mode="before") |
try/except around type casts | Pydantic coercion + ValidationError |
dict[str, Any] type hints for structured data | Named Pydantic model |
os.getenv() + manual casting | BaseSettings |