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"})