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.
Installation
Section titled “Installation”You can install Pydantic using uv:
uv add pydanticBasic Usage
Section titled “Basic Usage”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.
Default Conversion
Section titled “Default Conversion”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 with typing
Section titled “Pydantic with typing”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 BaseModelfrom 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.
Field Function
Section titled “Field Function”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.
field_validator Decorator
Section titled “field_validator Decorator”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.
On Single Field
Section titled “On Single Field”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_usernamevalidator checks if the username only has letters and numbers. If it has anything else (like an underscore), it raises an error. - The
normalize_emailvalidator 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.
On Multiple Fields
Section titled “On Multiple Fields”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.
model_validator Decorator
Section titled “model_validator Decorator”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:
- The
beforevalidator runs first and strips extra spaces from both password fields. - Pydantic then validates the cleaned values as strings.
- The
aftervalidator 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.
computed_field Decorator
Section titled “computed_field Decorator”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.
Nested Models
Section titled “Nested Models”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.
Self-Referencing Models
Section titled “Self-Referencing Models”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 BaseModelfrom 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.
Union Types
Section titled “Union Types”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 contentarticle_1 = Article( title="Getting Started with Pydantic", content={ "text": "Pydantic makes data validation simple." })
print(article_1)print(type(article_1.content))
# Image contentarticle_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 modelsarticle_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 contenttry: 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 and Deserialization
Section titled “Serialization and Deserialization”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 JSONjson_data = user.model_dump_json()print(json_data)
# Deserialize from JSONuser_from_json = User.model_validate_json(json_data)print(user_from_json)
# Serialize to Dictdict_data = user.model_dump()print(dict_data)
# Deserialize from Dictuser_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 stringmodel_validate_json()- takes a JSON string and creates a model from itmodel_dump()- converts the model to a plain Python dictionarymodel_validate()- takes a dictionary and creates a model from it
Pydantic handles all the conversion and validation automatically during these steps.
ConfigDict
Section titled “ConfigDict”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, ConfigDictfrom 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())How It Works
Section titled “How It Works”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"What Is ConfigDict?
Section titled “What Is ConfigDict?”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") })Why Use It?
Section titled “Why Use It?”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.
Conclusion
Section titled “Conclusion”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 hintsField- add extra rules like minimum length or required fieldsfield_validator- write custom checks for individual fieldsmodel_validator- write checks that involve multiple fields togethercomputed_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.