← Back to FastAPI
Lesson 2.1 · Pydantic & Validation

Pydantic Models in Depth

Use Pydantic on its own, outside FastAPI: field types (dates, enums, literals, lists), required vs optional, turning dicts and JSON into models and back, and the error object. Then two surprises: changing a field after creation is not checked - and the two settings that fix it.

Beginner35 min

What you will be able to do

  • Use the common field types: int, float, str, bool, date, datetime, list, Enum, Literal
  • Make fields required, optional with a default, or allowed to be None
  • Create models from dicts and JSON, and turn them back into dicts and JSON
  • Read a ValidationError
  • Know which conversions happen on their own
  • Protect objects from bad changes with validate_assignment or frozen

The idea, in plain English

In Module 1 FastAPI used Pydantic for us: we wrote a model, and FastAPI checked the request body. Pydantic is also a normal Python library you can use anywhere - to check data from a file, a database, another API or a config.

A Pydantic model is a class with typed fields. When you create an object from data, Pydantic checks every field, converts values where the meaning is clear, and either gives you a clean object or raises one ValidationError that lists every problem. Going the other way, it turns the object back into a dict or JSON.

This lesson uses Pydantic directly, without FastAPI, so you see exactly what it does. Everything you learn here works the same inside FastAPI - request bodies, responses and settings are all Pydantic models. Pydantic 2.14.0.

Worked example: A Product model with ints, floats, bools, dates, an Enum, a Literal and a list - built from a dict and from JSON, dumped back, broken on purpose, and changed after creation.

workflowIn, check, outstep 1 / 3

1 - Text becomes real types

From {"id": "7", "price": "19.99", "in_stock": "yes", "released": "2026-10-01"} Pydantic made an int, a float, a bool and a date.

"7"
7 (int)
"yes"
True (bool)
"2026-10-01"
date(2026, 10, 1)
"2026-10-11T09:30:00"
datetime

Data goes into a model, gets checked and converted, and comes back out. Real results with the Product model.

Words you will see in this module

Module 2 is all about Pydantic. These words come up in every lesson.

Small dictionary
ModelA class that inherits from BaseModel; its typed attributes are fields.
ValidationChecking data against the model - and converting it where the meaning is clear.
SerializationTurning an object back into a dict or JSON (model_dump, model_dump_json).
ValidationErrorThe exception Pydantic raises; it lists every problem found.
EnumA fixed set of named values (Lesson 1.4).
LiteralA type that allows only the exact values you list: Literal["red", "blue"].
Lax modePydantic’s default: convert "7" to 7 when the meaning is clear.

An everyday example: customs at the airport

At customs, every bag passes the same check before it enters the country. Allowed items go through; some are repacked into the right box (that is conversion); forbidden items are listed on one form, all at once. Once a bag is through, nobody checks it again - if you add something later, customs does not know.

Pydantic is the customs desk. model_validate is the check at the border. And the last part is the surprise of this lesson: after an object is created, changes to it are not checked - unless you ask for it.

Example 1 - a model with many field types

Product uses the types you will need most. id, name and price have no default, so they are required. in_stock, tags, status have defaults. colour, released and created_at can be None and default to None - optional.

Literal["red", "blue", "green"] allows only those exact strings - like a small Enum without a class. Status is an Enum (Lesson 1.4). date and datetime accept ISO text like "2026-10-01" and "2026-10-11T09:30:00".

Example 1 - models.py: the model
from datetime import date, datetime from enum import Enum from typing import Literal from pydantic import BaseModel, ValidationError class Status(str, Enum): draft = "draft" published = "published" class Product(BaseModel): id: int name: str price: float in_stock: bool = True # default -> optional tags: list[str] = [] # a list, empty by default colour: Literal["red", "blue", "green"] | None = None status: Status = Status.draft released: date | None = None created_at: datetime | None = None
Required or optional?
name: strRequired - must be given.
in_stock: bool = TrueOptional - True if not given.
colour: ... | None = NoneOptional - None if not given; None may also be sent.
summary: str | NoneCareful: REQUIRED, but may be None. No default means required.

In: from a dict or from JSON

Product.model_validate(a_dict) checks a dict and returns an object; Product(**a_dict) does the same. Product.model_validate_json(text) does it straight from JSON text - faster than json.loads plus model_validate, because Pydantic parses and checks in one step.

Look at the types after the first call: every value arrived as text, and each became the right type. "7" -> int 7, "19.99" -> float, "yes" -> True, "2026-10-01" -> a date object, the ISO time -> a datetime. In the second call, the text "published" became the Enum member Status.published, which still compares equal to "published".

