Custom Validators
When Field rules are not enough, write your own checks: clean and check one field with @field_validator, compare fields with @model_validator, and add values computed from other fields. Tested in FastAPI - including a validator that forgot "return" and silently turned a username into None.
What you will be able to do
- Write a field validator that cleans a value, checks it, or both
- Choose between mode="before" and the default "after"
- Compare several fields with a model validator
- Add a computed field to the output
- Read the errors your validators produce in a 422
- Avoid the traps: forgetting return, and errors that appear one round later
The idea, in plain English
Lesson 2.2 covered rules you can write as numbers and patterns: more than 0, 3 to 50 characters. Some rules are different. "The username may not be admin." "Keep only the digits of a phone number." "The end date must be after the start date." "The two passwords must be the same." For these you write a small Python function - a validator.
Pydantic has two kinds. A field validator looks at one field: it can clean the value (trim, lower-case, keep digits) and check it. A model validator runs after all fields are ready, so it can compare them. If something is wrong, you raise ValueError with a message, and it becomes a normal validation error - a 422 in FastAPI.
In this lesson we build a signup and a booking model, use them in FastAPI, and break every check on purpose. We also make two common mistakes and watch what happens. FastAPI 0.143.0, Pydantic 2.14.0.
Worked example: A signup (clean username, banned names, phone digits, matching passwords) and a booking (end after start, number of nights) - with every check broken on purpose.
1 - Before: the raw input
A mode="before" validator sees what the client sent, before any type check. The phone "+91 98480-12345" - or even the number 9848012345 - becomes the 10 digits "9848012345".
The order Pydantic follows for one Signup. Real results from our tests.
Words you will see in this lesson
A few words about custom checks.
ValidatorYour own function that checks (and may clean) data.Field validatorChecks one field. Decorated with @field_validator("name").Model validatorChecks the whole object, to compare fields. @model_validator.mode="before"Runs on the raw input, before the type check.mode="after"Runs after the type check (the default for field validators).Computed fieldA value calculated from other fields and included in the output.classmethodA method that belongs to the class, not one object - field validators must be one.An everyday example: a hotel check-in
At hotel check-in the clerk does three kinds of checks. Each box on its own: the phone number is written down as digits only, the name is written properly. Then the whole form together: does the check-out date come after the check-in date? And finally they work something out for you: "That is 3 nights."
Field validators are the first kind, the model validator is the second, and a computed field is the third.
Example 1 - field validators
A field validator is a classmethod with @field_validator("field_name") above it. It receives the value, may change it, and MUST return the value to keep. To reject it, raise ValueError("a clear reason").
username_rules runs in the default mode, "after": the type check has already happened, so v is surely a str. It cleans first (strip, lower) and then checks the cleaned value against BANNED - so " ADMIN " is caught too.
phone_digits uses mode="before": it receives exactly what the client sent - a string with spaces and dashes, or even a number. It keeps only the digits. Use "before" when you need to fix the input before the type check could fail on it.
from datetime import date
from fastapi import FastAPI
from pydantic import BaseModel, computed_field, field_validator, model_validator
app = FastAPI()
BANNED = {"admin", "root"}
class Signup(BaseModel):
username: str
password: str
password_again: str
phone: str
@field_validator("username") # runs AFTER the type check: v is already a str
@classmethod
def username_rules(cls, v: str) -> str:
v = v.strip().lower() # clean ...
if v in BANNED: # ... then check
raise ValueError(f"'{v}' is not allowed as a username")
return v # always return the value!
@field_validator("phone", mode="before") # runs BEFORE the type check: raw input
@classmethod
def phone_digits(cls, v):
digits = "".join(ch for ch in str(v) if ch.isdigit()) # "+91 98480-12345" -> "919848012345"
if len(digits) < 10:
raise ValueError("phone needs at least 10 digits")
return digits[-10:] # keep the last 10
@model_validator(mode="after") # runs after ALL fields: can compare them
def passwords_match(self):
if self.password != self.password_again:
raise ValueError("passwords do not match")
return self
@app.post("/signup")
def signup(data: Signup):
return data.model_dump(exclude={"password", "password_again"})Model validators: compare fields
A field validator sees one field. To compare two - password and password_again, start and end - use @model_validator(mode="after"). It is a normal method: self is the finished object, with every field already checked and converted. Raise ValueError if something is wrong, and return self at the end.
The Booking model below does the same for dates: the end must be after the start. And nights is a computed field: @computed_field on a property. It is not sent by the client - it is calculated from start and end, and it is included in the output and in the docs.
class Booking(BaseModel):
start: date
end: date
guests: int
@model_validator(mode="after")
def check_dates(self):
if self.end <= self.start:
raise ValueError("end must be after start")
return self
@computed_field # a value made from other fields, included in output
@property
def nights(self) -> int:
return (self.end - self.start).days
@app.post("/bookings")
def book(b: Booking):
return bEvery check, broken on purpose
A good signup came back with the username cleaned to "ravi_k" and the phone reduced to its last 10 digits. Sending the phone as a number (9848012345) also worked, because the "before" validator turns it into text first.
Look at the messages: Pydantic adds "Value error, " in front of your text. Look at loc: a field validator error says which field (["body", "username"]); a model validator error only says ["body"], because it is about the whole object. The booking came back with "nights": 3, computed from the dates.
good signup -> 200 {"username":"ravi_k","phone":"9848012345"}
banned username -> 422 loc=['body', 'username'] type=value_error msg=Value error, 'admin' is not allowed as a username
short phone -> 422 loc=['body', 'phone'] type=value_error msg=Value error, phone needs at least 10 digits
phone as a number -> 200 {"username":"ravi_k","phone":"9848012345"}
passwords differ -> 422 loc=['body'] type=value_error msg=Value error, passwords do not match
two field errors -> 422 loc=['body', 'username'] type=value_error msg=Value error, 'root' is not allowed as a username | loc=['body', 'phone'] type=value_error msg=Value error, phone needs at least 10 digits
field + model error -> 422 loc=['body', 'username'] type=value_error msg=Value error, 'root' is not allowed as a username
good booking -> 200 {"start":"2026-12-20","end":"2026-12-23","guests":2,"nights":3}
end before start -> 422 loc=['body'] type=value_error msg=Value error, end must be after startErrors one round at a time
Look at "field + model error": the username was "root" AND the passwords were different. Only the username error came back. The model validator did not run at all, because it needs every field to be valid first.
So a client that makes both mistakes fixes the username, sends again - and only then hears that the passwords do not match. Two field errors, on the other hand, came back together ("two field errors"). Keep this in mind when you design forms: checks inside one field report together; checks across fields come one round later.
The forgotten return
A field validator must return the value. We wrote one that cleaned the username and forgot "return v". No error, no warning - the username became None, even though the field is typed str. Pydantic trusts what your validator returns.
For model validators Pydantic is kinder: forgetting "return self" printed a UserWarning ("A custom validator is returning a value other than self") and kept the object. But do not rely on it - always end with return self.
field validator without return -> None
model validator without return -> a=1
UserWarning: A custom validator is returning a value other than 'self'.
Returning anything other than 'self' from a top level model validator isn't supported when validating via '__init__'.Watch out: A field validator without return silently replaces the value with None. Make "return v" the last line of every field validator, and test the cleaned value - not only that no error happened.
Field rule, field validator or model validator?
Use the simplest tool that works. Field rules (Lesson 2.2) are shortest and appear in the docs. Field validators handle anything about one value. Model validators handle anything about several values together.
More than 0, 3-50 characters, a patternField rule: Field(gt=0, min_length=3, pattern=...).Not in a banned list, clean then checkField validator (after).Fix messy input before the type checkField validator (mode="before").Compare two fieldsModel validator (mode="after").A value calculated from others@computed_field.Needs the database ("email already used")Not a validator - check in the endpoint (Module 5).Validators at a glance
Field validatorClean/check one field; return it.
@field_validator("username")
@classmethod
def check(cls, v): ... return vRaw inputBefore the type check.
@field_validator("phone", mode="before")Several fieldsOne validator for many.
@field_validator("first_name", "last_name")RejectBecomes a validation error.
raise ValueError("passwords do not match")Compare fieldsAfter all fields; return self.
@model_validator(mode="after") def check(self): ... return self
Computed valueIn output and docs.
@computed_field @property def nights(self) -> int: ...
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Add a field validator: a password needs at least 8 characters and one digit. Send "password" and "pass1234".”
“Send a signup with a banned username and different passwords. Fix the username and send again. What do you see each time?”
“Add a computed field total_guest_nights = guests * nights to Booking.”
“Remove "return v" from username_rules and send a good signup. What does the response show?”
What usually goes wrong
The username silently became None.
✗ def clean(cls, v):
v = v.strip().lower()✓ def clean(cls, v):
v = v.strip().lower()
return vA field validator sees one field. Compare fields in a model validator, where every field is ready.
Check the cleaned value, or " ADMIN " slips past a check for "admin".
✗ if v in BANNED: raise ...
v = v.strip().lower()✓ v = v.strip().lower()
if v in BANNED: raise ...Validators should be fast and pure. "Email already registered" needs the database - check it in the endpoint and return 409 or 400.
Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
Write a model Transfer with from_account, to_account (both 10-digit strings, spaces removed) and amount (Decimal > 0). A model validator must refuse a transfer to the same account.
Show hintHide hint
A mode="before" field validator for both accounts (@field_validator("from_account", "to_account", mode="before")), Field(gt=0) for amount, and a model validator comparing the two accounts.
Key points
- Field validators clean and check one field; they must return the value.
- mode="before" sees the raw input; the default "after" sees the typed value.
- Raise ValueError("why") to reject; Pydantic adds "Value error, " to the message.
- Model validators (mode="after") compare fields; their errors have loc ["body"] and they must return self.
- A model validator runs only when all fields are valid - its error can come one round later.
- @computed_field adds calculated values to the output and the docs.
Quick check before you move on
Interview questions
What is the difference between field and model validators in Pydantic v2?
Field validators run per field (before or after type validation) to clean or check one value; model validators run on the whole model (before on raw data, after on the instance) to enforce cross-field rules.
When would you use mode="before"?
To normalise raw input that would otherwise fail type validation - stripping formatting from phone numbers, accepting legacy formats - before the declared type is enforced.
Where should uniqueness checks like "email already exists" live?
In the service or endpoint layer with database access, not in Pydantic validators, which should stay fast, pure and free of I/O.
Quiz
- 1.
A signup has a banned username and different passwords. Which errors come back?
- 2.
Why clean before checking against BANNED?
- 3.
How did "nights" get into the booking response?
- 4.
What does Pydantic do with the ValueError message you raise?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...