FastAPI · lesson 8 of 19
Beginner
6Intermediate
10- 7 FastAPI Pydantic Validation: Field Constraints and Nested Models
- 8 FastAPI Validators and Serialization with Pydantic
- 9 FastAPI CRUD with SQLModel and SQLite
- 10 FastAPI Database Migrations with Alembic and SQLModel
- 11 FastAPI Authentication: Password Hashing, OAuth2 and JWT
- 12 FastAPI Roles, CORS and Settings from Environment Variables
- 13 FastAPI Dependency Injection: Depends, yield and Overrides
- 14 Testing FastAPI with pytest, TestClient and httpx
- 15 FastAPI with Async PostgreSQL: Pools, Transactions, and Real Errors
- 16 Sync vs Async Endpoints in FastAPI, Measured
Advanced
3Also part of FastAPI
6FastAPI Validators and Serialization with Pydantic
Custom checks with field_validator and model_validator, then control the JSON out with model_dump, camelCase aliases, serializers and computed fields.
By SkillByExample · Published
Rules That Types Can’t Express
Pydantic Validation used Field() for rules about single values: a minimum, a length, a pattern. Some rules need code. A username might have to be lower-cased and not reserved, or a check-out date might have to come after the check-in. Pydantic runs validators for those. On the way out, serialization settings decide what the JSON looks like: field names, number formats and derived values.
Outputs come from FastAPI 0.143.0, Pydantic 2.14.1 and Starlette 1.7.0 on Python 3.12. Each file runs its app through TestClient (pip install httpx2), so run it with python <file>.
field_validator: Check and Clean One Field
Save as field_validators.py:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, field_validator
RESERVED = {"admin", "root"}
class Signup(BaseModel):
username: str
@field_validator("username")
@classmethod
def normalise(cls, value: str) -> str:
value = value.strip().lower()
if value in RESERVED:
raise ValueError(f"'{value}' is reserved")
return value
app = FastAPI()
@app.post("/signup")
def signup(s: Signup):
return s
client = TestClient(app)
print(client.post("/signup", json={"username": " Ada "}).json())
print(client.post("/signup", json={"username": "Admin"}).json())
$ python field_validators.py
{'username': 'ada'}
{'detail': [{'type': 'value_error', 'loc': ['body', 'username'], 'msg': "Value error, 'admin' is reserved", 'input': 'Admin', 'ctx': {'error': {}}}]}
A validator does two jobs. Whatever it returns becomes the field’s value, so " Ada " is stored as "ada". If it raises ValueError, Pydantic turns the error into a normal 422 entry with type: value_error, prefixes your message with Value error, , and shows the original input, "Admin". ctx.error arrives as {} because FastAPI can’t turn the exception object into JSON, so put everything the client needs in the message. Validators are class methods, so stack @classmethod under @field_validator.
mode="before": Get the Raw Input
A validator runs after type conversion by default, so value already has the declared type. To accept a format the type would reject, run it before. Save as before.py:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, field_validator
class Post(BaseModel):
title: str
tags: list[str] = []
@field_validator("tags", mode="before")
@classmethod
def split_csv(cls, value):
if isinstance(value, str):
return [t.strip() for t in value.split(",") if t.strip()]
return value
app = FastAPI()
@app.post("/posts")
def create(p: Post):
return p
client = TestClient(app)
print(client.post("/posts", json={"title": "Hi", "tags": "python, web,,api"}).json())
print(client.post("/posts", json={"title": "Hi", "tags": ["python"]}).json())
print(client.post("/posts", json={"title": "Hi", "tags": 42}).json())
$ python before.py
{'title': 'Hi', 'tags': ['python', 'web', 'api']}
{'title': 'Hi', 'tags': ['python']}
{'detail': [{'type': 'list_type', 'loc': ['body', 'tags'], 'msg': 'Input should be a valid list', 'input': 42}]}
The string "python, web,,api" would fail list[str], but the before-validator turns it into a list first, dropping the empty piece. A real list passes through untouched. Anything else is returned as it is and left to the normal type check, so 42 still gets an ordinary list_type error. A before-validator receives raw JSON values, so it shouldn’t assume a type; that’s why the parameter has no annotation.
model_validator: Rules Across Fields
A field validator sees one field. To compare two, validate the whole model. Save as model_validators.py:
from datetime import date
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, model_validator
class Stay(BaseModel):
check_in: date
check_out: date
@model_validator(mode="after")
def dates_in_order(self):
if self.check_out <= self.check_in:
raise ValueError("check_out must be after check_in")
return self
app = FastAPI()
@app.post("/stays")
def book(stay: Stay):
return {"nights": (stay.check_out - stay.check_in).days}
client = TestClient(app)
print(client.post("/stays", json={"check_in": "2026-10-13", "check_out": "2026-10-16"}).json())
print(client.post("/stays", json={"check_in": "2026-10-13", "check_out": "2026-10-13"}).json())
print(client.post("/stays", json={"check_in": "2026-10-13", "check_out": "soon"}).json())
$ python model_validators.py
{'nights': 3}
{'detail': [{'type': 'value_error', 'loc': ['body'], 'msg': 'Value error, check_out must be after check_in', 'input': {'check_in': '2026-10-13', 'check_out': '2026-10-13'}, 'ctx': {'error': {}}}]}
{'detail': [{'type': 'date_from_datetime_parsing', 'loc': ['body', 'check_out'], 'msg': 'Input should be a valid date or datetime, input is too short', 'input': 'soon', 'ctx': {'error': 'input is too short'}}]}
An after model validator is an instance method: self is the finished model, with real date objects to compare, and it must return self. Its error belongs to no single field, so loc is just ['body'] and input is the whole body. The third request shows the order things run in: "soon" failed the date field, so the model was never built and the cross-field check never ran. The client fixes the field error first, then sees the date-order error if it still applies.
Pitfall: 422 bodies echo what the client sent. Because input is the whole body here, a sign-up form’s password would come straight back in the error, and into any log that records responses. The same happens with missing errors, which also put the full body in input, as the Pydantic validation lesson showed. To stop it, register a RequestValidationError handler that drops the field before responding, for example returning [{k: v for k, v in e.items() if k != "input"} for e in exc.errors()] as the detail.
model_dump: Choosing What a Model Turns Into
Serialization is the reverse trip, from model to dict or JSON. It’s plain Pydantic, so this script has no FastAPI. Save as dump.py:
from datetime import date
from decimal import Decimal
from pydantic import BaseModel
class Invoice(BaseModel):
number: str
issued: date
total: Decimal
note: str | None = None
paid: bool = False
inv = Invoice(number="INV-7", issued="2026-10-13", total="19.90", paid=False)
print(inv.model_dump())
print(inv.model_dump(mode="json"))
print(inv.model_dump(exclude_none=True, exclude={"paid"}))
print(inv.model_dump(exclude_unset=True))
print(inv.model_dump_json())
$ python dump.py
{'number': 'INV-7', 'issued': datetime.date(2026, 10, 13), 'total': Decimal('19.90'), 'note': None, 'paid': False}
{'number': 'INV-7', 'issued': '2026-10-13', 'total': '19.90', 'note': None, 'paid': False}
{'number': 'INV-7', 'issued': datetime.date(2026, 10, 13), 'total': Decimal('19.90')}
{'number': 'INV-7', 'issued': datetime.date(2026, 10, 13), 'total': Decimal('19.90'), 'paid': False}
{"number":"INV-7","issued":"2026-10-13","total":"19.90","note":null,"paid":false}
Plain model_dump() keeps Python types, so json.dumps on it would fail on the date. mode="json" converts to JSON-safe values. Note that a Decimal becomes the string "19.90", which keeps it exact but surprises clients expecting a number. exclude_none and exclude drop keys. exclude_unset keeps only the fields that were actually passed. paid=False was passed explicitly, so it stays even though it equals the default, while note goes. That’s the basis of PATCH updates, used in CRUD with SQLModel. FastAPI runs the JSON-mode version of this on every response.
Aliases: camelCase Outside, snake_case Inside
JavaScript clients usually send firstName; Python code wants first_name. An alias maps one to the other. Save as aliases.py:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class Profile(BaseModel):
model_config = ConfigDict(alias_generator=to_camel)
first_name: str
last_name: str
is_active: bool = True
app = FastAPI()
@app.post("/profiles")
def create(p: Profile) -> Profile:
print("inside:", p.first_name, p.model_dump())
return p
client = TestClient(app)
print(client.post("/profiles", json={"firstName": "Ada", "lastName": "Lovelace"}).json())
r = client.post("/profiles", json={"first_name": "Ada", "last_name": "Lovelace"})
print(r.status_code, [e["loc"] for e in r.json()["detail"]])
$ python aliases.py
inside: Ada {'first_name': 'Ada', 'last_name': 'Lovelace', 'is_active': True}
{'firstName': 'Ada', 'lastName': 'Lovelace', 'isActive': True}
422 [['body', 'firstName'], ['body', 'lastName']]
alias_generator=to_camel gives every field a camelCase alias, so you don’t write Field(alias=...) on each one. Inside the function you use Python names, and model_dump() uses them too. The response came back in camelCase, though, because FastAPI dumps response models by alias. The last request is the catch: once a field has an alias, the alias is the only name the request may use, so first_name is reported missing as firstName. Practice task 1 accepts both.
Serializers and Computed Fields
A field_serializer changes how one field is written out. A computed_field adds a value derived from the others to the output. Save as computed.py:
from decimal import Decimal
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, computed_field, field_serializer
class Line(BaseModel):
sku: str
unit_price: Decimal
qty: int
@computed_field
@property
def subtotal(self) -> Decimal:
return self.unit_price * self.qty
@field_serializer("unit_price", "subtotal")
def as_float(self, value: Decimal) -> float:
return float(value)
app = FastAPI()
@app.post("/lines")
def add(line: Line) -> Line:
return line
client = TestClient(app)
print(client.post("/lines", json={"sku": "MUG", "unit_price": "4.50", "qty": 3}).json())
print(client.post("/lines", json={"sku": "MUG", "unit_price": "4.50", "qty": 3, "subtotal": 1}).json())
print(sorted(app.openapi()["components"]["schemas"]))
$ python computed.py
{'sku': 'MUG', 'unit_price': 4.5, 'qty': 3, 'subtotal': 13.5}
{'sku': 'MUG', 'unit_price': 4.5, 'qty': 3, 'subtotal': 13.5}
['HTTPValidationError', 'Line-Input', 'Line-Output', 'ValidationError']
Prices are stored as exact Decimals but leave as JSON numbers, 4.5 instead of the string "4.50" that mode="json" gave the invoice. The serializer covers the computed subtotal too. subtotal is a read-only property, so a client can’t set it: the "subtotal": 1 in the second request was ignored and recomputed. Because the model now has a different shape in each direction (the computed field, and also the Decimal price on its own), FastAPI publishes two schemas, Line-Input without subtotal and Line-Output with it, so generated clients know which fields they send and which they receive.
Practice
- Make
Profilefromaliases.pyaccept bothfirstNameandfirst_namein requests, while still answering in camelCase.
Solution
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class Profile(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True)
first_name: str
last_name: str
app = FastAPI()
@app.post("/profiles")
def create(p: Profile) -> Profile:
return p
client = TestClient(app)
print(client.post("/profiles", json={"firstName": "Ada", "lastName": "Lovelace"}).json())
print(client.post("/profiles", json={"first_name": "Ada", "last_name": "Lovelace"}).json())
$ python p1.py
{'firstName': 'Ada', 'lastName': 'Lovelace'}
{'firstName': 'Ada', 'lastName': 'Lovelace'}
validate_by_name=True lets input use the Python name as well as the alias. Output still goes by alias. validate_by_name arrived in Pydantic 2.11; older versions use populate_by_name=True for the same effect.
- A
Signuphaspasswordandconfirm_password. Reject the request when they differ, and return only the username on success. Then look closely at the 422 body. What does it leak?
Solution
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, model_validator
class Signup(BaseModel):
username: str
password: str
confirm_password: str
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.confirm_password:
raise ValueError("passwords do not match")
return self
app = FastAPI()
@app.post("/signup", status_code=201)
def signup(s: Signup):
return {"username": s.username}
client = TestClient(app)
for confirm in ["hunter2", "hunter3"]:
body = {"username": "ada", "password": "hunter2", "confirm_password": confirm}
r = client.post("/signup", json=body)
print(r.status_code, r.json())
$ python p2.py
201 {'username': 'ada'}
422 {'detail': [{'type': 'value_error', 'loc': ['body'], 'msg': 'Value error, passwords do not match', 'input': {'username': 'ada', 'password': 'hunter2', 'confirm_password': 'hunter3'}, 'ctx': {'error': {}}}]}
The success response is safe, but the 422 echoes the whole body, both passwords included, in input. Anything that logs error responses now logs passwords. Error Handling shows how to override the 422 shape with a RequestValidationError handler, which is where you would drop input.
- A form posts
""for empty optional fields. Turn blank or whitespace-onlyphoneandcompanyintoNone, while a real phone number still has to be at least 7 characters.
Solution
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field, field_validator
class Contact(BaseModel):
name: str
phone: str | None = Field(default=None, min_length=7)
company: str | None = None
@field_validator("phone", "company", mode="before")
@classmethod
def blank_to_none(cls, value):
if isinstance(value, str) and not value.strip():
return None
return value
app = FastAPI()
@app.post("/contacts")
def create(c: Contact):
return c
client = TestClient(app)
print(client.post("/contacts", json={"name": "Ada", "phone": "", "company": " "}).json())
print(client.post("/contacts", json={"name": "Ada", "phone": "123"}).json())
$ python p3.py
{'name': 'Ada', 'phone': None, 'company': None}
{'detail': [{'type': 'string_too_short', 'loc': ['body', 'phone'], 'msg': 'String should have at least 7 characters', 'input': '123', 'ctx': {'min_length': 7}}]}
One validator can cover several fields. It has to run before, because an after validator would only run once "" had already failed min_length=7.
- Keep accepting camelCase requests in
Profile, but make one route answer in snake_case.
Solution
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class Profile(BaseModel):
model_config = ConfigDict(alias_generator=to_camel)
first_name: str
is_active: bool = True
app = FastAPI()
@app.post("/profiles", response_model=Profile, response_model_by_alias=False)
def create(p: Profile):
return p
client = TestClient(app)
print(client.post("/profiles", json={"firstName": "Ada"}).json())
$ python p4.py
{'first_name': 'Ada', 'is_active': True}
response_model_by_alias=False makes FastAPI dump the response with Python names. Use it sparingly: an API that takes firstName in and sends first_name out is confusing to call.