Pydantic v2: Data Validation and Settings Management
Learn Pydantic v2 for data validation, serialization, and settings management in Python. Covers models, validators, computed fields, and BaseSettings.
What you'll learn
- ✓Define validated data models with Pydantic BaseModel
- ✓Write custom field and model validators
- ✓Use computed fields and model serialization
- ✓Manage application settings with BaseSettings
Prerequisites
- •Python type hints
- •Basic understanding of JSON and APIs
Pydantic v2 is a complete rewrite of Python’s most popular data validation library. The core validation engine is now written in Rust, making it 5-50x faster than v1. It validates data, converts types, generates JSON schemas, and manages application settings. If you build APIs with FastAPI, work with external data, or need runtime type checking, Pydantic is the standard tool.
Getting Started
# pip install pydantic
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
email: str
active: bool = True
# Valid data
user = User(name="Alice", age=30, email="alice@example.com")
print(user)
# name='Alice' age=30 email='alice@example.com' active=True
# Pydantic coerces compatible types
user2 = User(name="Bob", age="25", email="bob@test.com")
print(user2.age) # 25 (int, converted from str)
print(type(user2.age)) # <class 'int'>
# Invalid data raises ValidationError
try:
User(name="Charlie", age="not a number", email="bad")
except Exception as e:
print(e)
Pydantic validates at model creation time. If the data does not match the type annotations, you get a detailed ValidationError with the field name, value, and error type.
Field Configuration
Use Field to add constraints, defaults, descriptions, and metadata:
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0, description="Price in USD")
quantity: int = Field(ge=0, default=0)
sku: str = Field(pattern=r"^[A-Z]{2}-\d{4}$")
tags: list[str] = Field(default_factory=list, max_length=10)
# Valid
p = Product(name="Widget", price=9.99, sku="AB-1234")
print(p)
# Validation failures
try:
Product(name="", price=-5, quantity=-1, sku="invalid")
except Exception as e:
print(e)
# Multiple errors: name too short, price must be > 0,
# quantity must be >= 0, sku doesn't match pattern
Common Field Constraints
| Type | Constraints |
|---|---|
str | min_length, max_length, pattern |
int, float | gt, ge, lt, le, multiple_of |
list, set | min_length, max_length |
| All types | default, default_factory, alias, exclude, frozen |
Nested Models
Pydantic models can contain other models, creating validated nested structures:
from pydantic import BaseModel, Field
from datetime import datetime
class Address(BaseModel):
street: str
city: str
state: str = Field(min_length=2, max_length=2)
zip_code: str = Field(pattern=r"^\d{5}(-\d{4})?$")
class Company(BaseModel):
name: str
address: Address
founded: int = Field(ge=1800, le=2026)
class Employee(BaseModel):
name: str
email: str
company: Company
hired_at: datetime
data = {
"name": "Alice Johnson",
"email": "alice@acme.com",
"company": {
"name": "Acme Corp",
"address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip_code": "62701"
},
"founded": 1995
},
"hired_at": "2024-01-15T09:00:00"
}
employee = Employee(**data)
print(employee.company.address.city) # Springfield
Nested dictionaries are automatically parsed into the appropriate model classes. Dates and datetimes are parsed from ISO strings.
Custom Validators
Field Validators
Use @field_validator to add custom validation logic to specific fields:
from pydantic import BaseModel, field_validator
class User(BaseModel):
username: str
email: str
age: int
@field_validator("username")
@classmethod
def username_alphanumeric(cls, v: str) -> str:
if not v.isalnum():
raise ValueError("Username must be alphanumeric")
return v.lower() # normalize to lowercase
@field_validator("email")
@classmethod
def email_valid(cls, v: str) -> str:
if "@" not in v or "." not in v.split("@")[-1]:
raise ValueError("Invalid email format")
return v.lower()
@field_validator("age")
@classmethod
def age_reasonable(cls, v: int) -> int:
if v < 13:
raise ValueError("Must be at least 13 years old")
if v > 120:
raise ValueError("Age seems unrealistic")
return v
user = User(username="AliceSmith", email="ALICE@Example.COM", age=30)
print(user.username) # alicesmith (normalized)
print(user.email) # alice@example.com (normalized)
Model Validators
Use @model_validator for validation that involves multiple fields:
from pydantic import BaseModel, model_validator
class DateRange(BaseModel):
start_date: str
end_date: str
label: str = ""
@model_validator(mode="after")
def check_dates(self) -> "DateRange":
if self.start_date >= self.end_date:
raise ValueError("start_date must be before end_date")
if not self.label:
self.label = f"{self.start_date} to {self.end_date}"
return self
class PasswordForm(BaseModel):
password: str
confirm_password: str
@model_validator(mode="after")
def passwords_match(self) -> "PasswordForm":
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
# Pre-validation (before Pydantic parses the data)
class FlexibleInput(BaseModel):
values: list[int]
@model_validator(mode="before")
@classmethod
def coerce_single_to_list(cls, data: dict) -> dict:
if isinstance(data, dict) and isinstance(data.get("values"), int):
data["values"] = [data["values"]]
return data
f = FlexibleInput(values=42)
print(f.values) # [42]
Computed Fields
@computed_field creates read-only properties that appear in serialization:
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
@computed_field
@property
def perimeter(self) -> float:
return 2 * (self.width + self.height)
@computed_field
@property
def is_square(self) -> bool:
return self.width == self.height
rect = Rectangle(width=10, height=5)
print(rect.area) # 50.0
print(rect.perimeter) # 30.0
# Computed fields appear in dict/JSON output
print(rect.model_dump())
# {'width': 10.0, 'height': 5.0, 'area': 50.0, 'perimeter': 30.0, 'is_square': False}
Serialization
Pydantic provides flexible serialization to dicts and JSON:
from pydantic import BaseModel, Field
from datetime import datetime
class Event(BaseModel):
name: str
start: datetime
tags: list[str] = []
internal_id: int = Field(exclude=True) # never serialized
event = Event(
name="Launch", start="2026-07-07T10:00:00", tags=["important"], internal_id=42
)
# To dictionary
d = event.model_dump()
print(d)
# {'name': 'Launch', 'start': datetime(2026, 7, 7, 10, 0), 'tags': ['important']}
# To JSON string
j = event.model_dump_json()
print(j)
# {"name":"Launch","start":"2026-07-07T10:00:00","tags":["important"]}
# Selective serialization
print(event.model_dump(include={"name", "tags"}))
# {'name': 'Launch', 'tags': ['important']}
print(event.model_dump(exclude_defaults=True))
# {'name': 'Launch', 'start': datetime(2026, 7, 7, 10, 0), 'internal_id': 42}
Custom Serializers
from pydantic import BaseModel, field_serializer
from datetime import datetime
class LogEntry(BaseModel):
message: str
timestamp: datetime
level: str
@field_serializer("timestamp")
def serialize_timestamp(self, value: datetime) -> str:
return value.strftime("%Y-%m-%d %H:%M:%S")
@field_serializer("level")
def serialize_level(self, value: str) -> str:
return value.upper()
entry = LogEntry(message="Server started", timestamp="2026-07-07T10:30:00", level="info")
print(entry.model_dump())
# {'message': 'Server started', 'timestamp': '2026-07-07 10:30:00', 'level': 'INFO'}
Aliases
Use aliases when external data has different field names than your Python code:
from pydantic import BaseModel, Field
class APIResponse(BaseModel):
user_id: int = Field(alias="userId")
first_name: str = Field(alias="firstName")
last_name: str = Field(alias="lastName")
email_address: str = Field(alias="emailAddress")
# Parse camelCase JSON into snake_case Python
data = {
"userId": 1,
"firstName": "Alice",
"lastName": "Johnson",
"emailAddress": "alice@test.com"
}
response = APIResponse(**data)
print(response.user_id) # 1
print(response.first_name) # Alice
# Serialize back with aliases
print(response.model_dump(by_alias=True))
# {'userId': 1, 'firstName': 'Alice', ...}
Discriminated Unions
Handle polymorphic data with discriminated unions:
from pydantic import BaseModel, Field
from typing import Literal, Annotated
class TextBlock(BaseModel):
type: Literal["text"]
content: str
class ImageBlock(BaseModel):
type: Literal["image"]
url: str
alt_text: str = ""
class CodeBlock(BaseModel):
type: Literal["code"]
language: str
source: str
Block = Annotated[
TextBlock | ImageBlock | CodeBlock,
Field(discriminator="type")
]
class Document(BaseModel):
title: str
blocks: list[Block]
doc = Document(
title="My Post",
blocks=[
{"type": "text", "content": "Hello world"},
{"type": "code", "language": "python", "source": "print('hi')"},
{"type": "image", "url": "https://example.com/img.png"},
]
)
for block in doc.blocks:
print(type(block).__name__, "->", block)
# TextBlock -> type='text' content='Hello world'
# CodeBlock -> type='code' language='python' source="print('hi')"
# ImageBlock -> type='image' url='https://example.com/img.png' alt_text=''
The discriminator field tells Pydantic which model to use based on the value of type, making parsing fast and unambiguous.
BaseSettings: Configuration Management
pydantic-settings reads configuration from environment variables, .env files, and other sources:
# pip install pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class DatabaseSettings(BaseSettings):
host: str = "localhost"
port: int = 5432
name: str = "mydb"
user: str = "postgres"
password: str
model_config = SettingsConfigDict(env_prefix="DB_")
class AppSettings(BaseSettings):
debug: bool = False
secret_key: str
allowed_hosts: list[str] = ["localhost"]
database: DatabaseSettings = DatabaseSettings()
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
)
# Reads from environment variables:
# DB_HOST=db.example.com
# DB_PASSWORD=secret123
# SECRET_KEY=my-secret-key
# DEBUG=true
# ALLOWED_HOSTS=["example.com","api.example.com"]
settings = AppSettings()
print(settings.database.host) # from DB_HOST env var
print(settings.debug) # from DEBUG env var
Settings Hierarchy
BaseSettings reads values in this priority order (highest to lowest):
- Constructor arguments
- Environment variables
.envfile- Default values
from pydantic_settings import BaseSettings
class Config(BaseSettings):
api_key: str = "default-key"
# Environment has API_KEY=env-key
config = Config()
print(config.api_key) # "env-key" (from environment)
config = Config(api_key="explicit-key")
print(config.api_key) # "explicit-key" (constructor wins)
JSON Schema Generation
Pydantic models generate JSON schemas automatically, which is how FastAPI produces its API documentation:
from pydantic import BaseModel, Field
import json
class CreateUserRequest(BaseModel):
"""Request body for creating a new user."""
name: str = Field(min_length=1, max_length=50, description="User's full name")
email: str = Field(description="User's email address")
age: int = Field(ge=13, le=120, description="User's age")
role: str = Field(default="user", description="User role")
schema = CreateUserRequest.model_json_schema()
print(json.dumps(schema, indent=2))
This generates a complete JSON Schema with types, constraints, descriptions, and required fields.
Strict Mode
By default, Pydantic coerces compatible types (e.g., "25" to 25). Use strict mode to disable coercion:
from pydantic import BaseModel, ConfigDict
class StrictUser(BaseModel):
model_config = ConfigDict(strict=True)
name: str
age: int
# This works
user = StrictUser(name="Alice", age=30)
# This fails in strict mode (string "30" is not int)
try:
StrictUser(name="Alice", age="30")
except Exception as e:
print(e) # Input should be a valid integer
Wrapping Up
Pydantic v2 covers the full lifecycle of external data in Python applications. Use BaseModel to define validated schemas with type annotations and Field constraints. Add @field_validator and @model_validator for custom logic. Use computed_field for derived values and serializers for output formatting. Handle polymorphic data with discriminated unions. Manage application configuration with BaseSettings and environment variables. The Rust-powered core makes all of this fast enough for high-throughput APIs. Whether you are building a FastAPI service, processing CSV imports, or reading configuration files, Pydantic turns unstructured data into validated, typed Python objects with minimal code.
Related articles
- FastAPI FastAPI Pydantic Models: A Deep Dive
Master Pydantic models in FastAPI: type coercion, validators, nested models, settings, and tips for clean request and response schemas.
- Python Python Dataclasses vs Pydantic: When to Use Which
Compare Python dataclasses and Pydantic models side by side. Learn their strengths, performance traits, and how to pick the right tool for your project.
- Backend FastAPI vs Django vs Flask: Python Web Frameworks Compared
Compare FastAPI, Django, and Flask for Python web development. Understand performance, features, and ecosystem differences to choose the right framework.
- FastAPI Background Tasks and Celery Integration in FastAPI
Learn to run background tasks in FastAPI using built-in BackgroundTasks and scale with Celery for distributed job processing.