Skip to content

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

shell
uv add pydantic

A First Model

A Pydantic model looks like a class with typed attributes:

Python
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:

Python REPL
>>> project = ProjectCreate(name="release-tracker", description="Track releases.")
>>> project
ProjectCreate(name='release-tracker', description='Track releases.')

Trying to create an invalid one raises ValidationError:

Python REPL
>>> 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:

Python
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:

Python
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.