Skip to content

Pydantic

Pydantic is a Python library that checks whether the data your program receives is correct and in the right format. It uses Python type hints to do this automatically at runtime - meaning it checks data while your program is actually running, not just when you write the code.

Think of it like a security guard at a door. Before any data enters your program, Pydantic checks it: “Is this the right type? Is this field required? Does this value make sense?” If something is wrong, it tells you exactly what went wrong and why.

Pydantic is very useful when you are working with data that comes from outside your program - like data from an API, a web form, a config file, or a database.

You can install Pydantic using uv:

Terminal window
uv add pydantic

Here is a simple example. You define a model that describes what your data should look like, and then Pydantic checks that the data you pass in actually matches that description.

from pydantic import BaseModel, ValidationError
class User(BaseModel):
id: int
name: str
is_active: bool
input_data = {
"id": 1,
"name": "Alice",
"is_active": True
}
try:
user = User(**input_data)
# user = User(id=1, name='Alice', is_active=True)
print(user)
except ValidationError as e:
print(e)

In this example, User is a Pydantic model. It says: “I expect an id that is an integer, a name that is a string, and an is_active value that is a boolean.” When you create a User with some data, Pydantic checks all three fields automatically.

Pydantic is smart enough to convert some values automatically. For example, if you say a field should be an int but pass in the string "1", Pydantic will convert it to the number 1 for you. This is called coercion.

from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
is_active: bool
input_data = {
"id": "1", # This will be converted to an integer
"name": "Alice",
"is_active": "true" # This will be converted to a boolean
}
user = User(**input_data)
print(user)
input_data_2 = {
"id": "sf", # This will raise a validation error because "sf" cannot be converted to an integer
"name": "Bob",
"is_active": "false"
}

If the value cannot be converted (like "sf" for an integer), Pydantic raises a ValidationError and tells you exactly what went wrong.

Pydantic works well with Python’s typing module. This lets you describe more complex data - like a list of strings, a dictionary, or a field that might be missing (optional).

from pydantic import BaseModel
from typing import List, Dict, Optional
class User(BaseModel):
id: int
name: str
is_active: bool
tags: List[str] # A list of strings
metadata: Dict[str, str] # A dictionary with string keys and values
nickname: Optional[str] = None # An optional field that can be None
input_data = {
"id": 1,
"name": "Alice",
"is_active": True,
"tags": ["admin", "user"],
"metadata": {"role": "admin", "department": "IT"},
"nickname": "Ally"
}
user = User(**input_data)
print(user)

The nickname field has a default value of None, which means you do not have to include it in the input data. All other fields are required.

The Field function lets you add extra rules and information to each field in your model. For example, you can set a minimum length for a string, mark a field as required, or add a description for documentation.

  • ... (ellipsis) - means the field is required. You must always provide a value for it.
  • min_length - the string must be at least this many characters long.
  • max_length - the string must be no longer than this many characters.
  • regex - the string must match this pattern.
  • default - the value to use if the field is not provided.
  • description - a human-readable description of what the field is for.
  • example - an example value, useful for documentation.
  • gt - the number must be greater than this value.
  • lt - the number must be less than this value.
  • ge - the number must be greater than or equal to this value.
  • le - the number must be less than or equal to this value.
  • etc.
from pydantic import BaseModel, Field, EmailStr
class User(BaseModel):
id: int = Field(
...,
description="The unique identifier for the user",
examples=[1, 2, 3]
)
name: str = Field(
...,
min_length=3,
max_length=50,
description="The name of the user",
examples=["Alice"]
)
is_active: bool = Field(
default=True,
description="Indicates whether the user is active",
examples=[True]
)
email: EmailStr = Field(
...,
description="The email address of the user",
examples=["alice@example.com"]
)
input_data = {
"id": 1,
"name": "Alice",
"email": "alice@example.com"
}
user = User(**input_data)
print(user)

Here, is_active is not required because it has a default=True. The other three fields use ... which means they must always be provided.

Sometimes the built-in type checks are not enough. You might want to add your own custom rule - like “the username must only contain letters and numbers.” The field_validator decorator lets you write a function that checks or changes a specific field’s value.

The validator runs automatically when you create a model. If the value is wrong, you raise a ValueError and Pydantic will stop and report the error.

from pydantic import BaseModel, field_validator
class User(BaseModel):
username: str
email: str
@field_validator('username')
def validate_username(cls, value):
if not value.isalnum():
raise ValueError(
'Username must contain only letters and numbers'
)
return value
@field_validator('email')
def normalize_email(cls, value):
# Normalize the email by converting it to lowercase
return value.lower().strip()
input_data_1 = {
"username": "user_123"
"email": "USER@EXAMPLE.COM"
# This will raise a validation error because
# the username contains an underscore (_)
}
input_data_2 = {
"username": "user123",
"email": "USER@EXAMPLE.COM"
# This will pass validation because
# the username contains only letters and numbers
# and the email is normalized to "user@example.com"
}
try:
user = User(**input_data_1)
except ValueError as e:
print(e)
try:
user = User(**input_data_2)
print(user)
except ValueError as e:
print(e)

