Pydantic Validation
Custom Validators and Nested Models
Capsule overview
In the previous capsule you learned to use Field() to put constraints on your data: minimum lengths, numeric ranges, regex patterns. That covers "generic" validations — but what happens when you need business rules? Things like "the title can't be only whitespace", "the year can't be in the future", or "the genre must be one from an allowed list." That's what @field_validator is for: a Pydantic v2 decorator that lets you write custom validation logic right inside your model.
But validators aren't the only thing you'll learn here. Real data is rarely flat. A book has a publisher, and that publisher has an address. A book can have a list of tags. You model those complex structures with nested models — Pydantic models inside other models. FastAPI validates the whole hierarchy automatically: if the client's JSON has an error in the publisher's address, you get a 422 error with the exact location of the problem.
By the end of this capsule you'll be able to create custom validators for business logic, nest models for complex data, use lists of models, handle optional models, and use @computed_field for calculated fields. All with Pydantic v2.
When Field() isn't enough
Field() handles numeric and string constraints. But some validations just can't be expressed with ge, le, min_length, or pattern:
- "The title can't be only whitespace"
- "The year can't be in the future"
- "The genre must be one of: Novel, Short story, Poetry, Essay"
These are business rules. For those, Pydantic gives you @field_validator.
@field_validator: your first custom validator
@field_validator turns a class method into a validation function. It receives the field's value, validates it, and returns it (optionally transformed):
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator
app = FastAPI()
class Book(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1450, le=2026)
genre: str = Field(min_length=1, max_length=50)
@field_validator("title")
@classmethod
def title_must_not_be_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Title cannot be empty or whitespace")
return v.strip().title()
@field_validator("year")
@classmethod
def year_not_in_future(cls, v: int) -> int:
from datetime import datetime
if v > datetime.now().year:
raise ValueError(f"Year {v} is in the future")
return v
books = []
@app.post("/books", status_code=201)
def create_book(book: Book):
books.append(book.model_dump())
return book
Three rules for @field_validator:
- It always carries
@classmethodunderneath it — it's a class method, not an instance method - It receives
clsand the value (v) — notself - It must return the value — if you return nothing, the field ends up as
None
Let's try it:
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "one hundred years of solitude", "author": "García Márquez", "year": 1967, "genre": "Novel"}'
Expected output:
{"title": "One Hundred Years Of Solitude", "author": "García Márquez", "year": 1967, "genre": "Novel"}
The validator turned "one hundred years of solitude" into "One Hundred Years Of Solitude". It doesn't just validate — it normalizes.
With a year in the future:
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Future Book", "author": "Author", "year": 2099, "genre": "Fiction"}'
Expected output:
{"detail": [{"type": "value_error", "loc": ["body", "year"], "msg": "Value error, Year 2099 is in the future"}]}
A validator with a list of allowed values
A very common case: restricting a field to a set of valid values.
ALLOWED_GENRES = ["novel", "short story", "poetry", "essay", "theater", "chronicle"]
class Book(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1450, le=2026)
genre: str = Field(min_length=1, max_length=50)
@field_validator("genre")
@classmethod
def genre_must_be_allowed(cls, v: str) -> str:
if v.strip().lower() not in ALLOWED_GENRES:
raise ValueError(f"Genre '{v}' not allowed. Options: {', '.join(ALLOWED_GENRES)}")
return v.strip().capitalize()
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "My Book", "author": "Author", "year": 2020, "genre": "Thriller"}'
Expected output:
{"detail": [{"type": "value_error", "loc": ["body", "genre"], "msg": "Value error, Genre 'Thriller' not allowed. Options: novel, short story, poetry, essay, theater, chronicle"}]}
The validator also normalizes with capitalize: "NOVEL" → "Novel", "novel" → "Novel".
Validator modes: before vs after
By default, @field_validator runs after Pydantic validates the type (mode="after"). Sometimes you need to step in before — to normalize the raw input before the type conversion.
from pydantic import BaseModel, field_validator
class Book(BaseModel):
title: str
genre: str
@field_validator("genre", mode="before")
@classmethod
def normalize_genre(cls, v):
if isinstance(v, str):
return v.strip().lower()
return v
With mode="before", the value is the raw data from the JSON. You need to check the type with isinstance().
| Aspect | mode="after" (default) | mode="before" |
|---|---|---|
| The value | Already has the right type | It's the raw input |
| Type safety | Guaranteed | Not guaranteed |
| Use case | Business rules | Normalizing before type validation |
| Example | "year not in the future" | "strip whitespace from the string" |
| Check the type? | Not needed | Yes, with isinstance() |
Rule: use mode="after" whenever you can. Only reach for mode="before" when you need to transform the data before type validation.
Validating multiple fields with a single validator
Apply the same validator to several fields by passing multiple names:
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator
app = FastAPI()
class Book(BaseModel):
title: str = Field(max_length=200)
author: str = Field(max_length=100)
genre: str = Field(max_length=50)
@field_validator("title", "author", "genre")
@classmethod
def must_not_be_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Field cannot be empty or whitespace")
return v.strip()
@app.post("/books", status_code=201)
def create_book(book: Book):
return book
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": " ", "author": "", "genre": "Novel"}'
Expected output:
{
"detail": [
{"type": "value_error", "loc": ["body", "title"], "msg": "Value error, Field cannot be empty or whitespace"},
{"type": "value_error", "loc": ["body", "author"], "msg": "Value error, Field cannot be empty or whitespace"}
]
}
Pydantic reports all the errors at once — the client fixes everything in a single pass.
Nested models: data with complex structure
Real data has structure: a book has a publisher, and that publisher has an address. You model this by nesting one BaseModel inside another.
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class Address(BaseModel):
street: str = Field(min_length=1)
city: str = Field(min_length=1)
country: str = "Mexico"
class Publisher(BaseModel):
name: str = Field(min_length=1, max_length=100)
address: Address
class Book(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1450, le=2026)
publisher: Publisher
@app.post("/books", status_code=201)
def create_book(book: Book):
return book
Book contains Publisher, which contains Address. FastAPI validates the whole hierarchy straight from the JSON:
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez", "year": 1967, "publisher": {"name": "Editorial Sudamericana", "address": {"street": "Humberto Primo 555", "city": "Buenos Aires", "country": "Argentina"}}}'
The response returns the complete structure with all three levels validated. If a field is missing at any level:
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Test", "author": "Author", "year": 2020, "publisher": {"name": "Ed", "address": {"city": "CDMX"}}}'
Expected output:
{"detail": [{"type": "missing", "loc": ["body", "publisher", "address", "street"], "msg": "Field required"}]}
The loc says exactly where the error is: body → publisher → address → street.
Lists of nested models
A book can have several tags:
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class Tag(BaseModel):
name: str = Field(min_length=1, max_length=30)
color: str = "blue"
class Book(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=100)
tags: list[Tag] = []
@app.post("/books", status_code=201)
def create_book(book: Book):
return book
tags: list[Tag] = [] — a list of Tag models, defaulting to empty. FastAPI validates every element:
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Hopscotch", "author": "Julio Cortázar", "tags": [{"name": "experimental", "color": "purple"}, {"name": "latin-american"}, {"name": "classic", "color": "gold"}]}'
The response shows the three tags; the second one got color: "blue" by default. If an element has an error, loc includes the index:
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Test", "author": "Author", "tags": [{"name": "ok"}, {"name": ""}]}'
{"detail": [{"type": "string_too_short", "loc": ["body", "tags", 1, "name"], "msg": "String should have at least 1 character"}]}
tags → 1 → name — the tag at position 1 has an empty name.
Optional nested models
A book doesn't always have a publisher. To make it optional:
class Book(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1450, le=2026)
publisher: Publisher | None = None
publisher: Publisher | None = None — it accepts a Publisher or None. If you don't send it:
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Fictions", "author": "Borges", "year": 1944}'
{"title": "Fictions", "author": "Borges", "year": 1944, "publisher": null}
But if you do send it, it has to be completely valid — it's "all or nothing". Sending {"publisher": {"name": ""}} produces errors both for the empty name and for the missing address.
Computed fields: calculated fields
Pydantic v2 introduces @computed_field — fields whose value is calculated from other fields. They aren't sent in the request, but they show up in the response:
from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel, Field, computed_field
app = FastAPI()
class Book(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1450, le=2026)
@computed_field
@property
def age(self) -> int:
return datetime.now().year - self.year
@computed_field
@property
def is_classic(self) -> bool:
return self.age > 50
@app.post("/books", status_code=201)
def create_book(book: Book):
return book
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Hopscotch", "author": "Julio Cortázar", "year": 1963}'
Expected output:
{"title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "age": 63, "is_classic": true}
The client sent 3 fields, the response has 5. age and is_classic were calculated automatically.
Comparison: Field() vs @field_validator
| Aspect | Field() | @field_validator |
|---|---|---|
| Type of validation | Generic constraints | Custom business logic |
| Examples | min_length, ge, le, pattern | "year not in the future", "allowed genre" |
| Normalization | Doesn't transform data | Can transform and return |
| Complexity | One parameter | A full function |
| Multiple fields | One per field | One for several fields |
| Errors | Automatic messages | Custom messages |
Rule of thumb: start with Field(). If it can't express the validation, reach for @field_validator. Don't write a validator for something Field(ge=0) already solves.
Connection to the project
The validators and nested models you learned here apply directly to the Module 6 project (the CRUD API). A Task could have a nested Category, a list of Tags, and validators that make sure the due date isn't in the past. Combining Field() (capsule 02) with @field_validator (this capsule) gives you a complete validation system before the data ever touches your business logic.
Troubleshooting
Problem 1: The validator doesn't run — the field isn't transformed
Cause: You forgot @classmethod underneath @field_validator, or you don't return the value.
Fix:
# ❌ Missing @classmethod
@field_validator("title")
def fix_title(cls, v: str) -> str:
return v.strip().title()
# ❌ Doesn't return the value — the field ends up as None
@field_validator("title")
@classmethod
def fix_title(cls, v: str):
v.strip().title()
# ✅ Correct
@field_validator("title")
@classmethod
def fix_title(cls, v: str) -> str:
return v.strip().title()
Problem 2: TypeError when validating with mode="before"
Cause: You operate on the raw value without checking its type.
Fix:
# ❌ Assumes v is a string
@field_validator("genre", mode="before")
@classmethod
def normalize(cls, v):
return v.strip().lower()
# ✅ Check the type first
@field_validator("genre", mode="before")
@classmethod
def normalize(cls, v):
if isinstance(v, str):
return v.strip().lower()
return v
Problem 3: A nested model raises "field required" even though it's optional
Cause: You declared the field as Publisher without | None = None.
Fix:
# ❌ publisher is required
class Book(BaseModel):
publisher: Publisher
# ✅ publisher is optional
class Book(BaseModel):
publisher: Publisher | None = None
Problem 4: @computed_field doesn't show up in the response
Cause: You forgot @property underneath @computed_field.
Fix:
# ❌ Missing @property
@computed_field
def age(self) -> int:
return 2026 - self.year
# ✅ Correct
@computed_field
@property
def age(self) -> int:
return 2026 - self.year
Exercises
Exercise 1: A basic email validator (Easy)
Create an Author model with name (str) and email (str). Add a @field_validator for email that checks it contains @ and .. If it doesn't, raise a ValueError. Normalize to lowercase. Endpoint POST /authors.
See solution
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator
app = FastAPI()
class Author(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: str = Field(min_length=5, max_length=100)
@field_validator("email")
@classmethod
def email_must_be_valid(cls, v: str) -> str:
if "@" not in v or "." not in v:
raise ValueError("Email must contain '@' and '.'")
return v.strip().lower()
@app.post("/authors", status_code=201)
def create_author(author: Author):
return author
curl -X POST http://127.0.0.1:8000/authors \
-H "Content-Type: application/json" \
-d '{"name": "Gabriel García Márquez", "email": "GABO@LITERATURA.COM"}'
# → {"name": "Gabriel García Márquez", "email": "gabo@literatura.com"}
curl -X POST http://127.0.0.1:8000/authors \
-H "Content-Type: application/json" \
-d '{"name": "Author", "email": "invalid"}'
# → Error 422: "Email must contain '@' and '.'"
Exercise 2: A nested model with an address (Easy)
Create a Library model with name (str) and address (an Address with street, city, and zip_code). Make zip_code a string of exactly 5 characters using Field(min_length=5, max_length=5). Endpoint POST /libraries.
See solution
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class Address(BaseModel):
street: str = Field(min_length=1)
city: str = Field(min_length=1)
zip_code: str = Field(min_length=5, max_length=5)
class Library(BaseModel):
name: str = Field(min_length=1, max_length=100)
address: Address
@app.post("/libraries", status_code=201)
def create_library(library: Library):
return library
curl -X POST http://127.0.0.1:8000/libraries \
-H "Content-Type: application/json" \
-d '{"name": "Biblioteca Vasconcelos", "address": {"street": "Eje 1 Norte", "city": "CDMX", "zip_code": "06040"}}'
# → {"name": "Biblioteca Vasconcelos", "address": {"street": "Eje 1 Norte", "city": "CDMX", "zip_code": "06040"}}
curl -X POST http://127.0.0.1:8000/libraries \
-H "Content-Type: application/json" \
-d '{"name": "Biblio", "address": {"street": "Calle", "city": "CDMX", "zip_code": "123"}}'
# → Error 422: "String should have at least 5 characters"
Exercise 3: A validator that normalizes several fields (Medium)
Create a Movie model with title, director, and genre (all strings). Write a single @field_validator that applies to all three: it rejects empty/whitespace strings and normalizes with .strip().title(). Test it by sending " the godfather " as the title.
See solution
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator
app = FastAPI()
class Movie(BaseModel):
title: str = Field(max_length=200)
director: str = Field(max_length=100)
genre: str = Field(max_length=50)
@field_validator("title", "director", "genre")
@classmethod
def normalize_text(cls, v: str) -> str:
if not v.strip():
raise ValueError("Field cannot be empty or whitespace")
return v.strip().title()
@app.post("/movies", status_code=201)
def create_movie(movie: Movie):
return movie
curl -X POST http://127.0.0.1:8000/movies \
-H "Content-Type: application/json" \
-d '{"title": " the godfather ", "director": "francis ford coppola", "genre": "drama"}'
# → {"title": "The Godfather", "director": "Francis Ford Coppola", "genre": "Drama"}
Exercise 4: A book with a list of authors and a computed field (Medium)
Create Author with name (str) and country (str, default "Desconocido"). Create Book with title, year, and authors (list[Author]). Add a validator that checks the list has at least 1 author. Add a @computed_field that returns author_count. Endpoint POST /books.
See solution
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator, computed_field
app = FastAPI()
class Author(BaseModel):
name: str = Field(min_length=1, max_length=100)
country: str = "Desconocido"
class Book(BaseModel):
title: str = Field(min_length=1, max_length=200)
year: int = Field(ge=1450, le=2026)
authors: list[Author]
@field_validator("authors")
@classmethod
def at_least_one_author(cls, v: list[Author]) -> list[Author]:
if len(v) == 0:
raise ValueError("Book must have at least one author")
return v
@computed_field
@property
def author_count(self) -> int:
return len(self.authors)
@app.post("/books", status_code=201)
def create_book(book: Book):
return book
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Don Quixote", "year": 1605, "authors": [{"name": "Cervantes", "country": "Spain"}]}'
# → {"title": "Don Quixote", "year": 1605, "authors": [...], "author_count": 1}
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Book", "year": 2020, "authors": []}'
# → Error 422: "Book must have at least one author"
Exercise 5: A complete model with validation and nesting (Hard)
Build a courses API. Instructor with name and email (validate @). Lesson with title (str) and duration_minutes (int, ge=1). Course with title (not empty), price (float, ge=0), instructor (Instructor), lessons (list[Lesson], at least 1), and a @computed_field for total_duration (the sum of the durations). Endpoint POST /courses.
See solution
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator, computed_field
app = FastAPI()
class Instructor(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: str = Field(min_length=5, max_length=100)
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
if "@" not in v:
raise ValueError("Email must contain '@'")
return v.strip().lower()
class Lesson(BaseModel):
title: str = Field(min_length=1, max_length=200)
duration_minutes: int = Field(ge=1)
class Course(BaseModel):
title: str = Field(min_length=1, max_length=200)
price: float = Field(ge=0)
instructor: Instructor
lessons: list[Lesson]
@field_validator("lessons")
@classmethod
def at_least_one_lesson(cls, v: list[Lesson]) -> list[Lesson]:
if len(v) == 0:
raise ValueError("Course must have at least one lesson")
return v
@computed_field
@property
def total_duration(self) -> int:
return sum(lesson.duration_minutes for lesson in self.lessons)
@app.post("/courses", status_code=201)
def create_course(course: Course):
return course
curl -X POST http://127.0.0.1:8000/courses \
-H "Content-Type: application/json" \
-d '{"title": "FastAPI from Scratch", "price": 49.99, "instructor": {"name": "Ana Developer", "email": "ANA@DEV.COM"}, "lessons": [{"title": "Introduction", "duration_minutes": 15}, {"title": "First Endpoint", "duration_minutes": 30}, {"title": "Pydantic Models", "duration_minutes": 45}]}'
The response includes "email": "ana@dev.com" (normalized) and "total_duration": 90 (calculated: 15 + 30 + 45).
Summary
@field_validatoradds custom validation logic whenField()isn't enough — business rules, normalization, allowed lists- Three rules for a validator: it carries
@classmethod, it receivesclsandv, and it must return the value mode="before"validates the raw input;mode="after"(the default) validates the already-typed value- Multiple fields:
@field_validator("title", "author")applies one validator to several fields - Nested models: a
BaseModelinside another models complex data; FastAPI validates the whole hierarchy automatically list[Model]models lists of nested models — Pydantic validates each element and points to the index of the error- Optional:
Publisher | None = Nonemakes the nested model optional — "all or nothing" if it is sent @computed_fieldwith@propertycreates calculated fields that show up in the response without being sent in the request- Rule of thumb: start with
Field(), reach for@field_validatoronly whenField()falls short
Next capsule: Request vs Response Models — You'll learn to split models for input and output: BookCreate, BookUpdate, BookResponse, and how to use response_model to filter sensitive data.
Additional resources
- Pydantic v2 - Validators - Official documentation on validators in Pydantic v2
- Pydantic v2 - Computed Fields - Calculated fields with @computed_field
- FastAPI - Nested Models - Nested models in FastAPI
- Pydantic v2 - Field Validators - Complete @field_validator reference
- FastAPI - Body - Nested Models - Deeply nested models
- Pydantic v2 - Migration Guide - Differences between Pydantic v1 and v2