Example 1 - models.py: in
# 1. From a dict: model_validate (or Product(**data)) p = Product.model_validate({"id": "7", "name": "Lamp", "price": "19.99", "in_stock": "yes", "tags": ["home"], "released": "2026-10-01", "created_at": "2026-10-11T09:30:00"}) print("1.", repr(p)) print(" types:", type(p.id).__name__, type(p.price).__name__, type(p.in_stock).__name__, type(p.released).__name__, type(p.created_at).__name__) # 2. From JSON text: model_validate_json p2 = Product.model_validate_json('{"id": 8, "name": "Chair", "price": 45, "colour": "blue", "status": "published"}') print("2.", p2.name, p2.price, p2.colour, p2.status, p2.status == "published")
Output
1. Product(id=7, name='Lamp', price=19.99, in_stock=True, tags=['home'], colour=None, status=<Status.draft: 'draft'>, released=datetime.date(2026, 10, 1), created_at=datetime.datetime(2026, 10, 11, 9, 30)) types: int float bool date datetime 2. Chair 45.0 blue Status.published True

Out: model_dump and model_dump_json

model_dump() turns the object back into a dict; model_dump_json() into JSON text, ready to send or save. Both take options. include= keeps only some fields. exclude_none=True leaves out fields that are None - released and created_at were None, so they are missing from the JSON, while colour (blue) stayed. exclude_unset=True keeps only the fields that were really given - you used it for PATCH in Lesson 1.3.

Notice the Enum: in the dict it is still the Enum member, in JSON it became the plain string "published".

Example 1 - models.py: out
# 3. Back out: model_dump and model_dump_json print("3. dict:", p2.model_dump(include={"id", "name", "price"})) print(" json:", p2.model_dump_json(exclude_none=True)) print(" only set:", p2.model_dump(exclude_unset=True))
Output
3. dict: {'id': 8, 'name': 'Chair', 'price': 45.0} json: {"id":8,"name":"Chair","price":45.0,"in_stock":true,"tags":[],"colour":"blue","status":"published"} only set: {'id': 8, 'name': 'Chair', 'price': 45.0, 'colour': 'blue', 'status': <Status.published: 'published'>}

A list default is safe here

In normal Python, a list as a default value is a famous trap: all objects share the same list. Pydantic copies defaults, so each object gets its own. We added "sale" to a.tags; b.tags stayed empty.

Example 1 - models.py: defaults
# 4. Defaults are not shared between objects a, b = Product(id=1, name="A", price=1), Product(id=2, name="B", price=2) a.tags.append("sale") print("4. a.tags:", a.tags, "| b.tags:", b.tags)
Output
4. a.tags: ['sale'] | b.tags: []

Reading a ValidationError

When data is wrong, Pydantic raises ValidationError. It does not stop at the first problem: our bad dict had four, and all four are listed. e.error_count() gives the number; e.errors() gives a list of dicts with loc (which field), type (what kind of problem) and msg (a sentence). FastAPI turns exactly this list into the 422 "detail" you saw in Module 1.

Notice what is NOT an error: "price": -1. A negative price makes no sense, but the model only says price is a float - and -1 is a float. Rules like "more than 0" come in Lesson 2.2.

Example 1 - models.py: errors
# 5. Errors: a ValidationError lists every problem try: Product.model_validate({"id": "x", "price": -1, "colour": "pink", "released": "11/10/2026"}) except ValidationError as e: print("5.", e.error_count(), "errors") for err in e.errors(): print(" ", err["loc"], err["type"], "-", err["msg"])
Output
5. 4 errors ('id',) int_parsing - Input should be a valid integer, unable to parse string as an integer ('name',) missing - Field required ('colour',) literal_error - Input should be 'red', 'blue' or 'green' ('released',) date_from_datetime_parsing - Input should be a valid date or datetime, invalid character in year

Tip: "11/10/2026" was refused: is it 11 October or 10 November? Pydantic only accepts the clear ISO format YYYY-MM-DD for dates. Ask clients to send ISO dates.

The surprise: changes are not checked

Pydantic checks data when the object is created. After that, by default, it does not watch. We set p.price = "free" - no error, and the "price" of our product is now the text "free". model_copy(update={"price": "cheap"}) also made a copy with a bad price, without any check.

Two settings fix this. validate_assignment=True checks every change: "free" was refused with float_parsing, and "25" was converted to 25.0. frozen=True forbids changes completely: "Instance is frozen". A frozen object can also be used as a dict key or in a set - and two frozen objects with the same values count as the same.

