Command Palette

Search for a command to run...

Hectal
PHASE 15Intermediate ~27 min· topic 4 of 10

Topic 15.4

Data Validation with Pydantic

In one line

Pydantic turns type hints into real checks: you describe the shape of your data as a class, and it converts and validates incoming dictionaries or JSON, collects clear errors, and can produce a JSON Schema. FastAPI, the AI SDKs and agent frameworks are built on it.

Think of it like this

A security guard at a building entrance with a list in hand. Each visitor says who they are and which floor they want. The guard checks the list: is the name there, is the floor a real number, is it a floor this visitor may enter? A visitor who says "floor 7" in writing is quietly accepted as floor 7; one who says "floor purple" is turned away with a note saying exactly what was wrong. Pydantic is the guard for data entering your program.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Validation
Checking that data has the right shape, types and values.
BaseModel
The Pydantic class you inherit from to describe a data shape.
Field
One named piece of data in a model, with a type, maybe a default and constraints.
Coercion
Safely converting a value to the declared type, such as the string "7" to the integer 7.
ValidationError
The exception Pydantic raises, holding every problem it found.
Serialisation (dump)
Turning a model back into a plain dictionary or JSON text.
Validator
A function you attach to a field or model to check or clean a value.
JSON Schema
A standard way to describe the shape of JSON data, as JSON.
Nested model
A model used as the type of a field in another model.

Step by step

01Describe the data as a class

Fields are class attributes with type hints. str | None = None means optional text; list[str] = [] is a list defaulting to empty (Pydantic copies the default for each instance, so the shared-mutable-default trap of ordinary functions does not occur here).

main.pywhole filepython
from pydantic import BaseModel


class User(BaseModel):
    id: int
    name: str
    email: str | None = None
    tags: list[str] = []

02Create an instance: validate and convert

Keyword arguments go through validation. The string "7" becomes 7. A value that cannot be converted raises ValidationError. Extra keys that the model does not declare are ignored by default (you can forbid them with model_config = ConfigDict(extra="forbid")).

main.pywhole filepython
u = User(id="7", name="Asha")
print(u)          # id=7 name='Asha' email=None tags=[]
print(type(u.id))  # <class 'int'>

03Read the errors

All problems arrive together. Each error has loc, type, msg and input. Print only the pieces you need; str(e) also works but its text includes a link to the Pydantic docs that contains the library version (for example https://errors.pydantic.dev/2.12/v/int_parsing), so your output differs on other versions.

main.pywhole filepython
try:
    User(id="seven", name=123, tags="python")
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["type"], err["msg"])

04Nest models and validate JSON

A model can contain other models and lists of them. The next runnable example builds a chat request like those that AI APIs accept: a list of Message objects. A wrong role deep inside is reported with the path ('messages', 0, 'role').

Nest models and validate JSONdiagram
Rendering diagram…

05Constraints with Field

Field(default=1.0, ge=0, le=2) says: a number from 0 to 2 inclusive. Field(min_length=1) rejects empty text. Literal["system", "user", "assistant"] accepts exactly those three words. All of these appear in the generated JSON Schema (minimum, maximum, enum), so a language model reading the schema knows the limits too.

06Custom validators

A @field_validator("model") runs after the type check. It receives the value and returns the cleaned value or raises ValueError, which Pydantic turns into a normal error entry (Value error, ...). The @classmethod line is required under the validator.

main.pywhole filepython
from pydantic import BaseModel, field_validator


class Request(BaseModel):
    model: str

    @field_validator("model")
    @classmethod
    def clean(cls, value: str) -> str:
        value = value.strip().lower()
        if " " in value:
            raise ValueError("model names cannot contain spaces")
        return value

07Dump and schema

model_dump() gives a dict for any model, including nested ones. model_dump(exclude_none=True) omits unset fields, mode="json" converts values such as dates to JSON-safe types. model_json_schema() returns a dict that follows JSON Schema, with properties and required.

Try it yourself

  1. 1

    Pass what was valid

    In the first example change the failing call to User(id="7", name="Asha", tags=["a", "b"]). No error: Pydantic accepted a string id and converted it. Now try id="7.5" and read the error type.

  2. 2

    Strict versus lax

    Add from pydantic import ConfigDict and model_config = ConfigDict(strict=True) inside User. Run it and see User(id="7", ...) now fail: strict mode refuses conversions.

  3. 3

    Add a rule

    In the second example add a validator to Message that rejects content longer than 20 characters, then send a 25-character message and read the error path.

Code & diagrams

BaseModel: conversion, errors and parsing New tab

