Pydantic Validation
BaseModel and Field() — Defining Data Models
Capsule overview
Up to now you've validated data by hand. You received a dict with Body(...), you checked fields with if "title" not in book, you checked types with isinstance(), and you returned hand-written errors. It works, but it scales terribly — every new endpoint means more if statements, more error messages, more code to maintain. Pydantic solves this at the root: you define a model that describes exactly what shape your data has, and Pydantic validates, coerces types, and generates error messages automatically.
BaseModel is Pydantic's base class. When you create a class that inherits from BaseModel, every attribute with a type hint becomes a validated field. If you declare year: int and someone sends "1967" (a string), Pydantic converts it to 1967 (an int). If they send "abc", it raises a ValidationError with a clear message. All of this happens before your endpoint code runs.
Field() adds constraints to each field: that year falls between 1000 and 2030, that title has at least 1 character, that genre isn't empty. It's the difference between "I accept any integer" and "I accept an integer between 1000 and 2030."
Your first BaseModel
A Pydantic model is a class that inherits from BaseModel. Every attribute with a type hint is a validated field:
from pydantic import BaseModel
class Book(BaseModel):
title: str
author: str
year: int
genre: str
available: bool = True
| Field | Type | Required? | Why |
|---|---|---|---|
title | str | Yes | No default value → required |
author | str | Yes | No default value → required |
year | int | Yes | No default value → required |
genre | str | Yes | No default value → required |
available | bool | No | = True → has a default, so it's optional |
The rule: if a field has a default value (= True, = "fiction", = None), it's optional. If it doesn't, it's required.
With a plain Python class you can write RegularBook(title=123, author=None, year="abc") and Python won't complain — type hints are just documentation there. With BaseModel, type hints are rules that Pydantic always enforces.
Creating instances
With keyword arguments
from pydantic import BaseModel
class Book(BaseModel):
title: str
author: str
year: int
genre: str
available: bool = True
book = Book(
title="One Hundred Years of Solitude",
author="Gabriel García Márquez",
year=1967,
genre="Magical Realism"
)
print(book.title) # One Hundred Years of Solitude
print(book.year) # 1967
print(book.available) # True (default value)
From a dict or a JSON string
When the data is already a dict or a JSON string, use model_validate() or model_validate_json():
data = {"title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Experimental Novel"}
book = Book.model_validate(data)
print(book.author) # Julio Cortázar
json_str = '{"title": "Don Quixote", "author": "Cervantes", "year": 1605, "genre": "Novel"}'
book = Book.model_validate_json(json_str)
print(book.title) # Don Quixote
Both apply the same validation as the constructor.
What happens with invalid data
Wrong type
Pydantic tries to coerce compatible types ("1967" → 1967), but when that's impossible it raises ValidationError:
from pydantic import BaseModel, ValidationError
class Book(BaseModel):
title: str
author: str
year: int
genre: str
try:
Book(title="Fictions", author="Borges", year="not a number", genre="Short stories")
except ValidationError as e:
print(e)
1 validation error for Book
year
Input should be a valid integer, unable to parse string as an integer
[type=int_parsing, input_value='not a number', input_type=str]
Missing required fields
try:
Book(title="The Aleph", year=1949) # author and genre are missing
except ValidationError as e:
print(e)
# 2 validation errors for Book
# author — Field required [type=missing]
# genre — Field required [type=missing]
Pydantic reports all the errors at once — the client fixes everything in a single pass.
Extra fields and type coercion
from pydantic import BaseModel
class Book(BaseModel):
title: str
author: str
year: int
genre: str
available: bool = True
# Extra fields are silently ignored
book = Book(title="Pedro Páramo", author="Rulfo", year=1955, genre="Novel", pages=124)
print(book) # pages doesn't show up — only the defined fields do
# Automatic coercion of compatible types
book = Book(title="Aura", author="Fuentes", year="1962", genre="Novel", available="true")
print(book.year) # 1962 (str → int)
print(type(book.year)) # <class 'int'>
print(book.available) # True (str → bool)
Field() to add constraints
Knowing that year is an int rules out "abc", but it doesn't rule out -5000. Knowing that title is a str doesn't rule out "". Field() adds value constraints:
from pydantic import BaseModel, Field
class Book(BaseModel):
title: str = Field(min_length=1, max_length=200, description="Book title")
author: str = Field(min_length=1, max_length=100, description="Author's name")
year: int = Field(ge=1000, le=2030, description="Year of publication")
genre: str = Field(min_length=1, max_length=50, description="Literary genre")
available: bool = Field(default=True, description="Available for lending?")
Constraints reference
| Constraint | Applies to | Meaning | Example |
|---|---|---|---|
min_length | str | Minimum length | Field(min_length=1) → not empty |
max_length | str | Maximum length | Field(max_length=200) |
ge | int, float | Greater or equal (≥) | Field(ge=0) |
gt | int, float | Greater than (>) | Field(gt=0) |
le | int, float | Less or equal (≤) | Field(le=2030) |
lt | int, float | Less than (<) | Field(lt=100) |
pattern | str | Regex | Field(pattern=r"^\d{3}-\d+$") |
default | All | Default value | Field(default=True) |
description | All | Description for /docs | Shows up in Swagger UI |
examples | All | Example values | Field(examples=["Fiction"]) |
Validation with constraints
from pydantic import BaseModel, Field, ValidationError
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=1000, le=2030)
genre: str = Field(min_length=1)
available: bool = Field(default=True)
# ✅ Valid data
book = Book(title="One Hundred Years of Solitude", author="García Márquez", year=1967, genre="Magical Realism")
print(book.title) # One Hundred Years of Solitude
# ❌ Empty title
try:
Book(title="", author="Someone", year=2000, genre="Fiction")
except ValidationError as e:
print(e)
# title — String should have at least 1 character [type=string_too_short]
# ❌ Year out of range
try:
Book(title="Future", author="Someone", year=3000, genre="Science Fiction")
except ValidationError as e:
print(e)
# year — Input should be less than or equal to 2030 [type=less_than_equal]
# ❌ Several errors at once
try:
Book(title="", author="", year=500, genre="")
except ValidationError as e:
print(e)
# 4 validation errors: title, author, year, genre — all reported together
Using pattern for regex
from pydantic import BaseModel, Field, ValidationError
class BookWithISBN(BaseModel):
title: str = Field(min_length=1)
isbn: str = Field(pattern=r"^\d{3}-\d{10}$", description="ISBN-13")
book = BookWithISBN(title="Fictions", isbn="978-0802130303")
print(book.isbn) # 978-0802130303
try:
BookWithISBN(title="Fictions", isbn="abc")
except ValidationError as e:
print(e)
# isbn — String should match pattern '^\d{3}-\d{10}$' [type=string_pattern_mismatch]
Using BaseModel in FastAPI endpoints
The key change
With BaseModel, FastAPI automatically detects that the parameter comes from the body — you no longer need Body(). It also validates automatically and generates documentation:
from fastapi import FastAPI
from pydantic import BaseModel, Field
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=1000, le=2030)
genre: str = Field(min_length=1)
available: bool = Field(default=True)
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez",
"year": 1967, "genre": "Magical Realism", "available": True},
{"id": 2, "title": "Don Quixote", "author": "Miguel de Cervantes",
"year": 1605, "genre": "Novel", "available": True},
{"id": 3, "title": "Hopscotch", "author": "Julio Cortázar",
"year": 1963, "genre": "Experimental Novel", "available": False},
]
def generate_id():
if not books:
return 1
return max(b["id"] for b in books) + 1
@app.get("/books")
def get_books():
return books
@app.post("/books", status_code=201)
def create_book(book: Book):
book_dict = book.model_dump()
book_dict["id"] = generate_id()
books.append(book_dict)
return book_dict
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "The Aleph", "author": "Jorge Luis Borges", "year": 1949, "genre": "Short stories"}'
{"title": "The Aleph", "author": "Jorge Luis Borges", "year": 1949, "genre": "Short stories", "available": true, "id": 4}
The automatic 422 response
When a client sends invalid data, FastAPI returns a 422 with details for each error:
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "", "year": 3000}' | python -m json.tool
{
"detail": [
{"type": "string_too_short", "loc": ["body", "title"],
"msg": "String should have at least 1 character", "input": ""},
{"type": "missing", "loc": ["body", "author"], "msg": "Field required"},
{"type": "less_than_equal", "loc": ["body", "year"],
"msg": "Input should be less than or equal to 2030", "input": 3000},
{"type": "missing", "loc": ["body", "genre"], "msg": "Field required"}
]
}
Each error includes type, loc (location), msg (a readable message), and input. A frontend can use this to show field-specific errors.
model_dump() and model_dump_json()
model_dump() converts an instance to a dict. It takes options for filtering fields:
from typing import Optional
from pydantic import BaseModel, Field
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=1000, le=2030)
genre: str = Field(min_length=1)
available: bool = Field(default=True)
notes: Optional[str] = None
book = Book(title="Pedro Páramo", author="Juan Rulfo", year=1955, genre="Novel")
# Every field
book.model_dump()
# {'title': 'Pedro Páramo', 'author': 'Juan Rulfo', 'year': 1955,
# 'genre': 'Novel', 'available': True, 'notes': None}
# Exclude fields that are None
book.model_dump(exclude_none=True)
# {'title': 'Pedro Páramo', 'author': 'Juan Rulfo', 'year': 1955,
# 'genre': 'Novel', 'available': True}
# Only specific fields
book.model_dump(include={"title", "author"})
# {'title': 'Pedro Páramo', 'author': 'Juan Rulfo'}
# Exclude specific fields
book.model_dump(exclude={"available", "notes"})
# {'title': 'Pedro Páramo', 'author': 'Juan Rulfo', 'year': 1955, 'genre': 'Novel'}
model_dump_json() serializes straight to a JSON string (faster than json.dumps(book.model_dump())):
print(book.model_dump_json())
# {"title":"Pedro Páramo","author":"Juan Rulfo","year":1955,"genre":"Novel","available":true,"notes":null}
model_config
model_config is a class-level dictionary that controls the model's behavior. You define it inside the class — not as class Config (that was Pydantic v1):
from pydantic import BaseModel, Field
class Book(BaseModel):
model_config = {
"json_schema_extra": {
"examples": [{
"title": "One Hundred Years of Solitude",
"author": "Gabriel García Márquez",
"year": 1967,
"genre": "Magical Realism",
"available": True
}]
},
"str_strip_whitespace": True
}
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1000, le=2030)
genre: str = Field(min_length=1)
available: bool = Field(default=True)
| Option | Effect |
|---|---|
json_schema_extra | Adds examples to the schema (shows up pre-filled in /docs) |
str_strip_whitespace | Strips leading/trailing whitespace from strings automatically |
extra: "forbid" | Rejects fields not defined in the model |
extra: "ignore" | Silently discards extra fields (default) |
Rejecting extra fields
from pydantic import BaseModel, Field, ValidationError
class StrictBook(BaseModel):
model_config = {"extra": "forbid"}
title: str = Field(min_length=1)
author: str = Field(min_length=1)
year: int = Field(ge=1000, le=2030)
try:
StrictBook(title="Fictions", author="Borges", year=1944, pages=174)
except ValidationError as e:
print(e)
# pages — Extra inputs are not permitted [type=extra_forbidden]
When you define json_schema_extra with examples, Swagger UI (/docs) pre-fills them in the "Try it out" textarea. You no longer have to guess the body's structure.
Comparison: dicts vs BaseModel
| Aspect | dict + Body() | BaseModel |
|---|---|---|
| Type validation | Manual (isinstance()) | Automatic |
| Required fields | Manual (if "title" not in book) | Automatic (no default = required) |
| Constraints | Manual (if len(title) > 200) | Field(max_length=200) |
| Error messages | You write them | Pydantic generates them |
| IDE autocomplete | No | Yes (book.title, book.author) |
| /docs documentation | Generic schema | Schema with types and constraints |
| Type coercion | Manual | Automatic ("1967" → 1967) |
| Body() needed | Yes | No (FastAPI detects BaseModel) |
With a dict you need ~10 lines of manual validation per field. With BaseModel you define the model once and the endpoint fits in 2 lines:
# dict: manual validation for every field
@app.post("/books", status_code=201)
def create_book(book: dict = Body(...)):
if "title" not in book:
return {"error": "title is required"}
if len(book["title"]) == 0:
return {"error": "title cannot be empty"}
# ... 20 more lines of validation
return book
# BaseModel: define once, validate always
@app.post("/books", status_code=201)
def create_book(book: Book):
return book.model_dump()
Troubleshooting
Problem 1: AttributeError: 'Book' object has no attribute 'dict'
Cause: You're using .dict(), which is Pydantic v1. Modern FastAPI uses Pydantic v2.
| Pydantic v1 (deprecated) | Pydantic v2 (correct) |
|---|---|
.dict() | .model_dump() |
.json() | .model_dump_json() |
.parse_obj(data) | .model_validate(data) |
class Config: | model_config = {} |
Problem 2: Field() isn't applying any validation
Cause: Field() isn't assigned to the field with =.
# ❌ A loose Field() — it does nothing
class Book(BaseModel):
title: str
Field(min_length=1)
# ✅ Field() assigned to the field
class Book(BaseModel):
title: str = Field(min_length=1)
Problem 3: You need to add fields before returning
Cause: The model has no id, but you need to add it to the response.
@app.post("/books", status_code=201)
def create_book(book: Book):
book_dict = book.model_dump() # convert to dict
book_dict["id"] = generate_id() # add the field
return book_dict
FastAPI can return a BaseModel directly. But if you need to modify fields, convert to a dict first.
Problem 4: 422 when sending data from curl or the frontend
Cause: Content-Type: application/json is missing, the body isn't valid JSON, or required fields are missing.
Check: The header is present, JSON uses double quotes (not single ones), all required fields are included. Use /docs to test — Swagger UI sends everything correctly.
Exercises
Exercise 1: A model for authors (Easy)
Create an Author model with: name (str, 1-100 chars), country (str, 1-50 chars), birth_year (int, 1000-2026), active (bool, default True). Create two instances and print them with model_dump().
See solution
from pydantic import BaseModel, Field
class Author(BaseModel):
name: str = Field(min_length=1, max_length=100)
country: str = Field(min_length=1, max_length=50)
birth_year: int = Field(ge=1000, le=2026)
active: bool = Field(default=True)
author1 = Author(name="Gabriel García Márquez", country="Colombia", birth_year=1927, active=False)
author2 = Author(name="Isabel Allende", country="Chile", birth_year=1942)
print(author1.model_dump())
# {'name': 'Gabriel García Márquez', 'country': 'Colombia', 'birth_year': 1927, 'active': False}
print(author2.model_dump())
# {'name': 'Isabel Allende', 'country': 'Chile', 'birth_year': 1942, 'active': True}
active takes the default True for author2.
Exercise 2: A POST endpoint with BaseModel (Easy)
Create a FastAPI app with Book (using Field constraints), 2 initial books, and GET /books and POST /books endpoints. Test it with valid and invalid data from curl.
See solution
from fastapi import FastAPI
from pydantic import BaseModel, Field
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=1000, le=2030)
genre: str = Field(min_length=1)
available: bool = Field(default=True)
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "García Márquez",
"year": 1967, "genre": "Magical Realism", "available": True},
{"id": 2, "title": "Hopscotch", "author": "Julio Cortázar",
"year": 1963, "genre": "Experimental Novel", "available": True},
]
def generate_id():
if not books:
return 1
return max(b["id"] for b in books) + 1
@app.get("/books")
def get_books():
return books
@app.post("/books", status_code=201)
def create_book(book: Book):
book_dict = book.model_dump()
book_dict["id"] = generate_id()
books.append(book_dict)
return book_dict
# ✅ Valid
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Fictions", "author": "Borges", "year": 1944, "genre": "Short stories"}'
# {"title":"Fictions","author":"Borges","year":1944,"genre":"Short stories","available":true,"id":3}
# ❌ Invalid
curl -s -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "", "year": 5000}' | python -m json.tool
# {"detail": [{"type": "string_too_short", ...}, {"type": "missing", ...}, ...]}
Exercise 3: model_dump() with filters (Medium)
Add an Optional[str] field called notes (default None) to the model. Create an instance without notes and show 4 ways to serialize it: complete, without nulls, title/author only, and excluding available.
See solution
from typing import Optional
from pydantic import BaseModel, Field
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=1000, le=2030)
genre: str = Field(min_length=1)
available: bool = Field(default=True)
notes: Optional[str] = None
book = Book(title="Aura", author="Carlos Fuentes", year=1962, genre="Novella")
book.model_dump()
# {'title': 'Aura', ..., 'available': True, 'notes': None}
book.model_dump(exclude_none=True)
# {'title': 'Aura', ..., 'available': True} ← notes disappears
book.model_dump(include={"title", "author"})
# {'title': 'Aura', 'author': 'Carlos Fuentes'}
book.model_dump(exclude={"available", "notes"})
# {'title': 'Aura', 'author': 'Carlos Fuentes', 'year': 1962, 'genre': 'Novella'}
Exercise 4: A model with a strict model_config (Medium)
Create a StrictBook that rejects extra fields and strips whitespace from strings. Test it with extra fields and with whitespace around the title.
See solution
from pydantic import BaseModel, Field, ValidationError
class StrictBook(BaseModel):
model_config = {"extra": "forbid", "str_strip_whitespace": True}
title: str = Field(min_length=1, max_length=200)
author: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1000, le=2030)
genre: str = Field(min_length=1)
# ✅ Whitespace gets stripped
book = StrictBook(title=" One Hundred Years ", author=" García Márquez ", year=1967, genre="Magical Realism")
print(book.title) # One Hundred Years (no leading/trailing whitespace)
print(book.author) # García Márquez
# ❌ Extra fields rejected
try:
StrictBook(title="Fictions", author="Borges", year=1944, genre="Short stories", pages=174)
except ValidationError as e:
print(e)
# pages — Extra inputs are not permitted [type=extra_forbidden]
Exercise 5: CRUD with GET and POST using BaseModel (Hard)
Create an app with a Movie model (title, director, year, genre, rating float 0-10) and these endpoints: GET /movies, GET /movies/{movie_id} (404 if it doesn't exist), POST /movies (status 201). Include 3 initial movies.
See solution
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
class Movie(BaseModel):
title: str = Field(min_length=1, max_length=200)
director: str = Field(min_length=1, max_length=100)
year: int = Field(ge=1888, le=2030)
genre: str = Field(min_length=1, max_length=50)
rating: float = Field(ge=0, le=10)
app = FastAPI()
movies = [
{"id": 1, "title": "Pan's Labyrinth", "director": "Guillermo del Toro",
"year": 2006, "genre": "Fantasy", "rating": 8.2},
{"id": 2, "title": "Roma", "director": "Alfonso Cuarón",
"year": 2018, "genre": "Drama", "rating": 7.7},
{"id": 3, "title": "Amores Perros", "director": "A. González Iñárritu",
"year": 2000, "genre": "Drama", "rating": 8.1},
]
def generate_id():
if not movies:
return 1
return max(m["id"] for m in movies) + 1
@app.get("/movies")
def get_movies():
return movies
@app.get("/movies/{movie_id}")
def get_movie(movie_id: int):
for movie in movies:
if movie["id"] == movie_id:
return movie
raise HTTPException(status_code=404, detail="Movie not found")
@app.post("/movies", status_code=201)
def create_movie(movie: Movie):
movie_dict = movie.model_dump()
movie_dict["id"] = generate_id()
movies.append(movie_dict)
return movie_dict
curl -s -X POST http://127.0.0.1:8000/movies \
-H "Content-Type: application/json" \
-d '{"title": "Y Tu Mamá También", "director": "Cuarón", "year": 2001, "genre": "Drama", "rating": 7.6}'
# {"title":"Y Tu Mamá También",...,"id":4}
Summary
BaseModelis Pydantic's base class — inherit from it for models with automatic validation- Attributes with type hints are validated fields; no default = required, with a default = optional
model_validate()creates instances from a dict;model_validate_json()from a JSON string- Pydantic reports all validation errors at once
Field()adds constraints:min_length,max_length,ge,gt,le,lt,pattern- In FastAPI, a
BaseModelas the type removes the need forBody() - Invalid data produces an automatic 422 with per-field details
model_dump()converts to a dict; it takesexclude_none,include,excludemodel_dump_json()serializes straight to a JSON stringmodel_configconfigures:extra,str_strip_whitespace,json_schema_extra- Always Pydantic v2:
model_dump()not.dict(),model_confignotclass Config
Next capsule: Validators and Nested Models — @field_validator for custom validations and composite models.
Additional resources
- Pydantic v2 - Models - Official BaseModel documentation
- Pydantic v2 - Fields - Field(), constraints, and options
- FastAPI - Request Body - How FastAPI uses Pydantic models for request bodies
- FastAPI - Body - Fields - Using Field() inside endpoints
- Pydantic v2 - Model Config - model_config options
- Pydantic v2 - Serialization - model_dump() and serialization options