open atlas
↑ Back to track
Python for JS/TS developers PY · 06 · 02

Pydantic v2: parse at the boundary, trust inside — coercion, validators, and response_model filtering

BaseModel turns input dicts into typed objects or a 422 — parse, do not validate. The Rust core costs ns–µs per field (visible on 10k-item lists). Lax coercion masks contract drift, field/model validators carry cross-field rules, response_model stops internal-field leaks.

PY Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The billing service typed its money field honestly: cents: int. Upstream, a partner integration regressed and started serializing money as strings — "8999" instead of 8999. Nothing failed. Pydantic’s default lax mode coerced the digit string to an int on every request, silently, for five months: the contract had drifted and no alarm existed to notice. Then the partner localized their formatter, a European locale produced "89,99", and the first hour of the biggest sale of the year became a wall of 422s on the payment path. The postmortem’s sharpest line was not about the comma — it was about the five quiet months: the boundary had been accepting payloads that violated the contract, and the very feature that made integration smooth (coercion) was the thing that hid the drift. The team kept lax mode for query params, switched the payment models to strict, and added one honest sentence to the runbook: a validation layer that never rejects anything is not validating.

After this lesson you will know when to trust pydantic’s coercion and when it is silently hiding a drifted contract — and you will have a structural answer for both the input leak and the output leak.

Parse, do not validate

Why does this distinction matter? Because a layer that checks data still lets your business code wonder “is this field actually an int or did it slip through as a string?” — a layer that parses makes that question disappear. The pydantic philosophy is that the boundary should not check data — it should consume untyped input and produce a typed object, or fail loudly. After Order.model_validate(payload) succeeds, every consumer downstream holds an Order whose fields are guaranteed int, str, datetime — no defensive isinstance checks, no “what if it is None” branches in business logic. In FastAPI the parse is wired into the endpoint signature, and failure is automatic:

from pydantic import BaseModel

class OrderIn(BaseModel):
    sku: str
    quantity: int
    cents: int

@app.post("/orders")
async def create_order(order: OrderIn):   # parsed before your code runs
    charge(order.cents * order.quantity)  # ints, guaranteed

A payload that does not parse never reaches the handler — the client gets a 422 with a machine-readable error list (loc, msg, type per failure). The cost is honest and small: pydantic v2 validation runs in pydantic-core, compiled Rust, at nanoseconds-to-microseconds per field — a small model parses in ~1–2 µs. Where it becomes visible is volume: a 10k-item list of nested models is tens of milliseconds, real p99 money on a hot endpoint. Measure before blaming, but do not pretend the boundary is free.

Lax versus strict: where coercion saves you and where it lies

By default pydantic validates in lax mode, applying a documented conversion table: the string "1" becomes the int 1, "true" becomes True, an integral float 89.0 passes an int field. This is deliberate — the web is stringly typed (query params, form fields, env vars all arrive as strings) and lax mode absorbs that. The failure mode is the Hook: coercion does not just accept sloppy input, it hides the fact that input is sloppy. A drifted contract keeps validating until the day the drift produces something uncoercible — "89,99" — and then it fails at the worst possible time instead of the first possible time. The dial is per-field, per-model, or per-call:

class PaymentIn(BaseModel):
    model_config = ConfigDict(strict=True)   # ints must BE ints
    cents: int
    currency: str

# or per call, at chosen boundaries:
PaymentIn.model_validate(payload, strict=True)

The senior pattern is asymmetric strictness: lax where strings are structural (query params, headers, settings), strict where types are the contract (money, ids, anything a partner serializes). Cross-field rules live in validators — field_validator for one field, model_validator(mode="after") for invariants across fields (“refund cannot exceed original charge”). And validators have one iron rule: no side effects. A validator runs wherever validation runs — request parsing, response_model serialization, a model_validate call in a batch job — so a metrics increment or audit write inside one fires two or three times per request and once per replay, which is how teams end up with audit tables that disagree with reality.

Quiz

A model declares cents: int (default config). The request body contains cents as the string '8999'. What happens, and what is the senior-grade concern?

The output boundary: response_model as a one-way valve

Validation has a mirror image on the way out. A handler that returns its ORM object returns everything on it — which is how the classic incident happens: User grows a hashed_password column, the endpoint returns user, and the hash ships to every client until someone reads the JSON. response_model is the valve: FastAPI validates and serializes the return value through the declared model, and fields not declared on it simply do not exist in the response:

class UserPublic(BaseModel):
    id: int
    email: str
    model_config = ConfigDict(from_attributes=True)  # read from ORM objects

@app.get("/users/{user_id}", response_model=UserPublic)
async def get_user(user_id: int):
    return await db.fetch_user(user_id)   # full ORM row in, two fields out

The filtering is structural, not advisory: it works even when the function returns a richer object, and it keeps working when next year’s migration adds the next sensitive column. The same discipline applies to configuration: pydantic-settings parses environment variables through a typed model at startup — DATABASE_URL: PostgresDsn, TOKEN_TTL: timedelta, API_KEY: SecretStr — so a malformed env crashes the deploy at boot instead of the first request at 3 a.m., and secrets repr as ********** in logs.

Polymorphic payloads: discriminated unions

Webhooks and event APIs send many shapes through one door. A plain Union[A, B, C, D] makes pydantic try members until one fits — slow, and worse, the error for a bad payload is a confusing pile of every member’s failures. A discriminated union names the tag field, and dispatch becomes a single dict lookup:

class PaymentSucceeded(BaseModel):
    type: Literal["payment.succeeded"]
    cents: int

class RefundCreated(BaseModel):
    type: Literal["refund.created"]
    cents: int
    original_id: str

Event = Annotated[PaymentSucceeded | RefundCreated, Field(discriminator="type")]

An unknown tag fails with one precise error pointing at type, not a four-way pileup; validation cost stays flat as the union grows. For event ingestion at any volume, the discriminator is the difference between an error a partner can act on and one they will open a ticket about.

Quiz

A handler returns the full User ORM object (including hashed_password) from an endpoint declared with response_model=UserPublic, which has only id and email. What does the client receive?

Recall before you leave
  1. 01
    Explain parse-do-not-validate, lax versus strict coercion, and the billing-style failure mode lax mode enables.
  2. 02
    Why must validators be side-effect free, what does response_model actually do, and when do you reach for a discriminated union?
Recap

The boundary parses; the inside trusts. model_validate (or a FastAPI signature) turns a raw dict into a typed object or a 422 whose error list names every failing field — business logic downstream never sees half-valid data, and that is the entire point. The default lax mode coerces along a documented table — "1" to 1, "true" to True — which absorbs the stringly-typed web and, in the same motion, hides contract drift: the billing service that accepted string cents for five months found out via a wall of "89,99" 422s at peak. Strictness is a dial — per field, per model, per call — and senior usage is asymmetric: strict where a type is a promise, lax where strings are structural. Validators carry cross-field invariants and must stay pure, because they fire wherever validation fires, including on the response path and in batch jobs. On the way out, response_model is a structural one-way valve that drops every undeclared field from whatever object the handler returns — the standing defense against shipping hashed_password to the world. pydantic-settings applies the same parse-at-the-boundary discipline to the environment at boot, and discriminated unions give polymorphic webhooks O(1) tag dispatch with errors a partner can read. The machinery is Rust and costs microseconds — honest, small, and visible only at 10k-item volume. Now when you see a model quietly accepting a string where an int was promised, you know to ask: how long has this been happening, and what uncoercible value will surface it at the worst moment?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.