Pydantic runs in your browser. The error "type" strings are stable codes; the "msg" text can change slightly between Pydantic versions.

Sign in to run this example in your browser.

Expected output

id=7 name='Asha' email=None tags=[]
id is int
{'id': 7, 'name': 'Asha', 'email': None, 'tags': []}
{"id":7,"name":"Asha","email":null,"tags":[]}
error count: 3
('id',) | int_parsing | Input should be a valid integer, unable to parse string as an integer
('name',) | string_type | Input should be a valid string
('tags',) | list_type | Input should be a valid list
id=9 name='Ben' email=None tags=[]
id=10 name='Cy' email=None tags=['a']
A chat request: nested models, constraints, validator and JSON Schema New tab

This is the shape of a request to an AI chat API: a model name, a list of messages (each with a role and some text) and settings. `temperature` is a dial for how adventurous the model's wording is (low is steady, high is varied). Try temperature=1.5 and a valid role to see it pass.

Sign in to run this example in your browser.

Expected output

model='fake-model-1' messages=[Message(role='user', content='Hi')] temperature=1.0 max_tokens=None
{"model": "fake-model-1", "messages": [{"role": "user", "content": "Hi"}], "temperature": 1.0}
('model',) -> Value error, model names cannot contain spaces
('messages', 0, 'role') -> Input should be 'system', 'user' or 'assistant'
('messages', 0, 'content') -> String should have at least 1 character
('temperature',) -> Input should be less than or equal to 2
['max_tokens', 'messages', 'model', 'temperature']
['model', 'messages']
{'default': 1.0, 'maximum': 2, 'minimum': 0, 'title': 'Temperature', 'type': 'number'}
Settings and secrets with pydantic-settings (needs pip install)python

Not runnable here: pydantic-settings is a separate package that is not in the browser.

# pip install pydantic-settings
from pydantic import SecretStr
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    llm_api_key: SecretStr
    timeout_seconds: float = 10.0


settings = Settings()                       # reads LLM_API_KEY from the environment
print(settings)                             # the key prints as '**********'
headers = {"Authorization": "Bearer " + settings.llm_api_key.get_secret_value()}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget to validate and trust a type hint

Use a plain class with type hints and pass it bad data.

main.pywhole filepython
class Order:
    quantity: int

    def __init__(self, quantity):
        self.quantity = quantity


order = Order("many")
print(order.quantity + 1)
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 9, in <module>
print(order.quantity + 1)
~~~~~~~~~~~~~~~^~~
TypeError: can only concatenate str (not "int") to str

Myth vs fact

Myth

Pydantic only checks types.

Fact

It also converts values (in lax mode), enforces constraints (ranges, lengths, patterns, allowed words), runs your own validators, and produces schemas and serialised output.

Myth

Type hints already protect my data at run time.

Fact

Python itself ignores them. Pydantic (and tools like it) is what turns the same hints into real run-time checks.

Myth

A model's output from an AI is valid JSON, so I can use it directly.

Fact

Models sometimes return text that is not JSON or does not match your schema. Always validate with model_validate_json and handle the ValidationError (Topic 16.6).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Pydantic v2 (the current line) moved the core into Rust (pydantic-core). Names changed from v1: model_dump replaces .dict(), model_validate replaces parse_obj, field_validator replaces validator. Old tutorials and some libraries still show v1 names.

  • ▸

    Choose strict or lax per use. External JSON often has numbers as strings, so lax is handy. For internal boundaries, ConfigDict(strict=True) or StrictInt catches sloppy conversions. Use extra="forbid" on request models to catch misspelt field names instead of silently ignoring them.

  • ▸

    Validation is not free, and model_validate_json is faster than json.loads followed by model_validate because it parses directly in Rust. For hot loops with huge numbers of tiny objects, plain dataclasses or TypedDict plus a single validation at the edge may be better.

  • ▸

    Validation runs when a model is created, not when you change it. user.id = "oops" succeeds silently unless you set ConfigDict(validate_assignment=True). Model.model_construct(...) skips validation entirely (it is for trusted data you already checked), and model_copy(update={...}) does not re-validate the update either. Interviewers like this one, because "it's a Pydantic model so it is valid" is only true at construction.

  • ▸

    Lax mode still has rules. For int, "7", "7.0" and 7.0 are accepted but 7.5 and "7.5" are not, and even True becomes 1, so test the exact inputs your API sees. For str, a number is not converted (name=123 fails, as the first runnable example shows). For bool, the strings "true", "yes", "1", "on" and their opposites are accepted, but "maybe" is not. The conversion table is in the docs, and per-call Model.model_validate(data, strict=True) switches it off without changing the class.

  • ▸

    @field_validator runs after Pydantic's own parsing by default (mode="after"), so value already has the declared type. Use mode="before" to accept messy input first (for example turning a comma-separated string into a list), and @model_validator(mode="after") for checks across fields (such as end > start). Raise ValueError or AssertionError inside validators; Pydantic turns them into a ValidationError entry. Do not raise ValidationError yourself. model_json_schema() lists a field under required only if it has no default, which matters when a language model must fill every field.

  • ▸

    dataclasses can be validated with pydantic.dataclasses or a TypeAdapter, which validates any type hint, such as TypeAdapter(list[int]).validate_python(["1", 2]), without writing a model.

