Module 02: Complex Validation Logic
Why this matters
The validators in modules 00 and 01 all looked at one field at a time.
Real forms aren't like that. "New password" is only valid if it matches
"confirm password" — a rule that needs to see two fields at once. "Partner's
name is required" — but only if married is true. "The discount code is
required for orders over $500." These are cross-field, conditional,
and chained rules, and no per-field validator can express them, because a
per-field validator structurally cannot see the other fields. You need a
different tool: the model validator, which runs after all the individual
fields are parsed and can see the whole object.
The second half of this module is about the response side of validation:
error handling. Getting a rejection right is a craft. A good validation
error tells the client exactly what's wrong and where ("age must be >= 18",
pointing at the age field) so they can fix it — and it reports all the
problems at once, not one per round-trip. But some errors must go the
opposite way and reveal less: a login endpoint should say "invalid credentials" whether the email was unknown or the password was wrong.
Saying "no account with that email" versus "wrong password" hands an
attacker an enumeration oracle — they can now discover which emails have
accounts. Knowing when to be maximally specific (help the honest user) and
when to be deliberately vague (starve the attacker) is a security judgment,
not a UX one.
Finally, transformations can fail — a client sends malformed JSON, or a
date that won't parse, or a number where you expected an object. Your API has
to degrade gracefully into a clean 4xx with a helpful message, never a bare
500 stack trace. And all of this has a performance angle: validation isn't
free, so you order checks to fail fast and avoid redundant or expensive work
on input that's already doomed.
Concepts
Model validators: seeing the whole object
A @model_validator runs on the whole model, after each field has been
individually parsed and validated. That's what lets it compare fields to each
other. Use mode="after" to get a fully-built model instance (fields already
typed):
from pydantic import BaseModel, model_validator
class PasswordReset(BaseModel):
password: str
confirm_password: str
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.confirm_password:
raise ValueError("password and confirm_password do not match")
return self
@field_validator("password") @model_validator(mode="after")
sees ONE field: sees the WHOLE object:
┌───────────┐ ┌──────────────────────────────┐
│ password │ ✓ │ password ─┐ │
└───────────┘ │ ├─ compare ✓/✗ │
confirm_password ✗ (invisible) │ confirm ──┘ │
can't reach it └──────────────────────────────┘
mode="after" receives self (the built instance) and returns self.
mode="before" receives the raw dict before field parsing — useful when
the very shape depends on some field, but you lose type safety, so prefer
after unless you specifically need the raw data.
Conditional validation: required-if
A field that's required only under a condition can't be modeled with a plain
Optional type — the conditionality is a cross-field rule. Model
validators handle it:
from pydantic import BaseModel, model_validator
class Profile(BaseModel):
married: bool
partner_name: str | None = None
@model_validator(mode="after")
def partner_required_if_married(self):
if self.married and not self.partner_name:
raise ValueError("partner_name is required when married is true")
return self
The field is declared Optional (because sometimes it legitimately isn't
there), and the rule enforcing when it must be present lives in the model
validator. This pattern — optional type + conditional model validator — is
the canonical shape for "required if."
Chained validation: order and short-circuiting
Some rules are a pipeline: lowercase → strip disallowed characters → check resulting length. Each step feeds the next, and a failure early should stop the chain (why check length of something that already failed the character rule?). You express a chain inside one validator by ordering the steps and returning early / raising as soon as a step fails:
from pydantic import BaseModel, field_validator
class Username(BaseModel):
value: str
@field_validator("value")
@classmethod
def clean_and_check(cls, v: str) -> str:
v = v.strip().lower() # step 1: normalize
v = "".join(c for c in v if c.isalnum()) # step 2: strip specials
if not (3 <= len(v) <= 20): # step 3: length of RESULT
raise ValueError("username must be 3-20 alphanumeric characters")
return v
The order is deliberate: you check the length of the cleaned value, not
the raw one, so "a b!!" (which cleans to "ab") is correctly rejected as
too short.
Aggregating errors vs. failing fast — the real tension
Two goals pull against each other. Fail fast (module 00) says: reject ASAP, do no needless work. Aggregate errors says: tell the client every problem at once so they don't fix-and-resubmit five times. Which wins depends on who benefits.
- For validating a request body, aggregation wins for UX: Pydantic
already collects every field error into one
422. A user fixing a form wants all the errors at once. - For expensive or security-sensitive checks, fail fast wins: don't run a database uniqueness query if the email is already syntactically invalid; don't reveal which credential was wrong.
FAIL FAST AGGREGATE
check A ─✗─► stop, return check A ─✗─┐
(B, C never run) check B ─✗─┼─► collect → one 422
best for: cost, security check C ─✓─┘ [A failed, B failed]
best for: form UX (fix all at once)
Pydantic gives you aggregation for free across field validators. Within a single validator (or across manual checks in a handler), you choose. When you want to collect multiple manual errors, gather them into a list and raise once:
def validate_order(order) -> None:
errors = []
if order.quantity <= 0:
errors.append("quantity must be positive")
if order.total < 0:
errors.append("total cannot be negative")
if errors:
from fastapi import HTTPException
raise HTTPException(status_code=422, detail={"errors": errors})
Obscuring sensitive errors: don't build an enumeration oracle
A validation error should help the legitimate user, but for authentication-adjacent flows, specificity helps attackers. The rule:
- Login: return one generic message —
"invalid email or password"— regardless of whether the email exists or the password was wrong. If you say "no such user" for unknown emails and "wrong password" for known ones, an attacker learns your entire user list by probing. - Password reset / signup: don't reveal whether an email is registered. Respond identically ("if that email exists, we've sent a link") whether or not it does.
- Timing matters too (advanced, track 09): even if messages match, responding faster for unknown users leaks the same info. The concept to internalize now: the error message is part of your attack surface.
Contrast with a normal validation error, where being specific is exactly
right: "age must be >= 18" helps the user and tells the attacker nothing
useful.
Gracefully handling failed transformations
When JSON is malformed, a date won't parse, or a required field is missing,
the client made an error — respond with a 4xx and a clear message, never a
500. FastAPI already turns Pydantic parse failures into 422
automatically. For raw-body or manual parsing you catch the failure yourself:
import json
from fastapi import Request, HTTPException
@app.post("/raw")
async def raw(request: Request):
body = await request.body()
try:
data = json.loads(body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="request body is not valid JSON")
return {"received": data}
A malformed-JSON request is the client's fault (400), not a server bug
(500). The distinction matters for monitoring: 5xx should mean your
code broke; 4xx means the client sent something wrong.
Performance tradeoffs
Validation costs CPU. Cheap checks (type, length, regex) are negligible; expensive checks (a database uniqueness lookup, a call to an external service, hashing) are not. So: order checks cheap-to-expensive and fail fast, and don't validate the same thing twice. Verify format before you spend a database round-trip confirming uniqueness. If a value was validated at the edge, downstream layers can trust it — re-validating in every service function is wasted work and a sign the layer boundaries (module 06) are muddy.
Command reference
| Pattern | What it does | Example |
|---|---|---|
@model_validator(mode="after") |
Whole-object validation; can compare fields | def f(self): ...; return self |
@model_validator(mode="before") |
Runs on raw dict before field parsing | def f(cls, data): ...; return data |
field: T | None = None |
Optional field for conditional (required-if) rules | partner_name: str | None = None |
chained steps in one @field_validator |
normalize → sanitize → check in order | v.strip().lower() then length |
raise HTTPException(422, detail={...}) |
Manual aggregated error response | detail={"errors": [...]} |
| generic auth message | Prevent enumeration | "invalid email or password" |
json.JSONDecodeError catch |
Turn malformed JSON into a clean 400 |
try: json.loads(...) |
@app.exception_handler(RequestValidationError) |
Customize the global 422 shape | see below |
mode="after" returns self; mode="before" returns the data. An
after model validator gets the constructed instance and must return it (or
raise). A before model validator gets the raw input (usually a dict), can
reshape it, and returns the (possibly modified) data for field parsing.
Customizing the global validation error shape. By default FastAPI's
422 has a detail array of Pydantic errors. To standardize your API's
error envelope (you'll formalize this in module 06), override the handler:
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={"error": "validation_failed", "fields": exc.errors()},
)
This catches every request-validation failure app-wide and reshapes it — one place, consistent for all clients.
Never leak internals in messages. exc.errors() is safe (it's about the
client's input). Never put a raw exception string, SQL, or stack frame into a
response body — that's an information leak. Log the detail server-side;
return a generic message to the client.
Hands-on exercises
Continue in the api-layer project.
1. Cross-field: password confirmation
from pydantic import BaseModel, model_validator, Field
class PasswordReset(BaseModel):
password: str = Field(min_length=8)
confirm_password: str
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.confirm_password:
raise ValueError("password and confirm_password do not match")
return self
@app.post("/reset-password")
async def reset(body: PasswordReset):
return {"ok": True}
Try mismatched passwords, then matching ones under 8 chars, then a valid
pair. Expected: mismatch → 422 (your message); short → 422
(min_length); valid → 200. Note the field-level and model-level errors
coexist.
2. Conditional: required-if
from pydantic import BaseModel, model_validator
class MaritalInfo(BaseModel):
married: bool
partner_name: str | None = None
@model_validator(mode="after")
def partner_required(self):
if self.married and not self.partner_name:
raise ValueError("partner_name is required when married is true")
return self
@app.post("/marital")
async def marital(body: MaritalInfo):
return {"married": body.married, "partner": body.partner_name}
Try {"married": true} (no partner), {"married": true, "partner_name": "Sam"},
and {"married": false}. Expected: first → 422, other two → 200. The
field is optional, but the rule makes it required under a condition.
3. Chained validation with result-based length
from pydantic import BaseModel, field_validator
class Handle(BaseModel):
value: str
@field_validator("value")
@classmethod
def clean(cls, v: str) -> str:
v = v.strip().lower()
v = "".join(c for c in v if c.isalnum())
if not (3 <= len(v) <= 20):
raise ValueError("handle must be 3-20 alphanumeric characters")
return v
@app.post("/handle")
async def handle(body: Handle):
return {"handle": body.value}
Try " Ada!! " (cleans to "ada", valid), "a b!!" (cleans to "ab",
too short → 422), and "Ada_Lovelace_99" (underscores stripped). Expected
outputs confirm length is checked on the cleaned value.
4. Aggregate manual errors into one response
from fastapi import HTTPException
from pydantic import BaseModel
class Order(BaseModel):
quantity: int
total: float
coupon: str | None = None
@app.post("/order")
async def order(body: Order):
errors = []
if body.quantity <= 0:
errors.append("quantity must be positive")
if body.total < 0:
errors.append("total cannot be negative")
if body.total > 500 and not body.coupon:
errors.append("coupon is required for orders over 500")
if errors:
raise HTTPException(status_code=422, detail={"errors": errors})
return {"ok": True}
Send {"quantity": 0, "total": -5}. Expected: one 422 listing both
errors — the client fixes everything in one pass. Then send
{"quantity": 1, "total": 600} and confirm the coupon rule fires.
5. Obscure a sensitive error (prevent enumeration)
from fastapi import HTTPException
from pydantic import BaseModel
USERS = {"ada@example.com": "hunter2"} # pretend-hashed for the exercise
class Login(BaseModel):
email: str
password: str
@app.post("/login")
async def login(body: Login):
stored = USERS.get(body.email.strip().lower())
if stored is None or stored != body.password:
# SAME message whether the email is unknown or the password is wrong
raise HTTPException(status_code=401, detail="invalid email or password")
return {"token": "fake-jwt-for-now"}
Try an unknown email and a known email with the wrong password. Expected:
identical 401 and message for both. Now imagine returning "no such user" vs "wrong password" — write down how an attacker would use that to
enumerate accounts.
6. Turn malformed JSON into a clean 400
import json
from fastapi import Request, HTTPException
@app.post("/ingest")
async def ingest(request: Request):
raw = await request.body()
try:
data = json.loads(raw)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="request body is not valid JSON")
if not isinstance(data, dict):
raise HTTPException(status_code=400, detail="body must be a JSON object")
return {"keys": list(data.keys())}
Send -d '{not json' and then -d '[1,2,3]'. Expected: both return 400
with clear messages, not a 500. Malformed input is the client's fault and
should be reported as such.
7. Standardize the global validation error shape
Add the RequestValidationError handler from the command reference. Then
resend any earlier bad request (e.g. exercise 1's mismatch). Expected: the
422 body is now your custom envelope ({"error": "validation_failed", "fields": [...]}) instead of the default. One handler reshaped every
validation error in the app.
8. Diagnose and fix
This registration endpoint is meant to (a) require company_name only for
account_type == "business", (b) reject mismatched passwords, and (c)
return all problems at once. Instead it crashes with a 500 on some inputs
and leaks which emails exist. Find and fix three problems.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, model_validator
app = FastAPI()
EXISTING = {"ada@example.com"}
class Register(BaseModel):
email: str
account_type: str
company_name: str = None # (A)
password: str
confirm_password: str
@model_validator(mode="after")
def check(self):
if self.account_type == "business":
if self.company_name is None:
raise ValueError("company_name required")
if self.password != self.confirm_password:
raise ValueError("passwords do not match")
return self
@app.post("/register")
async def register(body: Register):
if body.email in EXISTING:
raise HTTPException(status_code=409, detail=f"email {body.email} already exists")
return {"ok": True}
Solution
company_name: str = Noneis a type error. The declared type isstrbut the default isNone, which Pydantic v2 rejects (or forces to fail on validation). It must becompany_name: str | None = None. That's the500/parse crash.- Enumeration leak.
detail=f"email {body.email} already exists"confirms which emails are registered. For signup, respond generically — e.g. return200with "if that email is new, we've created it / we've sent a confirmation," or at minimum a409that doesn't echo the address back as proof. Don't hand attackers a membership oracle. - Not aggregating. The model validator raises on the first failing rule, so a request with both a missing company name and mismatched passwords only reports one. Collect into a list and raise once:
class Register(BaseModel):
email: str
account_type: str
company_name: str | None = None
password: str
confirm_password: str
@model_validator(mode="after")
def check(self):
errors = []
if self.account_type == "business" and not self.company_name:
errors.append("company_name is required for business accounts")
if self.password != self.confirm_password:
errors.append("passwords do not match")
if errors:
raise ValueError("; ".join(errors))
return self
Independent challenge
No code given. Build a POST /checkout endpoint. The body has items (a
non-empty list), a payment_method ("card" or "invoice"), a
card_token (required only when payment_method == "card"), a
purchase_order (required only when payment_method == "invoice"), and a
shipping object with country and postal_code. Enforce: postal code
format depends on country (US = 5 digits, UK = alphanumeric); the correct
payment field is present for the chosen method; and the response reports
every violation at once. Reuse the normalization idea from module 01 to
canonicalize the country before you branch on it, and apply module 00's
distinction between syntactic and semantic validation when you decide
which postal-code checks are format versus real-world. Make sure a failed
transformation (e.g. items sent as a string instead of a list) comes back
as a clean 4xx, never a 500.
Hint
The country-dependent postal rule and the method-dependent required field
are both cross-field — they belong in a single @model_validator(mode="after")
that gathers a list of error strings and raises once at the end. Normalize
country (upper/trim) before you pick which postal regex to apply, so
"us", "US", and " us " all take the same branch.
Common mistakes & troubleshooting
- Using a field validator for a cross-field rule. A
@field_validatorcan't see other fields. Password-match, required-if, and end-after-start all need@model_validator. - Forgetting to return
self/the data. Amode="after"model validator mustreturn self; amode="before"one must return the data. ReturningNonesilently wipes your model. - Leaking which account exists. Login/reset/signup errors that distinguish "unknown email" from "wrong password" create an enumeration oracle. Use one generic message.
- Returning
500for bad client input. Malformed JSON, unparseable dates, wrong types are4xx. Reserve5xxfor your bugs, so monitoring stays meaningful. - Checking length before cleaning. In a chain, run normalize/sanitize
first and validate the result, or
"a b!!"passes a length check it should fail. - Redundant re-validation everywhere. Validate once at the edge; let inner layers trust the data. Re-validating in every service function wastes CPU and signals blurred layer boundaries.
- Putting exception internals in responses. Never echo a raw exception, SQL, or stack trace to the client. Log it; return a generic message.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- Why can't a
@field_validatorenforce "confirm_password must equal password," and what's the right tool? - What must a
mode="after"model validator return, and what happens if you forget? - Describe the canonical shape for a "required-if" field (e.g. partner_name required only when married is true).
- When does aggregating all errors beat failing fast, and when does the reverse hold?
- Your login endpoint says "no such email" for unknown emails and "wrong password" for known ones. What attack does this enable, and what's the fix?
- A client posts malformed JSON. What status code should you return, and why
is a
500the wrong choice? - In a chained validator (normalize → strip specials → check length), why must the length check run on the result and not the raw input?
Answers
- A field validator only receives its own field's value, so it can't compare
two fields. Use
@model_validator(mode="after"), which sees the whole built instance. - It must
return self. Forgetting to return (returningNone) silently replaces the model withNoneand breaks everything downstream. - Declare the field as
Optional(str | None = None) and put the conditional requirement in a@model_validator(mode="after")that raises when the trigger field is set but the dependent field is missing. - Aggregation wins for request-body/form UX — report every field error in one response so the user fixes them all at once (Pydantic does this for free). Fail fast wins for expensive or security-sensitive checks — skip a DB uniqueness query on already-invalid input; don't reveal which credential failed.
- An enumeration oracle: an attacker probes emails and learns which have accounts, building a user/target list. Fix: return one generic message ("invalid email or password") for both cases, ideally with matched timing.
400(or422) — malformed JSON is a client error. A500implies a server fault, which pollutes monitoring and can leak stack traces; bad input is the client's fault, not a crash.- Because the rule is about the cleaned value.
"a b!!"cleans to"ab"(length 2); checking length on the raw 5-character string would wrongly accept it. Validate what you'll actually store.
Cumulative review
Closed-book. Don't reopen modules 00–02 while attempting these — the point is to find out what actually stuck.
- A birth date of
"3000-01-01"and a birth date of"not-a-date"both get rejected, but by different validation layers and at different points. Explain which layer catches each and why the ordering matters (module 00). - You store emails verbatim and later add a "one account per email" constraint, yet duplicates still appear. Name the missing step from module 01, and explain why validation alone wouldn't have prevented it.
- Why can't a
@field_validatorenforce "confirm_password must equal password," and what's the correct tool — including what that tool must return (module 02)? - Give one rule that should be enforced with a declarative
Field(...)constraint and one that must be a custom validator, and say what makes the difference (modules 00 & 02). - Your login endpoint currently returns
"no user with that email"for unknown emails and"incorrect password"for known ones. Describe the exact attack this enables and the fix (module 02). - A client sends
?page=abcand, separately, a request body that is malformed JSON. What status code should each produce, and why is it wrong to let either become a500(modules 01 & 02)? - You have a chain: lowercase → strip non-alphanumerics → enforce length
3–20. A username
"J. D."arrives. Walk through each step and give the final outcome, and explain why checking length first would give the wrong answer (module 02). - Explain the tension between "fail fast" (module 00) and "aggregate all errors" (module 02): when does each win, and why does Pydantic give you aggregation for free across fields but leave the choice to you inside a single validator?
Answers
"not-a-date"fails type/syntactic parsing — Pydantic can't build adatefrom it, so it's rejected before any semantic validator runs."3000-01-01"parses fine (valid type and format) and is only rejected by the semantic@field_validatorcomparing todate.today(). Ordering matters because the semantic check assumes it's already a real date — you can't ask "is this date in the future" until you have a date.- Normalization (trim + lowercase) before comparing/storing. Validation only
checks a value is acceptable; it doesn't make
Bob@x.comandbob@x.comequal. Without collapsing them to one canonical form, the uniqueness check sees two different strings. - A field validator only receives its own field's value, not the others, so
it can't compare the two. Use
@model_validator(mode="after"), which sees the whole instance; it mustreturn self. - Declarative: e.g.
age: int = Field(ge=0, le=150)ormin_length— a bound expressible as a simple constraint. Custom validator: e.g. "date_of_birth not in the future" or "password == confirm_password" — needs logic (comparison totoday(), another field) a constraint can't express. The difference is whether the rule is a static constraint or requires computation/cross-referencing. - It's an enumeration oracle: an attacker submits many emails; "no user"
vs "incorrect password" reveals which addresses have accounts, building a
user list (and a target list for password attacks). Fix: one generic
"invalid email or password"for both cases (ideally with matched timing). - Both should be
4xx—?page=abcis a422(uncastable param); malformed JSON is a400. Both are client errors. Letting them become500pollutes error monitoring (which should flag server bugs) and can leak stack traces. - lowercase →
"j. d."; strip non-alphanumerics →"jd"; length of"jd"is 2, which fails 3–20 → rejected. Checking length first would measure the raw"J. D."(length 5, passes) and wrongly accept an input that's only 2 usable characters. - Fail fast wins for expensive/security-sensitive checks (skip a DB lookup if the format's already invalid; don't reveal which credential failed). Aggregate wins for form UX (report every field error in one response). Pydantic runs all field validators and collects their errors, so aggregation across fields is automatic; inside one validator you're writing imperative code, so you decide whether to stop at the first problem or gather several.
Further reading & sources
- Pydantic — Model validators - the reference for
@model_validator, itsbefore/aftermodes, and what each must return. - FastAPI — Handling Errors - custom exception handlers and overriding
RequestValidationErrorto standardize your error envelope. - OWASP — Authentication Cheat Sheet - why login/reset errors must be generic to avoid an account-enumeration oracle.
- OWASP — WSTG: Testing for Account Enumeration - how attackers exploit message and timing differences to enumerate users.
- MDN — 422 Unprocessable Content - the status code FastAPI returns for semantic validation failures, and how it differs from 400.
- MDN — 400 Bad Request - the right response for malformed JSON and other client-side syntax errors, versus a 500.
Next
03-middleware-fundamentals — you've mastered validating and shaping the request body; now step back to the layer that wraps every request before and after your handler runs: middleware.