In this example:

  • The validate_username validator checks if the username only has letters and numbers. If it has anything else (like an underscore), it raises an error.
  • The normalize_email validator does not reject the email - it just cleans it up by converting it to lowercase and removing extra spaces.

If the value is valid, you return it at the end. Pydantic uses the returned value as the final value for that field.

You can apply the same validator to more than one field by listing them in the decorator.

from pydantic import BaseModel, field_validator
class User(BaseModel):
first_name: str
last_name: str
@field_validator('first_name', 'last_name')
def validate_names(cls, value):
if not value.isalpha():
raise ValueError(
'Names must contain only letters'
)
return value
input_data = {
"first_name": "John",
"last_name": "Doe"
# Both fields will be validated by the same validator
}
try:
user = User(**input_data)
print(user)
except ValueError as e:
print(e)

In this example, the same validator runs for both first_name and last_name. It checks first_name first, then last_name. If either contains anything other than letters, a validation error is raised.

field_validator checks one field at a time. But sometimes you need to check two or more fields together - like making sure a password and a confirm password match. That is what model_validator is for. It works with the whole model.

  • before - runs before Pydantic validates any fields. You get the raw input data as a dictionary. Use it to clean or change the data before validation starts. You must return the modified data.

  • after - runs after all fields have been validated. You get the fully built model instance. Use it to compare fields or do checks that need the final values. You must return the model instance.

from pydantic import BaseModel, model_validator
class User(BaseModel):
password: str
confirm_password: str
@model_validator(mode='before')
@classmethod
def clean_passwords(cls, data):
# Remove extra spaces before validation
if "password" in data:
data["password"] = data["password"].strip()
if "confirm_password" in data:
data["confirm_password"] = data["confirm_password"].strip()
return data
@model_validator(mode='after')
def validate_passwords(self):
# Compare passwords after validation
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
input_data_1 = {
"password": " secret123 ",
"confirm_password": " secret123 "
# The before validator removes spaces.
# The after validator sees both values as "secret123".
}
input_data_2 = {
"password": "secret123",
"confirm_password": "secret456"
# The after validator raises an error
# because the passwords are different.
}
try:
user = User(**input_data_1)
print(user)
except ValueError as e:
print(e)
try:
user = User(**input_data_2)
print(user)
except ValueError as e:
print(e)

Here is what happens step by step:

  1. The before validator runs first and strips extra spaces from both password fields.
  2. Pydantic then validates the cleaned values as strings.
  3. The after validator runs and compares the two passwords. If they do not match, it raises an error.

For input_data_1, both passwords become "secret123" after the spaces are removed, so they match and the model is created. For input_data_2, the passwords are different, so an error is raised.

Sometimes you want a field that is not given in the input, but is calculated automatically from other fields. For example, if you have a width and height, you might want an area field that multiplies them together. You can do this with computed_field.

from pydantic import BaseModel, computed_field
class Rectangle(BaseModel):
width: float
height: float
@computed_field
@property
def area(self) -> float:
return self.width * self.height
input_data = {
"width": 5.0,
"height": 3.0
}
rectangle = Rectangle(**input_data)
print(rectangle)
print(f"Area of the rectangle: {rectangle.area}")

You do not pass area in the input data. Pydantic calculates it automatically from width and height when the model is created.

You can use one Pydantic model inside another. This is useful when your data has a structure inside a structure - like a user who has an address.

from pydantic import BaseModel
class Address(BaseModel):
street: str
city: str
state: str
class User(BaseModel):
name: str
addresses: Address
input_data = {
"name": "Alice",
"addresses": {
"street": "123 Main St",
"city": "Anytown",
"state": "CA"
}
}
user = User(**input_data)
print(user)

In this example, the addresses field expects an Address object. But you can just pass a plain dictionary - Pydantic is smart enough to automatically convert it into an Address model for you, including validating all the fields inside it.

Sometimes a model needs to refer to itself. For example, a comment can have replies, and each reply is also a comment. This is called a recursive or self-referencing model.

Because the class refers to itself before it is fully defined, you write the type as a string ('Comment') instead of the class directly. After the class is defined, you call model_rebuild() to tell Pydantic to resolve that reference.

from pydantic import BaseModel
from typing import Optional
class Comment(BaseModel):
id: int
text: str
replies: Optional[list['Comment']] = None
Comment.model_rebuild()
comment = Comment(
id=1,
text="This is the original comment",
replies=[
Comment(
id=2,
text="This is a reply",
replies=[
Comment(
id=3,
text="This is a nested reply"
)
]
)
]
)
print(comment)

Output:

Comment(
id=1,
text='This is the original comment',
replies=[
Comment(
id=2,
text='This is a reply',
replies=[
Comment(
id=3,
text='This is a nested reply',
replies=None
)
]
)
]
)

This pattern is useful for tree-like structures - things like comment threads, file systems, category trees, or organization charts - where each item can contain more items of the same type.