Remember this

  1. 1

    Data from outside your program (an API reply, a form, a file, a language model's text) is untrusted: it may be missing fields, have the wrong types, or be nonsense. Python's type hints (Topic 10.1) are not enforced at run time, so a hint alone protects nothing. Pydantic reads your type hints and actually enforces them at the boundary, the place where data enters your program from outside. A schema is the written description of what valid data looks like: which fields, which types.

  2. 2

    You describe the data as a class that inherits from BaseModel (Topic 8.5: a subclass gets everything its parent class has), listing fields with type hints. str | None means "text or nothing" (Topic 10.2). Creating an instance (User(id="7", name="Asha")) validates and converts the values: the string "7" becomes the int 7 (Pydantic's default "lax" mode accepts safe conversions). A field with a default (= None, = []) is optional. Fields without one are required.

  3. 3

    When the data is wrong, Pydantic raises **ValidationError**. It does not stop at the first problem: it collects all of them. e.errors() returns a list of dictionaries, each with loc (where, as a path such as ('messages', 0, 'role')), type (a stable machine-readable code like int_parsing), msg (a human sentence) and input. This is what lets an API reply with a precise 422 error (Topic 15.5), or lets your code feed the exact error back to a language model so it can fix its own output (Topic 16.6).

  4. 4

    Models nest: a field can be another model, or a list of them, and validation goes all the way down, with the path in each error. Use model_validate(dict) for a dictionary and model_validate_json(text) for JSON text; model_dump() gives back a plain dictionary and model_dump_json() JSON text. exclude_none=True drops unset optional fields, which is useful for API request bodies.

  5. 5

    Constraints go in Field(...): ge/gt/le/lt for numbers, min_length/max_length/pattern for text and lists. Use typing.Literal["a", "b"] or an Enum for fixed choices. For rules the built-ins cannot express, write a validator with @field_validator("name") on a @classmethod (decorators are Topic 9.6, class methods Topic 8.3; the validator can clean a value or raise ValueError), or @model_validator to check fields against each other.

  6. 6

    Model.model_json_schema() produces a JSON Schema describing the model. This is why every AI framework uses Pydantic: the same class that validates data also tells a language model exactly which JSON to produce (tool definitions, structured output) and validates what comes back. FastAPI uses the schema for its automatic documentation. Settings can be read from the environment into a typed class with the separate pydantic-settings package (Topic 15.3 showed the manual version). Pydantic v2 is written largely in Rust (a fast systems language), so validation is fast.

Explain it without notes

01

What does Pydantic do that type hints alone do not?

02

What does a ValidationError contain, and why is that useful?

03

How do you add a rule that built-in constraints cannot express?

04

Why is model_json_schema() important for AI applications?

05

What is the difference between lax and strict mode, and is a Pydantic object always valid?

Practice

01

Create a model Product with name (non-empty text), price (a number above 0) and in_stock (boolean defaulting to True). Show that Product(name="Pen", price="2.5") works.

02

Show the error loc and type when price is -1.

03

Add a validator so name is stored with the first letter capitalised and surrounding spaces removed.

Trade-offs

  • ↔

    Pydantic models versus dataclasses. Dataclasses are standard library and light but do not validate. Pydantic validates and converts, but adds a dependency and some overhead. Use Pydantic where data enters from outside; use dataclasses for trusted internal values.

  • ↔

    Lax versus strict. Lax accepts "7" for an int, which is convenient for JSON and forms but can hide mistakes. Strict is safer but rejects data that is merely formatted differently.

Done when you can

  • Done when you can define a BaseModel with required, optional and defaulted fields.

  • Done when you can read e.errors() and say what loc, type and msg mean.

  • Done when you can nest models and validate JSON text with model_validate_json.

  • Done when you can add Field constraints and a field_validator.

  • Done when you can produce model_json_schema() and explain why AI tools need it.