Skip to main content
FastAPI · lesson 8 of 19
FastAPI intermediate Lesson 8 of 19

FastAPI 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

fastapi python pydantic validators serialization aliases

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

  1. Make Profile from aliases.py accept both firstName and first_name in 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.

  1. A Signup has password and confirm_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.

  1. A form posts "" for empty optional fields. Turn blank or whitespace-only phone and company into None, 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.

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

Frequently Asked Questions

What is the difference between field_validator and model_validator in Pydantic?
A field_validator checks or transforms one field at a time and receives only that field's value. A model_validator sees the whole model, so it is the place for rules that involve several fields, such as check_out being after check_in. In FastAPI a field_validator error is reported at ['body', '<field>'], while a model_validator error is reported at ['body'] because it belongs to no single field.
When should I use mode='before' on a Pydantic field_validator?
Use mode='before' when you need to see the raw input before Pydantic converts it, for example to split the string 'python, web' into a list, or to turn an empty string into None. The default mode='after' runs after type conversion and constraints, so the value already has the declared type and is the right place for business rules on clean data.
How do I make FastAPI accept and return camelCase JSON with snake_case Python fields?
Set model_config = ConfigDict(alias_generator=to_camel) using pydantic.alias_generators.to_camel. Requests must then use firstName, and FastAPI returns firstName too, because it serializes response models by alias by default. Add validate_by_name=True to also accept first_name in requests, or pass response_model_by_alias=False on the route to return snake_case.
What is the difference between model_dump() and model_dump(mode='json')?
model_dump() returns Python objects as they are stored, so a date stays a datetime.date and a Decimal stays a Decimal. model_dump(mode='json') converts everything to JSON-compatible types, so dates become ISO strings and Decimals become strings. Use mode='json' when the dict is going into json.dumps, a cache or a message queue.
Why does my FastAPI OpenAPI schema show Model-Input and Model-Output?
FastAPI generates separate input and output schemas when a model looks different in each direction, for example when it has a computed_field that appears in responses but is never read from requests, or a Decimal field, which is read as a number or string but written as a number. Clients see Line-Input without the computed field and Line-Output with it, which is accurate. It's expected and doesn't indicate a bug.