Sometimes a field can hold more than one type of data. For example, the content of an article could be plain text or an image. Union types let you say “this field can be one of these types,” and Pydantic will figure out which one matches the data you pass in.

from pydantic import BaseModel, ValidationError
# from typing import Union
class TextContent(BaseModel):
text: str
class ImageContent(BaseModel):
url: str
alt_text: str
class Article(BaseModel):
title: str
content: TextContent | ImageContent
# content: Union[TextContent, ImageContent] # older syntax using typing.Union
# Text content
article_1 = Article(
title="Getting Started with Pydantic",
content={
"text": "Pydantic makes data validation simple."
}
)
print(article_1)
print(type(article_1.content))
# Image content
article_2 = Article(
title="Pydantic Architecture Diagram",
content={
"url": "https://example.com/diagram.png",
"alt_text": "Architecture diagram"
}
)
print(article_2)
print(type(article_2.content))
# Content containing fields from both models
article_3 = Article(
title="Mixed Content Example",
content={
"text": "Pydantic supports unions.",
"url": "https://example.com/image.png",
"alt_text": "Example image"
}
)
print(article_3)
print(type(article_3.content))
# Invalid content
try:
article_4 = Article(
title="Invalid Example",
content={
"invalid_field": "This does not match any content type."
}
)
except ValidationError as e:
print(e)

In this example, the content field of Article can be either a TextContent or an ImageContent. Pydantic looks at the fields in the data you pass and automatically picks the right type. If the data does not match either type, it raises a ValidationError.

Serialization means converting your Pydantic model into a format that can be stored or sent - like JSON or a dictionary. Deserialization is the opposite - taking JSON or a dictionary and turning it back into a Pydantic model.

This is very useful when building APIs, saving data to a file, or sending data between services.

from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
is_active: bool
user = User(id=1, name="Alice", is_active=True)
# Serialize to JSON
json_data = user.model_dump_json()
print(json_data)
# Deserialize from JSON
user_from_json = User.model_validate_json(json_data)
print(user_from_json)
# Serialize to Dict
dict_data = user.model_dump()
print(dict_data)
# Deserialize from Dict
user_from_dict = User.model_validate(dict_data)
# or user_from_dict = User(**dict_data)
print(user_from_dict)
  • model_dump_json() - converts the model to a JSON string
  • model_validate_json() - takes a JSON string and creates a model from it
  • model_dump() - converts the model to a plain Python dictionary
  • model_validate() - takes a dictionary and creates a model from it

Pydantic handles all the conversion and validation automatically during these steps.

Sometimes you want to change how a Pydantic model behaves. For example, you may want dates to be displayed in a specific format when converting the model to JSON.

Pydantic lets you do this by adding model settings. In older code, these settings are placed inside a Config class. In Pydantic v2, the recommended way is to use ConfigDict.

from pydantic import BaseModel, ConfigDict
from datetime import datetime
class User(BaseModel):
id: int
name: str
logged_in_at: datetime
class Config:
json_encoders = {
datetime: lambda value: value.strftime("%Y-%m-%d %H:%M:%S")
}
class Product(BaseModel):
id: int
name: str
price: float
created_at: datetime
model_config = ConfigDict(
json_encoders={
datetime: lambda value: value.strftime("%d-%m-%Y %H:%M:%S")
}
)
user = User(
id=1,
name="Alice",
logged_in_at=datetime.now()
)
product = Product(
id=1,
name="Widget",
price=19.99,
created_at=datetime.now()
)
print(user.model_dump_json())
print(product.model_dump_json())

Both models have a datetime field. By default, Pydantic converts datetime values to JSON using its own built-in format. In this example, we tell Pydantic to use our own format instead.

For the User model, the date will look like:

"2026-06-09 18:30:45"

For the Product model, the date will look like:

"09-06-2026 18:30:45"

ConfigDict is where you put settings that control how your Pydantic model behaves. Think of it as a list of instructions you give to Pydantic.

In this example, the instruction is:

model_config = ConfigDict(
json_encoders={
datetime: lambda value: value.strftime("%d-%m-%Y %H:%M:%S")
}
)

Model settings let you control how Pydantic behaves without changing your actual data or business logic. For example, you can use them to:

  • Format dates and times the way you want
  • Control what JSON output looks like
  • Allow or reject extra fields that were not defined in the model
  • Change how validation works

This makes it easy to keep your data in exactly the format your application needs.

Pydantic is a very useful library that handles one of the most common problems in programming - making sure the data coming into your program is correct.

Here is a quick summary of everything covered:

  • BaseModel - define what your data should look like using type hints
  • Field - add extra rules like minimum length or required fields
  • field_validator - write custom checks for individual fields
  • model_validator - write checks that involve multiple fields together
  • computed_field - add fields that are calculated automatically
  • Nested models - use one model inside another for structured data
  • Self-referencing models - models that can contain themselves, useful for trees
  • Union types - fields that can accept more than one type
  • Serialization - convert models to JSON or dictionaries and back

Whether you are building APIs, reading config files, or processing data from external sources, Pydantic helps you catch problems early and keep your data clean.