Skip to content

Week 2 · Python for real work

Validating data with Pydantic

Install a package from PyPI and use Pydantic models to check data from outside your program, with clear errors when it's wrong.

Live session

Type hints describe what your code expects. Pydantic enforces it. You describe your data in a class called a model, and Pydantic checks every value when you create an object from it. Bad data raises a ValidationError that says what's wrong and where.

Use it wherever data enters your program from outside, because you can't trust that data to be right:

  • the body of a request to your API
  • a settings file
  • a CSV file someone uploaded
  • the response from another system or an AI model

Your model becomes the one place that decides whether the data is the right shape, instead of checks scattered through your code.

Installing a package from PyPI

Pydantic isn't part of the standard library. Python packages written by other people are published on the Python Package Index, or PyPI. You can see Pydantic's page at pypi.org/project/pydantic (opens in a new tab). When you run uv add, uv downloads the package from PyPI.

In your learn-types project, run:

uv add pydantic

There's no --dev this time, because your program needs Pydantic when it runs.

Create a file called models.py, type each example into it and run it with uv run models.py. Don't call it pydantic.py, or your file would be imported instead of the real package.

Your first model

A model is a class built on BaseModel, with a type hint for each field:

models.py
from pydantic import BaseModel, Field


class Customer(BaseModel):
    name: str = Field(min_length=2, max_length=120)
    city: str | None = Field(default=None, max_length=50)


noor = Customer(name="Noor Foods", city="Karachi")
print(noor.name)
print(noor.city)

bilal = Customer(name="Bilal Traders")
print(bilal.city)

Output:

Noor Foods
Karachi
None

Field() adds rules on top of the type. name must be a string of 2 to 120 characters. city is optional, is None by default and can't be longer than 50 characters.

When the data is wrong

Creating an object with bad data raises a ValidationError. You can catch it like any other exception:

models.py
from pydantic import BaseModel, Field, ValidationError


class Customer(BaseModel):
    name: str = Field(min_length=2, max_length=120)
    city: str | None = Field(default=None, max_length=50)


try:
    Customer(name="x")
except ValidationError as error:
    for problem in error.errors():
        print(problem["loc"][0], "->", problem["msg"])

Output:

name -> String should have at least 2 characters

You didn't write that check or that message: Pydantic did it from your model. error.errors() gives you a list with one item for each problem. Try print(error) as well to see the full report.

Checking data from outside

Real data usually arrives as a dictionary, for example after json.load(). model_validate() checks a dictionary and turns it into an object. Here's a fuller model, using a StrEnum for the status:

models.py
from enum import StrEnum

from pydantic import BaseModel, Field, ValidationError


class OrderStatus(StrEnum):
    PENDING = "pending"
    DELIVERED = "delivered"


class Order(BaseModel):
    id: str
    customer: str
    total: int = Field(gt=0)
    status: OrderStatus = OrderStatus.PENDING


good = {"id": "A101", "customer": "Noor Foods", "total": "4500"}
order = Order.model_validate(good)
print(order.total + 350)

bad = {"id": "A102", "total": "abc", "status": "lost"}
try:
    Order.model_validate(bad)
except ValidationError as error:
    for problem in error.errors():
        print(problem["loc"][0], "->", problem["msg"])

Output:

4850
customer -> Field required
total -> Input should be a valid integer, unable to parse string as an integer
status -> Input should be 'pending' or 'delivered'

Three things to notice:

  • Pydantic converts where it safely can. "4500" became the number 4500, which is why order.total + 350 works. "abc" can't be converted, so it's rejected.
  • It reports every problem at once, not just the first, so you can fix them all in one go.
  • gt=0 means "greater than 0". Other rules include ge (greater than or equal), lt and le.

Turning a model back into data

To save or send your data, turn the model back into JSON text:

print(order.model_dump_json())

Output:

{"id":"A101","customer":"Noor Foods","total":4500,"status":"pending"}

order.model_dump() gives you a Python dictionary instead.

Your own rules

The rules in Field() cover the simple cases. For anything else, write your own with the @field_validator decorator:

models.py
from pydantic import BaseModel, field_validator


class Customer(BaseModel):
    email: str

    @field_validator("email")
    @classmethod
    def clean_email(cls, value: str) -> str:
        cleaned = value.strip().lower()
        if "@" not in cleaned:
            raise ValueError("email must contain @")
        return cleaned


print(Customer(email=" AMNA@noorfoods.pk ").email)

Output:

amna@noorfoods.pk

Whatever the function returns becomes the field's value, and raising a ValueError rejects it. Pydantic requires the @classmethod line and the cls parameter, so copy them as a pattern for now.

Use built-in rules first

Don't write your own validator if Pydantic already has the rule. Removing extra spaces and checking the length is common enough to be built in:

models.py
from typing import Annotated

from pydantic import BaseModel, StringConstraints

CleanName = Annotated[
    str,
    StringConstraints(strip_whitespace=True, min_length=2, max_length=120),
]


class Customer(BaseModel):
    name: CleanName


print(Customer(name="  Noor Foods ").name)

Output:

Noor Foods

Annotated attaches the rules to the str type. Pydantic removes the spaces first and then checks the length, so a name of only spaces is rejected. Because CleanName is a name you've given to the type, any other field can reuse the same rules.