Example 1 - models.py: changes
# 6. Changing a model: assignment is NOT checked by default p.price = "free" print("6. after p.price = 'free':", repr(p.price)) # 7. model_copy(update=...) is not checked either p3 = p2.model_copy(update={"price": "cheap"}) print("7. copy with price='cheap':", repr(p3.price))
Output
6. after p.price = 'free': 'free' 7. copy with price='cheap': 'cheap'
Example 2 - config.py: the two fixes
from pydantic import BaseModel, ConfigDict, ValidationError class Checked(BaseModel): model_config = ConfigDict(validate_assignment=True) # check every change too name: str price: float class Frozen(BaseModel): model_config = ConfigDict(frozen=True) # no changes at all after creation name: str price: float c = Checked(name="Lamp", price=19.99) try: c.price = "free" except ValidationError as e: print("validate_assignment:", e.errors()[0]["type"], "-", e.errors()[0]["msg"]) c.price = "25" print("validate_assignment, '25':", repr(c.price)) f = Frozen(name="Lamp", price=19.99) try: f.price = 25 except ValidationError as e: print("frozen:", e.errors()[0]["type"], "-", e.errors()[0]["msg"]) print("frozen can be a dict key / in a set:", {f: "ok"}[f], len({f, Frozen(name="Lamp", price=19.99)}))
Output
validate_assignment: float_parsing - Input should be a valid number, unable to parse string as a number validate_assignment, '25': 25.0 frozen: frozen_instance - Instance is frozen frozen can be a dict key / in a set: ok 1

Pydantic models at a glance

Define

Typed fields.

class Product(BaseModel):
    id: int
    price: float = 0.0
Fixed values

Without an Enum class.

colour: Literal["red", "blue"] | None = None
From a dict

Checks and converts.

Product.model_validate(data)
From JSON

Parse + check in one step.

Product.model_validate_json(text)
To a dict

With options.

p.model_dump(include={...}, exclude_none=True, exclude_unset=True)
To JSON

Enums become strings.

p.model_dump_json()
Errors

Every problem.

except ValidationError as e: e.errors()
Check changes

Or forbid them.

model_config = ConfigDict(validate_assignment=True)  # or frozen=True

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Required or not

“Add summary: str | None (no default). Create a Product without it. Is it required?”

Dates

“Try released="2026-13-01" and released="2026-10-01T10:00:00". Which works, and what does each become?”

Round trip

“Dump p2 with model_dump_json(), then load it back with model_validate_json(). Are they equal (==)?”

Freeze it

“Make Product frozen. What happens to a.tags.append("sale")? (A frozen model stops assignment - does it stop changing a list inside?)”

What usually goes wrong

Thinking X | None means optional

X | None only allows None. Without a default, the field is still required.

✗ summary: str | None          # required!
✓ summary: str | None = None   # optional
Changing fields and trusting them

p.price = "free" was accepted. Turn on validate_assignment, or use frozen models.

✗ class Product(BaseModel): ...
✓ class Product(BaseModel):
    model_config = ConfigDict(validate_assignment=True)
Non-ISO dates

"11/10/2026" was refused. Send dates as YYYY-MM-DD.

json.loads, then model_validate

model_validate_json does both in one faster step.

✗ Product.model_validate(json.loads(text))
✓ Product.model_validate_json(text)

Key points

  • A Pydantic model is a class with typed fields; Pydantic checks and converts data into it.
  • No default = required; a default = optional; X | None only allows None (still required without a default).
  • model_validate / model_validate_json go in; model_dump / model_dump_json come out.
  • A ValidationError lists every problem with loc, type and msg - FastAPI sends it as the 422 detail.
  • Conversion is lax: "7" -> 7, "yes" -> True, ISO text -> date and datetime.
  • Changes after creation are not checked - use validate_assignment=True or frozen=True.

Quick check before you move on

How do you create a model from JSON text in one step?
Model.model_validate_json(text).
What did "in_stock": "yes" become?
True.
How many problems did one ValidationError report for our bad dict?
Four - all of them at once.
Was p.price = "free" refused?
No - changes are not checked by default.

Interview questions

What is Pydantic and why does FastAPI use it?

A data validation library using type hints: it validates and coerces input into typed models, reports structured errors, and serializes back to dicts and JSON. FastAPI uses it for request parsing, response serialization and OpenAPI schemas.

What is the difference between model_validate and the constructor?

Both validate. model_validate takes a dict (or object) explicitly and is clearer when the data comes from outside; model_validate_json parses JSON and validates in one step.

Does Pydantic validate attribute assignment?

Not by default. Enable validate_assignment=True in model_config to validate on assignment, or use frozen=True to make instances immutable and hashable.

Quiz

  1. 1.

    Is summary: str | None (no default) required?

  2. 2.

    Why was price -1 accepted?

  3. 3.

    What does exclude_unset=True keep?

  4. 4.

    What is the difference between validate_assignment and frozen?

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...