Data Validation with Pydantic
PyPI and Importing Packages
PyPI.org
PyPI aka the Python Package Index is the official repository where Python libraries are published.
Let's look at Pydantic in PyPI.
What Pydantic Is
Type hints describe what a function expects. Pydantic enforces it.
You define a model class with typed fields, and Pydantic checks every value against those types when an instance is created. Bad data raises a ValidationError with a clear explanation of what's wrong and where.
We want that when data crosses a trust boundary:
- an API request body
- a config file
- a row pulled from a queue
- a CSV someone uploaded.
The model becomes the single place where "is this data shaped correctly?" gets answered.
Install
uv add pydantic
A First Model
A Pydantic model looks like a class with typed attributes:
from pydantic import BaseModel, Field
class ProjectCreate(BaseModel):
name: str = Field(min_length=2, max_length=120)
description: str | None = Field(default=None, max_length=1000)
Field(...) adds constraints on top of the type. name has to be a string between 2 and 120 characters. description is optional, defaults to None, and caps at 1000 characters when provided.
Creating a valid instance works like a normal class:
>>> project = ProjectCreate(name="release-tracker", description="Track releases.")
>>> project
ProjectCreate(name='release-tracker', description='Track releases.')
Trying to create an invalid one raises ValidationError:
>>> ProjectCreate(name="x")
Traceback (most recent call last):
...
pydantic_core._pydantic_core.ValidationError: 1 validation error for ProjectCreate
name
String should have at least 2 characters [type=string_too_short, ...]
The error names the field, the rule that was broken, and the value that broke it.
It's why Pydantic is useful at the API boundary: the moment a payload doesn't match, you know exactly which field is wrong without writing any of the checking yourself.
Custom Validation with field_validator
Constraints like min_length cover the easy cases. For anything that needs custom logic (cross-field checks, normalization, calls into the database), reach for field_validator.
Say we want a project name to never be just whitespace. min_length=2 doesn't catch a name like " " because the string itself has 2 characters. We can write a validator:
from pydantic import BaseModel, Field, field_validator
class ProjectCreate(BaseModel):
name: str = Field(min_length=2, max_length=120)
description: str | None = Field(default=None, max_length=1000)
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
name = value.strip()
if not name:
raise ValueError("Project name cannot be blank.")
return name
This works. But the logic is small enough that custom code is overkill.
Reach for StringConstraints First
Pydantic ships built-in constraints for strings that cover stripping, length, patterns, and case. The same whitespace-stripping behavior, with StringConstraints:
from typing import Annotated
from pydantic import BaseModel, Field, StringConstraints
StrippedName = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=2, max_length=120),
]
class ProjectCreate(BaseModel):
name: StrippedName
description: str | None = Field(default=None, max_length=1000)
Annotated[str, StringConstraints(...)] attaches the constraint metadata to the string type itself.
Pydantic strips the value first, then applies min_length=2 to the stripped result.
A name of " " becomes "" after stripping, fails the length check, and raises with a clear message.
Pulling the constraint out into a StrippedName alias makes it reusable: any other field that wants the same rules just uses StrippedName.