← Back to FastAPI
Lesson 2.3 · Pydantic & Validation

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.

Intermediate35 min

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.

workflowWhen each check runsstep 1 / 3

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".

input
"+91 98480-12345"
output
"9848012345"
a number in
works too
"12345"
at least 10 digits

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.

Small dictionary
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.

Example 1 - validators.py (the Signup model)
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.

Example 1 - validators.py (the Booking model)
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 b

Every 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.

Output - signups and bookings
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 start

Errors 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.

Output - validators without return
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.

Which check?
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 validator

Clean/check one field; return it.

@field_validator("username")
@classmethod
def check(cls, v): ... return v
Raw input

Before the type check.

@field_validator("phone", mode="before")
Several fields

One validator for many.

@field_validator("first_name", "last_name")
Reject

Becomes a validation error.

raise ValueError("passwords do not match")
Compare fields

After all fields; return self.

@model_validator(mode="after")
def check(self): ... return self
Computed value

In 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.

Strong passwords

“Add a field validator: a password needs at least 8 characters and one digit. Send "password" and "pass1234".”

Both errors

“Send a signup with a banned username and different passwords. Fix the username and send again. What do you see each time?”

Guests per night

“Add a computed field total_guest_nights = guests * nights to Booking.”

Forget return

“Remove "return v" from username_rules and send a good signup. What does the response show?”

What usually goes wrong

Forgetting return in a field validator

The username silently became None.

✗ def clean(cls, v):
    v = v.strip().lower()
✓ def clean(cls, v):
    v = v.strip().lower()
    return v
Comparing fields in a field validator

A field validator sees one field. Compare fields in a model validator, where every field is ready.

Checking before cleaning

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 ...
Database checks in validators

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.

1.

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 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

Which validator do you use to check that end is after start?
A model validator (mode="after"), because it compares two fields.
What does mode="before" receive?
The raw input, before the type check - for example a number where a str is expected.
What was loc for "passwords do not match"?
['body'] - no field, because it is about the whole object.
What happened when a field validator forgot return?
The value silently became None.

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. 1.

    A signup has a banned username and different passwords. Which errors come back?

  2. 2.

    Why clean before checking against BANNED?

  3. 3.

    How did "nights" get into the booking response?

  4. 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...