Enforce API contracts at the boundary: Pydantic v2 validators done right
Pydantic v2 Boundary Validation: The Traps That Don't Raise Exceptions
Your model looks tight. Fields are typed, validators are defined, and yet a string slips through an int field, or a required field raises "Field required" on a payload that definitely included it. These aren't edge cases. They're the default behavior of Pydantic v2 if you carry v1 assumptions into v2 code.
Start with the install:
pip install "pydantic>=2.13"
Pydantic 2.13.4 (released 2026-05-06) ships a Rust validation core, so the overhead of enforcing a strict boundary is negligible. The API surface changed significantly from v1, and a few of those changes are silent traps.
Declaring a boundary model
The core pattern: BaseModel fields are your contract. @field_validator is the v2 replacement for v1's @validator. Catch ValidationError and read .errors(), which returns a structured list, not a plain string.
from pydantic import BaseModel, field_validator, ValidationError
from typing import Optional
class OrderRequest(BaseModel):
item_id: str
quantity: int
unit_price: float
coupon: Optional[str] = None # v2 requires the explicit = None
@field_validator("unit_price", mode="after")
@classmethod
def price_must_be_positive(cls, v: float) -> float:
if v <= 0:
raise ValueError("unit_price must be greater than zero")
return v
The install command pip install "pydantic>=2.13" specifies a minimum version constraint, meaning pip will install v2.13 or any later release that satisfies it. It does not pin you to exactly v2.13, nor does it guarantee the absolute latest available version-it resolves to the newest release that meets the constraint at the time you run it. To pin an exact version for reproducibility, use pip install "pydantic==2.13.4" instead. Either way, any v2.13.x release ships the Rust validation core, so the enforcement overhead stays negligible.
from pydantic import BaseModel, field_validator, ValidationError
from typing import Optional
class OrderRequest(BaseModel):
item_id: str
quantity: int
unit_price: float
coupon: Optional[str] = None
@field_validator("unit_price", mode="after")
@classmethod
def price_must_be_positive(cls, v: float) -> float:
if v <= 0:
raise ValueError("unit_price must be greater than zero")
return v
# Valid payload
order = OrderRequest(item_id="SKU-1", quantity=3, unit_price=9.99)
print(order.item_id) # => SKU-1
print(order.coupon) # => None
# Invalid payload - ValidationError carries a structured .errors() list
try:
OrderRequest(item_id="SKU-2", quantity=1, unit_price=-5.00)
except ValidationError as exc:
errors = exc.errors()
print(len(errors)) # => 1
print(errors[0]["loc"]) # => ('unit_price',)
print(errors[0]["msg"]) # => Value error, unit_price must be greater than zero
print(errors[0]["type"]) # => value_error
The Optional[str] behavior is the single most common v1-to-v2 migration break. In v1, Optional[str] implied = None. In v2 it means "required, but accepts None." The migration guide documents this explicitly. Every field that should be optional needs a default.
from pydantic import BaseModel, ValidationError
from typing import Optional
# ── The Optional[T] gotcha ────────────────────────────────────────────────────
# v1 behaviour: Optional[str] -> not required, defaults to None
# v2 behaviour: Optional[str] -> REQUIRED, just also accepts None
# Optional[str] = None -> not required, defaults to None
class BrokenModel(BaseModel):
note: Optional[str] # required in v2 - raises if omitted
class FixedModel(BaseModel):
note: Optional[str] = None # not required, defaults to None
try:
BrokenModel()
except ValidationError as exc:
print(exc.errors()[0]["msg"]) # => Field required
fixed = FixedModel()
print(fixed.note) # => None
One more thing about mode="after": if the field fails type coercion before the validator runs, your custom validator is skipped entirely. An after validator on unit_price never fires if the input is "not-a-float". Don't rely on a single after-validator as the only gate.
Cross-field invariants and the return-self trap
Single-field validators can't express rules that span multiple fields. That's what @model_validator(mode="after") is for. It runs after all field validators have passed and receives the fully constructed model instance as self.
from pydantic import BaseModel, field_validator, model_validator, ValidationError, ConfigDict
from typing import Optional
class OrderRequest(BaseModel):
item_id: str
quantity: int
unit_price: float
discount_pct: float = 0.0
coupon: Optional[str] = None
@field_validator("unit_price", mode="after")
@classmethod
def price_must_be_positive(cls, v: float) -> float:
if v <= 0:
raise ValueError("unit_price must be greater than zero")
return v
@model_validator(mode="after")
def discount_cannot_exceed_price(self) -> "OrderRequest":
if self.discount_pct >= self.unit_price:
raise ValueError(
f"discount_pct ({self.discount_pct}) must be less than "
f"unit_price ({self.unit_price})"
)
return self # <- required; omitting this makes model_validate() return None
# Valid order
order = OrderRequest(item_id="SKU-1", quantity=2, unit_price=19.99, discount_pct=5.00)
print(order.discount_pct) # => 5.0
# Cross-field violation
try:
OrderRequest(item_id="SKU-3", quantity=1, unit_price=10.00, discount_pct=15.00)
except ValidationError as exc:
errors = exc.errors()
print(len(errors)) # => 1
print(errors[0]["loc"]) # => ()
print(errors[0]["msg"]) # => Value error, discount_pct (15.0) must be less than unit_price (10.0)
The return self omission is a live bug in Pydantic's behavior, tracked as issue #9334. What makes it genuinely dangerous: Model(**kwargs) construction still works and still returns a valid instance, so any test suite that builds models with keyword arguments will pass clean. Only model_validate(dict) - the path every real API endpoint actually uses - returns None silently. The validator body does run, so exceptions from invalid data still fire. Only the return value is dropped.
from pydantic import BaseModel, model_validator, ValidationError
# ── The return-self trap (GitHub issue #9334) ─────────────────────────────────
# If @model_validator(mode='after') forgets `return self`:
# model_validate({...}) -> returns None, no exception raised
# ModelClass(**kwargs) -> still works, masking the bug in keyword-arg tests
class BrokenOrderRequest(BaseModel):
item_id: str
unit_price: float
discount_pct: float = 0.0
@model_validator(mode="after")
def discount_cannot_exceed_price(self) -> "BrokenOrderRequest":
if self.discount_pct >= self.unit_price:
raise ValueError("discount_pct must be less than unit_price")
# <- forgot return self
via_validate = BrokenOrderRequest.model_validate(
{"item_id": "SKU-4", "unit_price": 20.0, "discount_pct": 3.0}
)
print(via_validate) # => None
via_kwargs = BrokenOrderRequest(item_id="SKU-4", unit_price=20.0, discount_pct=3.0)
print(type(via_kwargs).__name__) # => BrokenOrderRequest
from pydantic import BaseModel, ValidationError, ConfigDict
# ── Strict mode: reject silent coercion at the boundary ──────────────────────
# By default, pydantic accepts "3" for an int field (lax mode).
# Set strict=True so the contract means what it says.
class StrictOrder(BaseModel):
model_config = ConfigDict(strict=True)
quantity: int
try:
StrictOrder.model_validate({"quantity": "3"}) # string rejected for int
except ValidationError as exc:
print(exc.errors()[0]["msg"]) # => Input should be a valid integer
The complete, runnable examples for this article live on GitHub: https://github.com/MichaelStaHelena/pythonista-rocks-codes/tree/main/examples/contract-validation-in-python-apis
Where strict mode fits in
Pydantic's default lax mode accepts "3" for an int field, "true" for a bool, and similar coercions. That's convenient for form data but wrong for a typed API boundary where the caller controls the serialization. ConfigDict(strict=True) on the model, or .model_validate(data, strict=True) per call, disables all of it. Use per-call strict mode when you want strictness only at the entry point and lax behavior for internal construction during tests.
If the model is shared across an inbound HTTP layer and internal code, strict=True on the model is the safer default. Any internal caller that needs to construct from loose data can pass strict=False explicitly, making the exception visible rather than the rule.