In standard Python, a class is just a box. If you want it to validate data, print nicely, or convert to JSON, you have to write all that logic yourself. Or you can inherit from Pydantic’s BaseModel, which has most of that logic built in by default.

from pydantic import BaseModel, Field

class User(BaseModel):
    uid: int = Field(gt=0) 
    username: str = Field(min_length=3, max_length=20)
    email: str
    age: int

Wait, why didn’t I use Annotated?

You might remember Annotated from the previous post. In classes, uid: int = Field(gt=0) is the standard, cleaner way to set rules and default values simultaneously. Use Annotated primarily when you want to define a “rule” once and reuse it across many different functions or classes.

Data Coercion

Standard Python is strict: if you want an int but get a "25" (string), it crashes. Pydantic is helpful. It sees the string "25", realizes it can be an integer, and converts it for you automatically.

Built-in “Pretty Printing” (__repr__)

If you print a normal Python object, you get something ugly like <__main__.User object at 0x102...>.

A Pydantic model automatically knows how to display itself cleanly:

User(uid=1, username='Daniel', email='[email protected]', age=18)

default_factory

In one of my previous posts, I explained why you shouldn’t use something like tags: list = [] as a default value.

Simply put, in Python, this empty list will be created once when the code is run. Every new user will use the same list. And often, we don’t want that.

That’s why we can use default_factory from Pydantic. It tells: “Every time a new object is created, run this function to get a new value.”

from datetime import datetime, UTC
from pydantic import BaseModel, Field

class BlogPost(BaseModel):
    # Get a fresh list for every post
    tags: list[str] = Field(default_factory=list)
    
    # Get the exact current time for every post
    created_at: datetime = Field(default_factory=lambda: datetime.now(tz=UTC))

Custom Validators

Sometimes built-in rules (like min_length) aren’t enough. You might need to check if two fields match or if a string follows a specific complex rule.

Field Validator

Use @field_validator to clean or check a specific field.

from pydantic import BaseModel, field_validator

class User(BaseModel):
    username: str

    @field_validator("username")
    @classmethod
    def no_spaces(cls, v: str):
        if " " in v:
            raise ValueError("No spaces allowed in username")
        return v.lower() # We can also transform the data!

Model Validator

Use @model_validator(mode="after") when you need to compare two fields (like a password and its confirmation).

from pydantic import BaseModel, model_validator

class Signup(BaseModel):
    password: str
    confirm_password: str

    @model_validator(mode="after")
    def passwords_match(self):
        if self.password != self.confirm_password:
            raise ValueError("Passwords do not match")
        return self

Computed Fields

A @computed_field is like a “calculated column” in a spreadsheet. It’s a value that is derived from other data but is still included when you export your model to JSON.

from pydantic import BaseModel, computed_field

class User(BaseModel):
    first_name: str
    last_name: str

    @computed_field
    @property
    def full_name(self) -> str:
        return f"{self.first_name} {self.last_name}"

Nested Models

Pydantic handles complexity by letting you put models inside other models. If you have a BlogPost that has an Author, Pydantic will validate the author data automatically.

class Author(BaseModel):
    name: str
    email: str

class BlogPost(BaseModel):
    title: str
    author: Author # Nested model!

# Pydantic will validate the 'author' dict inside this dict
raw_data = {
    "title": "Pydantic is Cool",
    "author": {"name": "Daniel", "email": "[email protected]"}
}
post = BlogPost(**raw_data)

ConfigDict

Web Apps (JavaScript) usually use camelCase (e.g., userId), while Python uses snake_case (user_id). ConfigDict allows your model to speak both languages using Aliases.

from pydantic import BaseModel, ConfigDict, Field

class User(BaseModel):
    # Look for 'id' in the input data, but call it 'uid' in Python
    uid: int = Field(alias="id")
    
    model_config = ConfigDict(
        populate_by_name=True, # Allow using either 'id' or 'uid'
        frozen=True,           # Make the model immutable (read-only)
        extra="forbid"         # Throw error if extra unknown fields are sent
    )

Exporting Data (Serialization)

Once your data is validated, you often need to send it back to a database or a browser.

  • model_dump(): Converts the model to a Python Dictionary.
  • model_dump_json(): Converts the model to a JSON String.

You can hide sensitive data (like passwords) during export using exclude.

user_json = user.model_dump_json(exclude={"password"})