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.
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:
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:
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:
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 number4500, which is whyorder.total + 350works."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=0means "greater than 0". Other rules includege(greater than or equal),ltandle.
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:
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:
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.