Pydantic
Data validation using Python type annotations.
Overview
Pydantic is a Python library that provides data validation and settings management using Python type annotations. It validates data at runtime and generates JSON Schema for automatic documentation.
Key Features:
- Data validation using type hints
- Automatic data coercion
- JSON Schema generation
- Serialization/deserialization
- Settings management with environment variables
- Fast performance (especially v2)
- Extensive customization options
Installation
# Basic installation
pip install pydantic
# With email validation
pip install pydantic[email]
# With best performance (recommended)
pip install pydantic[email] orjson
# Version 2 (current)
pip install pydantic>=2.0.0
Basic Models
Creating Models
from pydantic import BaseModel, Field
from typing import Optional, List
class User(BaseModel):
"""Basic user model."""
id: int
name: str
email: str
age: Optional[int] = None
is_active: bool = True
# Create instance
user = User(id=1, name="John", email="john@example.com")
print(user)
# id=1 name='John' email='john@example.com' age=None is_active=True
# From dictionary
data = {
"id": 2,
"name": "Jane",
"email": "jane@example.com",
"age": 25
}
user = User(**data)
# From JSON
json_data = '{"id": 3, "name": "Bob", "email": "bob@example.com"}'
user = User.model_validate_json(json_data)
Field Types
from pydantic import BaseModel
from typing import Optional, List, Dict, Set, Tuple, Union, Any
class AllTypesExample(BaseModel):
# Primitives
string: str
integer: int
floating: float
boolean: bool
bytes: bytes
# Optional
optional_string: Optional[str] = None
optional_with_default: Optional[int] = 42
# Collections
list_of_strings: List[str] = []
list_of_ints: List[int]
dict_data: Dict[str, int] = {}
set_of_ints: Set[int] = set()
tuple_data: Tuple[int, str, float] = (1, "a", 1.5)
# Union
union_field: Union[int, str] = 1
# Any
any_field: Any = "anything"
# Literal
from typing import Literal
status: Literal["pending", "active", "completed"] = "pending"
Nested Models
from pydantic import BaseModel, Field
from typing import List, Optional
class ChildModel(BaseModel):
"""Nested child model with alias for input."""
child_id: int
child_name: str
class ParentModel(BaseModel):
"""Parent containing child model with Field alias."""
parent_id: int
parent_name: str
child_data: ChildModel = Field(alias='child_data') # Field alias for input
def __init__(self, parent_id: int, parent_name: str, child_info: ChildModel = None, **data):
super().__init__(
parent_id=parent_id,
parent_name=parent_name,
child_data=child_info if child_info else data.get('child_data')
)
class Person(BaseModel):
name: str
email: str
address: Optional[Address] = None
company: Optional[Company] = None
# Create nested data
person = Person(
name="John",
email="john@example.com",
address=Address(
street="123 Main St",
city="New York",
country="USA"
),
company=Company(
name="Tech Corp",
address=Address(
street="456 Business Ave",
city="San Francisco",
country="USA"
),
employees=["Alice", "Bob"]
)
)
Field Validation
Field Constraints
from pydantic import BaseModel, Field, field_validator
from typing import Optional
import re
class ConstrainedUser(BaseModel):
# String constraints
name: str = Field(min_length=1, max_length=100)
username: str = Field(pattern=r'^[a-zA-Z0-9_]+$')
email: str = Field(format="email")
# Number constraints
age: int = Field(ge=0, le=150) # greater or equal, less or equal
price: float = Field(gt=0) # greater than
quantity: int = Field(ge=0, default=0)
# Collection constraints
tags: List[str] = Field(min_length=1, max_length=10)
scores: List[int] = Field(min_length=1, max_length=100)
# Optional with constraint
nickname: Optional[str] = Field(default=None, max_length=50)
# Validation examples
user = ConstrainedUser(
name="John Doe",
username="john_doe",
email="john@example.com",
age=25,
price=19.99,
tags=["python", "fastapi"]
)
Field Validators
from pydantic import BaseModel, field_validator, Field
import re
class ValidatedUser(BaseModel):
username: str
password: str
age: int
@field_validator('username')
@classmethod
def validate_username(cls, v: str) -> str:
if len(v) < 3:
raise ValueError('Username must be at least 3 characters')
if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('Username can only contain letters, numbers, and underscores')
return v
@field_validator('password')
@classmethod
def validate_password(cls, v: str) -> str:
if len(v) < 8:
raise ValueError('Password must be at least 8 characters')
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'[0-9]', v):
raise ValueError('Password must contain at least one digit')
return v
@field_validator('age')
@classmethod
def validate_age(cls, v: int) -> int:
if v < 0 or v > 150:
raise ValueError('Age must be between 0 and 150')
return v
# Multiple validators on same field
class MultiValidatedField(BaseModel):
value: str
@field_validator('value')
@classmethod
def strip_value(cls, v: str) -> str:
return v.strip() # First: strip whitespace
@field_validator('value')
@classmethod
def validate_not_empty(cls, v: str) -> str:
if not v:
raise ValueError('Value cannot be empty')
return v # Then: check not empty
Model Validators
from pydantic import BaseModel, model_validator, field_validator
from typing import Optional
class UserRegistration(BaseModel):
password: str
confirm_password: str
username: str
@model_validator(mode='before')
@classmethod
def check_passwords_match(cls, data):
"""Validate before any field validation."""
if isinstance(data, dict):
if data.get('password') != data.get('confirm_password'):
raise ValueError('Passwords do not match')
return data
@model_validator(mode='after')
def validate_username_not_password(self):
"""Validate after all field validation."""
if self.username.lower() in self.password.lower():
raise ValueError('Username cannot be part of the password')
return self
# Root validator (alias for model_validator in v1)
class AdvancedValidation(BaseModel):
start_date: str
end_date: str
@model_validator(mode='after')
def validate_dates(self):
from datetime import datetime
start = datetime.fromisoformat(self.start_date)
end = datetime.fromisoformat(self.end_date)
if end < start:
raise ValueError('End date must be after start date')
return self
Serialization
Model Dump
from pydantic import BaseModel
from typing import List, Optional
class User(BaseModel):
id: int
name: str
email: str
tags: List[str] = []
metadata: Optional[dict] = None
user = User(
id=1,
name="John",
email="john@example.com",
tags=["admin", "developer"],
metadata={"department": "Engineering"}
)
# To dictionary
data = user.model_dump()
print(data)
# {'id': 1, 'name': 'John', 'email': 'john@example.com', 'tags': ['admin', 'developer'], 'metadata': {'department': 'Engineering'}}
# To JSON string
json_str = user.model_dump_json()
print(json_str)
# {"id":1,"name":"John",...}
# Include/Exclude fields
data = user.model_dump(include={'id', 'name'}) # Only id and name
data = user.model_dump(exclude={'metadata'}) # Everything except metadata
# Serialization Tips for clean API responses
# Exclude None values
data = user.model_dump(exclude_none=True)
# Exclude nested None values recursively
from pydantic import TypeAdapter
ta = TypeAdapter(List[Optional[str]])
clean_data = ta.dump_json(["a", None, "b"]) # Clean API responses
Advanced Serialization
from pydantic import BaseModel, Field, SerializerFunctionWrap
from typing import List, Optional
from datetime import datetime
class User(BaseModel):
id: int
name: str
created_at: datetime
# Custom serialization
@property
def display_name(self) -> str:
return f"#{self.id} - {self.name}"
# Custom field serializer
@field_serializer('created_at')
def serialize_datetime(self, dt: datetime) -> str:
return dt.isoformat()
user = User(id=1, name="John", created_at=datetime.now())
# Serialization options
data = user.model_dump(
mode='json', # Convert to JSON-serializable types
include={'id', 'name'},
exclude={'created_at'},
by_alias=True, # Use field aliases
exclude_none=True, # Exclude None values
exclude_unset=True, # Exclude unset fields
exclude_defaults=True # Exclude default values
)
# model_rebuild for dynamic schema generation
def dynamic_schema_gen():
# Generate schema at runtime
schema = {
"type": "object",
"properties": {"id": {"type": "integer"}, "name": {"type": "string"}}
}
User.model_rebuild() # Rebuild model with new schema
return User
Model Validate
from pydantic import BaseModel
from typing import Optional
class User(BaseModel):
id: int
name: str
email: str
# From dictionary
data = {"id": 1, "name": "John", "email": "john@example.com"}
user = User.model_validate(data)
# From JSON string
json_str = '{"id": 2, "name": "Jane", "email": "jane@example.com"}'
user = User.model_validate_json(json_str)
# From object
class SomeClass:
def __init__(self):
self.id = 3
self.name = "Bob"
self.email = "bob@example.com"
obj = SomeClass()
user = User.model_validate(obj)
# Partial update
data = {"name": "Updated Name"}
user = User.model_validate(data, update={"id": 1, "email": "old@example.com"})
JSON Schema
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
email: str
# Generate JSON Schema
schema = User.model_json_schema()
print(schema)
# With configuration
class ConfiguredUser(BaseModel):
model_config = {'title': 'User Model', 'description': 'A user model'}
id: int
name: str
schema = ConfiguredUser.model_json_schema()
Alias and Naming
Field Aliases
from pydantic import BaseModel, Field, AliasChoices
class UserWithAlias(BaseModel):
# Use alias for input, different name for code
user_id: int = Field(alias='userId')
first_name: str = Field(alias='firstName')
last_name: str = Field(alias='lastName')
email_address: str = Field(validation_alias='email') # AliasChoices for multiple
# Populate using alias
data = {"userId": 1, "firstName": "John", "lastName": "Doe", "email": "john@example.com"}
user = UserWithAlias.model_validate(data)
# Access by Python name
print(user.user_id)
print(user.first_name)
# Serialize with alias
json_data = user.model_dump(by_alias=True)
# {'userId': 1, 'firstName': 'John', 'lastName': 'Doe', 'email': 'john@example.com'}
Alias Generator
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel, to_snake, to_pascal
class CamelCaseUser(BaseModel):
"""User with camelCase serialization."""
model_config = ConfigDict(alias_generator=to_camel)
user_id: int
first_name: str
last_name: str
email_address: str
user = CamelCaseUser(
user_id=1,
first_name="John",
last_name="Doe",
email_address="john@example.com"
)
# Serialized as camelCase
print(user.model_dump(by_alias=True))
# {'userId': 1, 'firstName': 'John', 'lastName': 'Doe', 'emailAddress': 'john@example.com'}
Default Values
Field Defaults
from pydantic import BaseModel, Field
from typing import Optional, List
class DefaultsExample(BaseModel):
# Simple default
name: str = "Unknown"
# Default with type
age: int = 25
# Optional with default None
nickname: Optional[str] = None
# Required (no default)
email: str
# Field with default
tags: List[str] = Field(default_factory=list)
# Field with factory
items: List[int] = Field(default_factory=lambda: [1, 2, 3])
# Default factory for mutable objects (important!)
metadata: dict = Field(default_factory=dict)
scores: List[int] = Field(default_factory=list)
# Using default_factory for complex defaults
import uuid
class WithFactory(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
created_at: str = Field(default_factory=lambda: "generated_value")
Default Values with Validators
from pydantic import BaseModel, field_validator, Field
from typing import Optional
class UserWithDefaultValidator(BaseModel):
username: str
# Validator runs even with default
display_name: str = Field(default="")
@field_validator('display_name', mode='before')
@classmethod
def use_username_as_display_name(cls, v, info):
# If display_name not provided, use username
if v == "":
# info.data contains other field values
return info.data.get('username', 'Unknown')
return v
# Without display_name - uses username
user = UserWithDefaultValidator(username="john")
print(user.display_name) # "john"
# With display_name - uses provided value
user = UserWithDefaultValidator(username="john", display_name="John Doe")
print(user.display_name) # "John Doe"
Constrained Types
String Constraints
from pydantic import BaseModel, Field, constr
# Using Field
class FieldConstraints(BaseModel):
username: str = Field(min_length=3, max_length=20)
password: str = Field(min_length=8)
slug: str = Field(pattern=r'^[a-z0-9-]+$')
# Using constrained types
class ConstrainedTypes(BaseModel):
# constr creates a constrained string type
username: constr(min_length=3, max_length=20)
password: constr(min_length=8)
slug: constr(pattern=r'^[a-z0-9-]+$')
email: constr(pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')
user = ConstrainedTypes(
username="john_doe",
password="secret123",
slug="my-blog-post",
email="john@example.com"
)
Numeric Constraints
from pydantic import BaseModel, Field, conint, confloat, conlist
class NumericConstraints(BaseModel):
# Integer constraints
quantity: conint(ge=0, le=1000)
age: conint(ge=0, le=150)
# Float constraints
price: confloat(gt=0)
rating: confloat(ge=0.0, le=5.0)
# List constraints
scores: conlist(int, min_length=1, max_length=10)
codes: conlist(str, min_length=3)
product = NumericConstraints(
quantity=10,
age=25,
price=19.99,
rating=4.5,
scores=[90, 85, 95],
codes=["ABC", "DEF"]
)
Special Types
Email, URL, UUID
from pydantic import BaseModel, EmailStr, HttpUrl, UUID1, UUID4, PaymentCardNumber
from typing import Optional
import uuid
class ContactInfo(BaseModel):
# Email validation
email: EmailStr
secondary_email: Optional[EmailStr] = None
# URL validation
website: HttpUrl
api_endpoint: Optional[HttpUrl] = None
# UUID
user_uuid: UUID1 # UUID version 1
session_uuid: UUID4 # UUID version 4
# Payment card (Luhn validation)
card_number: Optional[PaymentCardNumber] = None
contact = ContactInfo(
email="user@example.com",
website="https://example.com",
user_uuid=uuid.uuid1(),
session_uuid=uuid.uuid4()
)
Secret Types
from pydantic import BaseModel, SecretStr, SecretBytes
class Credentials(BaseModel):
# Masks value in output
password: SecretStr
api_key: Optional[SecretStr] = None
# For binary secrets
encryption_key: SecretBytes
creds = Credentials(password="secret123")
# Access the secret
print(creds.password) # SecretStr('**********')
print(creds.password.get_secret_value()) # "secret123"
# Serialization
data = creds.model_dump()
# {'password': SecretStr('**********'), 'api_key': None, 'encryption_key': SecretBytes(b'**********')}
Enum Types
from pydantic import BaseModel, StrEnum, IntEnum
from enum import Enum
class Status(str, Enum):
PENDING = "pending"
ACTIVE = "active"
COMPLETED = "completed"
FAILED = "failed"
class Priority(int, Enum):
LOW = 1
MEDIUM = 2
HIGH = 3
class Task(BaseModel):
# Works with any enum
status: Status = Status.PENDING
priority: Priority = Priority.MEDIUM
task = Task(status=Status.ACTIVE, priority=Priority.HIGH)
# StringEnum (Pydantic v2)
class UserRole(StrEnum):
ADMIN = "admin"
USER = "user"
GUEST = "guest"
class User(BaseModel):
role: UserRole = UserRole.GUEST
Configuration
Model Config
from pydantic import BaseModel, ConfigDict
from typing import Optional
# Pydantic v2 style
class User(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True, # Strip whitespace from strings
validate_assignment=True, # Validate on assignment
arbitrary_types_allowed=True, # Allow arbitrary types
use_enum_values=True, # Use enum values (not enum objects)
populate_by_name=True, # Allow population by name (not alias)
extra='forbid', # Forbid extra fields
frozen=True, # Make model immutable
strict_fields=False, # Not strict by default
)
id: int
name: str
email: Optional[str] = None
# v1 style (still works)
class UserV1(BaseModel):
class Config:
str_strip_whitespace = True
validate_assignment = True
id: int
name: str
Field-Level Config
from pydantic import BaseModel, Field, field_config
@field_config(validate_default=True)
def validated_field():
return Field(default="default_value")
class ModelWithFieldConfig(BaseModel):
# This field will be validated even with default
value: str = Field(default="test", validate_default=True)
Advanced Config Patterns
Strict Mode and Extra Fields
from pydantic import BaseModel, ConfigDict, Field
from typing import Any, Optional
# Strict mode with extra allowed
class StrictWithExtra(BaseModel):
model_config = ConfigDict(
strict=False, # Non-strict mode
extra="allow" # Allow extra fields
)
id: int
name: str
metadata: Optional[dict] = None
extra_data: Any = Field(default=None)
# This accepts extra fields without error
data = {"id": 1, "name": "John", "extra_field": "allowed"}
user = StrictWithExtra(**data)
print(user.extra_field) # "allowed"
# Strict mode with forbid (default)
class StrictModel(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
id: int
name: str
# This will raise ValidationError for extra fields
data = {"id": 1, "name": "John", "extra": "not allowed"}
Multi-Environment Settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional
import os
class DatabaseSettings(BaseSettings):
"""Database configuration."""
model_config = SettingsConfigDict(
env_file='.env.prod', # Different env file for production
env_nested_delimiter='__',
)
host: str = "localhost"
port: int = 5432
name: str
user: str
password: str
class DevSettings(BaseSettings):
"""Development settings."""
model_config = SettingsConfigDict(
env_file='.env.dev',
)
debug: bool = True
host: str = "127.0.0.1"
port: int = 8000
class ProdSettings(BaseSettings):
"""Production settings."""
model_config = SettingsConfigDict(
env_file='.env.prod',
)
debug: bool = False
host: str = os.getenv("DATABASE_HOST", "db.prod.example.com")
port: int = 5432
# Use environment-aware settings
env = os.getenv("ENVIRONMENT", "dev")
if env == "prod":
settings = ProdSettings()
else:
settings = DevSettings()
# Environment variables:
# For production:
# export ENVIRONMENT=prod
# source .env.prod
# For development:
# export ENVIRONMENT=dev
# source .env.dev
Nested Structure Config
from pydantic import BaseModel, ConfigDict
from typing import Dict, Any, Optional
class APIConfig(BaseModel):
"""Nested API configuration."""
model_config = ConfigDict(
extra="allow",
)
timeout: int = 30
retries: int = 3
endpoints: Dict[str, Any] = {}
class Application(BaseModel):
"""Application with nested config structures."""
model_config = ConfigDict(
str_strip_whitespace=True,
)
app_name: str
version: str
api: APIConfig = Field(default_factory=APIConfig)
features: Dict[str, bool] = {}
# Usage
app = Application(
app_name="MyApp",
version="1.0.0",
api={"timeout": 60, "retries": 5, "custom_setting": "extra"}
)
TypeAdapter
TypeAdapter for Custom Validation
from pydantic import TypeAdapter, ValidationError
from typing import List, Optional, Any
# TypeAdapter for custom validation on arbitrary objects
ta_list_str = TypeAdapter(List[str])
# Validate arbitrary data against the adapter
data = ["a", "b", "c"]
validated = ta_list_str.validate_python(data)
print(validated) # ["a", "b", "c"]
# Validate from JSON
json_data = '["x", "y", "z"]'
validated_json = ta_list_str.validate_json(json_data)
print(validated_json) # ["x", "y", "z"]
# Convert to JSON
output = ta_list_str.dump_json(["hello", "world"])
print(output) # b'["hello","world"]'
# Using TypeAdapter for type coercion
ta_int = TypeAdapter(int)
result = ta_int.validate_python("42") # Converts "42" to 42
print(result) # 42
# TypeAdapter for complex nested types
from typing import Dict
ta_dict = TypeAdapter(Dict[str, int])
nested_data = {"a": 1, "b": 2, "c": 3}
validated_nested = ta_dict.validate_python(nested_data)
# TypeAdapter for custom error messages
class CustomIntError(BaseException):
pass
ta_custom = TypeAdapter(int)
try:
ta_custom.validate_python("not a number")
except ValidationError as e:
# Custom error handling
print(f"Validation failed: {e.errors()[0]['msg']}")
RootModel
RootModel for Collection-Only Schemas
from pydantic import RootModel
from typing import List, Optional
# RootModel for collection-only schemas
class StringList(RootModel[List[str]]):
"""Root model for list of strings - no parent wrapper."""
@property
def labels(self) -> List[str]:
"""Derived property for list items."""
return [item.upper() for item in self.root]
# Usage with list of strings
strings = StringList(root=["apple", "banana", "cherry"])
print(strings.root) # ["apple", "banana", "cherry"]
print(strings.labels) # ["APPLE", "BANANA", "CHERRY"]
# From JSON
json_data = '["dog", "cat", "bird"]'
pets = StringList.model_validate_json(json_data)
print(pets) # StringList(root=['dog', 'cat', 'bird'])
# Optional root
class OptionalList(RootModel[Optional[List[str]]]):
"""Root model with optional root."""
pass
optional = OptionalList(root=["a", "b"])
empty = OptionalList(root=None)
print(optional.root) # ["a", "b"]
print(empty.root) # None
# Multiple types with RootModel
class NumberList(RootModel[List[int]]):
"""List of numbers."""
pass
numbers = NumberList(root=[1, 2, 3])
print(numbers.model_dump()) # [1, 2, 3]
Discriminated Unions
Basic Discriminated Union
from pydantic import BaseModel, Field, Discriminator
from typing import Union, Annotated
class Cat(BaseModel):
pet_type: str = Field(default="cat", const=True)
meows: int
class Dog(BaseModel):
pet_type: str = Field(default="dog", const=True)
barks: float
class Zoo(BaseModel):
# Using Tag for discriminated union
pet: Union[Cat, Dog] = Field(..., discriminator='pet_type')
# Pydantic v2 style
class ZooV2(BaseModel):
pet: Annotated[
Union[Cat, Dog],
Discriminator(tag='pet_type')
]
# This is how it's done in API responses with multiple types
class ApiResponse(BaseModel):
status: str
data: Union[Cat, Dog] = Field(..., discriminator='pet_type')
# Usage
response = ApiResponse(
status="success",
data={"pet_type": "cat", "meows": 5}
)
print(response.data) # Cat(meows=5)
Advanced Discriminated Union
from pydantic import BaseModel, Field, Discriminator
from typing import Union, Literal, Annotated
class Image(BaseModel):
type: Literal["image"]
url: str
format: Literal["jpg", "png", "gif"]
class Video(BaseModel):
type: Literal["video"]
url: str
duration: int
format: Literal["mp4", "webm"]
class Document(BaseModel):
type: Literal["document"]
pages: int
format: Literal["pdf", "docx"]
# API Response with multiple media types
class MediaResponse(BaseModel):
id: int
title: str
media: Annotated[
Union[Image, Video, Document],
Discriminator(tag='type')
]
created_at: str
# Pydantic automatically routes based on discriminator
response = MediaResponse(
id=1,
title="My Video",
media={"type": "video", "url": "https://example.com/video.mp4", "duration": 120, "format": "mp4"},
created_at="2024-01-01T00:00:00Z"
)
print(response.media) # Video(duration=120, url='https://example.com/video.mp4', format='mp4')
Error Handling
Error Handling with ValidationError
from pydantic import BaseModel, ValidationError
from typing import List, Optional
# API response handling with error formatting
class UserResponse(BaseModel):
id: int
username: str
email: str
age: int
# Try/except for API responses
def fetch_user_from_api(api_data):
try:
user = UserResponse(**api_data)
return {"success": True, "data": user}
except ValidationError as e:
# Format validation errors for API
errors = e.errors()
formatted_errors = {
error['loc'][0]: {
'message': error['msg'],
'type': error['type'],
'input': error['input']
}
for error in errors
if error['loc'] and len(error['loc']) > 0
}
return {
"success": False,
"errors": formatted_errors
}
# Example usage
valid_data = {"id": 1, "username": "john", "email": "john@example.com", "age": 30}
invalid_data = {"id": "not_an_int", "username": "jo", "email": "invalid", "age": -5}
result = fetch_user_from_api(valid_data)
print(result["success"]) # True
result = fetch_user_from_api(invalid_data)
print(result["success"]) # False
print(result["errors"]) # Formatted error dict
# Error details in e.errors()
try:
user = UserResponse(id="not an int", name="John", age="not an int")
except ValidationError as e:
# Full error list
print(e.errors())
# [
# {
# 'type': 'int_parsing',
# 'loc': ('id',),
# 'msg': 'Input should be a valid integer',
# 'input': 'not an int'
# },
# {
# 'type': 'int_parsing',
# 'loc': ('age',),
# 'msg': 'Input should be a valid integer',
# 'input': 'not an int'
# }
# ]
# Human-readable
print(e)
# 2 validation errors for UserResponse
# id
# Input should be a valid integer [type=int_parsing, input_value='not an int', input_type=str]
# age
# Input should be a valid integer [type=int_parsing, input_value='not an int', input_type=str]
# Custom error messages in field validators
from pydantic import field_validator
class AgeUser(BaseModel):
age: int
@field_validator('age')
@classmethod
def validate_age(cls, v):
if v < 0:
raise ValueError('Age must be a positive number')
if v > 150:
raise ValueError('Are you really that old?')
return v
try:
AgeUser(age=-5)
except ValidationError as e:
print(e.error_count()) # 1
for error in e.errors():
print(f"Field: {error['loc']}")
print(f"Message: {error['msg']}")
print(f"Input: {error['input']}")
Settings
BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional
class Settings(BaseSettings):
"""Application settings from environment variables."""
# Required settings
app_name: str
database_url: str
# Optional with defaults
debug: bool = False
port: int = 8000
max_connections: int = 10
# Sensitive settings (will be masked in output)
secret_key: str
# Settings with aliases
db_host: str = Field(alias='DATABASE_HOST', default='localhost')
#model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8')
# Usage
# Reads from environment variables and .env file
settings = Settings(
app_name="My App",
database_url="postgresql://localhost/mydb",
secret_key="super-secret"
)
# Access values
print(settings.app_name)
print(settings.debug)
# Configuration via model_config
class SettingsV2(BaseSettings):
model_config = SettingsConfigDict(
env_file='.env',
env_file_encoding='utf-8',
case_sensitive=False,
extra='ignore'
)
app_name: str
debug: bool = False
Nested Settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional
class DatabaseSettings(BaseSettings):
"""Database configuration."""
host: str = "localhost"
port: int = 5432
name: str
user: str
password: str
class RedisSettings(BaseSettings):
"""Redis configuration."""
host: str = "localhost"
port: int = 6379
db: int = 0
class Settings(BaseSettings):
"""Application settings."""
app_name: str
database: DatabaseSettings
redis: RedisSettings
model_config = SettingsConfigDict(env_nested_delimiter='__')
# Environment variables:
# APP_NAME=MyApp
# DATABASE__HOST=db.example.com
# DATABASE__PORT=5432
# REDIS__HOST=redis.example.com
settings = Settings(
app_name="MyApp",
database={"name": "mydb", "user": "user", "password": "pass"},
redis={}
)
FastAPI Integration
Request Models
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel, EmailStr, Field
app = FastAPI()
class UserCreate(BaseModel):
"""User creation schema."""
username: str = Field(min_length=3, max_length=50)
email: EmailStr
password: str = Field(min_length=8)
age: int = Field(ge=0, le=150)
class UserResponse(BaseModel):
"""User response schema (excludes sensitive data)."""
id: int
username: str
email: str
model_config = {'from_attributes': True}
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate):
"""Create a new user."""
# Validate and process user data
# user is already validated!
new_user = await save_user(user.model_dump())
return new_user
@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
"""Get user by ID."""
user = await get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
Response Models
from fastapi import FastAPI
from pydantic import BaseModel
from datetime import datetime
class Item(BaseModel):
id: int
name: str
price: float
class ItemWithTax(BaseModel):
"""Item with calculated tax."""
id: int
name: str
price: float
tax: float
@classmethod
def from_item(cls, item: Item):
return cls(
id=item.id,
name=item.name,
price=item.price,
tax=item.price * 0.1 # 10% tax
)
app = FastAPI()
@app.get("/items/{item_id}", response_model=ItemWithTax)
async def get_item(item_id: int):
item = await get_item_from_db(item_id)
return ItemWithTax.from_item(item)
Nested Validation
from fastapi import FastAPI
from pydantic import BaseModel, ValidationError
app = FastAPI()
class Address(BaseModel):
street: str
city: str
country: str
class UserWithAddress(BaseModel):
name: str
address: Address
@app.post("/users")
async def create_user(user: UserWithAddress):
return user
# Nested validation error example:
# Request: {"name": "John", "address": {"street": "123 Main"}}
# Error: Validation error for address.city (field required)
Best Practices
1. Use Type Hints
# Good: Full type hints
class User(BaseModel):
id: int
name: str
email: str
is_active: bool = True
# Bad: Missing type hints
class User(BaseModel):
id = None
name = None
2. Define Defaults Properly
# Good: Use default_factory for mutable objects
class User(BaseModel):
tags: List[str] = Field(default_factory=list)
metadata: dict = Field(default_factory=dict)
# Bad: Mutable default argument
class User(BaseModel):
tags: List[str] = [] # ERROR: mutable default!
metadata: dict = {}
3. Use Constrained Types
# Good: Constrained types
class User(BaseModel):
username: str = Field(min_length=3, max_length=20)
age: int = Field(ge=0, le=150)
# Bad: Unconstrained validation in handler
class User(BaseModel):
username: str
age: int
@field_validator('username')
def validate_username(self, v):
if len(v) < 3 or len(v) > 20:
raise ValueError('Invalid username')
return v
4. Separate Schemas
# Good: Separate schemas for different operations
class UserBase(BaseModel):
email: EmailStr
class UserCreate(UserBase):
username: str
password: str
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
username: Optional[str] = None
class UserResponse(UserBase):
id: int
created_at: datetime
model_config = {'from_attributes': True}
Common Issues
Performance
# Issue: Slow validation
# Solution: Use Pydantic v2 (much faster)
# pip install pydantic>=2.0.0
# Solution: Use orjson for serialization
# pip install orjson
import orjson
class FastModel(BaseModel):
model_config = {'json_loads': orjson.loads, 'json_dumps': orjson.dumps}
Mutable Defaults
# Issue: Mutable default arguments
# Error in Pydantic v2
class BadModel(BaseModel):
items: list = [] # ERROR!
# Solution: Use default_factory
class GoodModel(BaseModel):
items: list = Field(default_factory=list)
Validation Order
# Issue: Validator order
# In Pydantic v2, field_validators run in order of definition
# mode='before' validators run first, then field type validation, then 'after'
class OrderedValidation(BaseModel):
value: str
@field_validator('value', mode='before')
@classmethod
def before_validation(cls, v):
# Runs first
return v.strip().lower() if isinstance(v, str) else v
@field_validator('value')
@classmethod
def after_validation(cls, v):
# Runs after type validation
return v
References
- Official Documentation: https://docs.pydantic.dev/
- GitHub Repository: https://github.com/pydantic/pydantic
- Pydantic Discord: https://discord.gg/pydantic
- FastAPI Documentation: https://fastapi.tiangolo.com/
- Stack Overflow: https://stackoverflow.com/questions/tagged/pydantic