Python Pydantic
Data validation and settings management using Python type annotations.
Python Pydantic
Data validation and settings management using Python type annotations.
Overview
Pydantic is a data validation library that uses Python type hints to define data schemas. It provides runtime type checking, automatic data conversion, and detailed error messages. Pydantic is the foundation for FastAPI and is widely used for API request/response validation, configuration management, and data serialisation.
flowchart TB
subgraph "Pydantic Validation Flow"
A[Input Data] --> B[Type Coercion]
B --> C{Field Validators}
C -->|Pass| D{Model Validators}
C -->|Fail| E[ValidationError]
D -->|Pass| F[Valid Model Instance]
D -->|Fail| E
end
subgraph "Model Features"
G[BaseModel] --> H[Type Hints]
G --> I[Field Constraints]
G --> J[Validators]
G --> K[Config]
G --> L[Serialisation]
end
subgraph "Output Formats"
F --> M[dict/JSON]
F --> N[JSON Schema]
F --> O[ORM Objects]
end
Model Definition
Key Concepts
- BaseModel: The base class for all Pydantic models
- Field: Function to add metadata and constraints to fields
- Type hints: Python annotations define expected types
- Default values: Fields can have defaults or be required
- Automatic coercion: Pydantic converts compatible types automatically
Basic Models
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional
from uuid import UUID
# Simple model
class User(BaseModel):
id: int
name: str
email: str
is_active: bool = True # Default value
# Create instance
user = User(id=1, name="Alice", email="alice@example.com")
print(user.name) # Alice
print(user.model_dump()) # {'id': 1, 'name': 'Alice', 'email': 'alice@example.com', 'is_active': True}
# Optional fields
class Profile(BaseModel):
username: str
bio: Optional[str] = None
avatar_url: Optional[str] = None
# Nested models
class Address(BaseModel):
street: str
city: str
postcode: str
country: str = "UK"
class Company(BaseModel):
name: str
address: Address
founded: datetime
company = Company(
name="TechCorp",
address={"street": "123 Main St", "city": "London", "postcode": "EC1A 1BB"},
founded="2020-01-15T10:30:00"
)
Field Function
from pydantic import BaseModel, Field
from typing import Annotated
class Product(BaseModel):
# Field with constraints
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0, description="Price in GBP")
quantity: int = Field(ge=0, default=0)
# Field with alias
product_id: str = Field(alias="productId")
# Field with examples for documentation
sku: str = Field(
min_length=8,
max_length=12,
pattern=r"^[A-Z]{3}-\d{5}$",
examples=["ABC-12345", "XYZ-99999"]
)
# Using Annotated syntax (Pydantic v2)
class Item(BaseModel):
name: Annotated[str, Field(min_length=1, max_length=50)]
weight: Annotated[float, Field(gt=0, description="Weight in kg")]
# Create with alias
product = Product(
name="Widget",
price=9.99,
productId="PROD-001", # Uses alias
sku="ABC-12345"
)
print(product.product_id) # PROD-001
# Field with default factory
from typing import List
from uuid import uuid4
from datetime import timezone
class Order(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
items: List[str] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
Complex Types
from pydantic import BaseModel
from typing import List, Dict, Set, Tuple, Union, Literal
from enum import Enum
# Collections
class Inventory(BaseModel):
items: List[str]
quantities: Dict[str, int]
tags: Set[str]
coordinates: Tuple[float, float]
# Union types
class Response(BaseModel):
data: Union[str, int, List[str]]
status: Union[int, str]
# Literal types
class Config(BaseModel):
environment: Literal["development", "staging", "production"]
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"]
# Enums
class Status(str, Enum):
PENDING = "pending"
ACTIVE = "active"
COMPLETED = "completed"
class Task(BaseModel):
title: str
status: Status = Status.PENDING
task = Task(title="Review PR", status="active") # String coerced to enum
print(task.status) # Status.ACTIVE
print(task.status.value) # active
Data Validation
Key Concepts
- field_validator: Validates individual field values
- model_validator: Validates the entire model (cross-field validation)
- Validation modes:
before,after,wrapfor different validation stages - ValidationError: Exception containing all validation failures
flowchart LR
subgraph "Validation Order"
A[Raw Input] --> B[before validators]
B --> C[Type Coercion]
C --> D[after validators]
D --> E[model_validator]
E --> F[Valid Instance]
end
Field Validators
from pydantic import BaseModel, field_validator, ValidationError
import re
class User(BaseModel):
username: str
email: str
age: int
password: str
# Single field validator
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
if not v.isalnum():
raise ValueError("Username must be alphanumeric")
if len(v) < 3:
raise ValueError("Username must be at least 3 characters")
return v.lower()
# Email validator
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
pattern = r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$"
if not re.match(pattern, v):
raise ValueError("Invalid email format")
return v.lower()
# Multiple fields with same validator
@field_validator("username", "email")
@classmethod
def strip_whitespace(cls, v: str) -> str:
return v.strip()
# Validate before type coercion
@field_validator("age", mode="before")
@classmethod
def parse_age(cls, v):
if isinstance(v, str):
v = v.strip()
if v.endswith(" years"):
v = v[:-6]
return int(v)
# Usage
try:
user = User(
username=" Alice123 ",
email="alice@example.com",
age="25 years",
password="secret123"
)
print(user.username) # alice123
print(user.age) # 25
except ValidationError as e:
print(e.errors())
Model Validators
from pydantic import BaseModel, model_validator, field_validator
from typing import Self
class UserRegistration(BaseModel):
username: str
email: str
password: str
confirm_password: str
# After validation (all fields parsed)
@model_validator(mode="after")
def check_passwords_match(self) -> Self:
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
# Before validation (raw data)
@model_validator(mode="before")
@classmethod
def preprocess_data(cls, data: dict) -> dict:
# Normalise email if present
if "email" in data:
data["email"] = data["email"].lower().strip()
return data
class DateRange(BaseModel):
start_date: datetime
end_date: datetime
@model_validator(mode="after")
def check_date_order(self) -> Self:
if self.start_date >= self.end_date:
raise ValueError("start_date must be before end_date")
return self
# Wrap mode for complex transformations
class FlexibleModel(BaseModel):
value: int
@model_validator(mode="wrap")
@classmethod
def wrap_validator(cls, values, handler):
# Pre-processing
if isinstance(values, int):
values = {"value": values}
# Call the standard validation
result = handler(values)
# Post-processing
return result
Custom Types
from pydantic import BaseModel, BeforeValidator, AfterValidator, PlainValidator
from typing import Annotated
# Before validator - transforms input before standard validation
def normalise_phone(v: str) -> str:
"""Remove spaces and dashes from phone number."""
return v.replace(" ", "").replace("-", "")
PhoneNumber = Annotated[str, BeforeValidator(normalise_phone)]
# After validator - validates after type coercion
def check_positive(v: int) -> int:
if v <= 0:
raise ValueError("Must be positive")
return v
PositiveInt = Annotated[int, AfterValidator(check_positive)]
# Plain validator - complete custom validation
def validate_postcode(v: str) -> str:
v = v.upper().strip()
# UK postcode pattern
pattern = r"^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$"
if not re.match(pattern, v):
raise ValueError("Invalid UK postcode")
return v
UKPostcode = Annotated[str, PlainValidator(validate_postcode)]
class Contact(BaseModel):
name: str
phone: PhoneNumber
age: PositiveInt
postcode: UKPostcode
contact = Contact(
name="Bob",
phone="07700 900-123",
age=30,
postcode="sw1a 1aa"
)
print(contact.phone) # 07700900123
print(contact.postcode) # SW1A 1AA
Type Hints and Constraints
Key Concepts
- Constrained types: Built-in types with validation rules
- Annotated: Python typing feature for adding metadata
- Strict types: Disable automatic type coercion
- Custom constraints: Define reusable type patterns
Built-in Constrained Types
from pydantic import (
BaseModel,
PositiveInt,
NegativeFloat,
NonNegativeInt,
NonPositiveFloat,
StrictInt,
StrictStr,
StrictBool,
conint,
confloat,
constr,
conlist,
conset,
)
class Constraints(BaseModel):
# Numeric constraints
age: PositiveInt # > 0
balance: NonNegativeInt # >= 0
temperature: NegativeFloat # < 0
# Custom numeric constraints
percentage: confloat(ge=0, le=100)
score: conint(ge=0, le=1000, multiple_of=10)
# String constraints
username: constr(min_length=3, max_length=20, pattern=r"^[a-z]+$")
code: constr(to_upper=True, strip_whitespace=True)
# Collection constraints
tags: conlist(str, min_length=1, max_length=10)
unique_ids: conset(int, min_length=1)
# Strict types (no coercion)
strict_count: StrictInt
strict_name: StrictStr
strict_flag: StrictBool
# Strict types reject type coercion
class StrictModel(BaseModel):
count: StrictInt
# This raises ValidationError - no coercion from string
# StrictModel(count="5")
# This works
StrictModel(count=5)
Annotated Constraints
from pydantic import BaseModel, Field
from typing import Annotated
from annotated_types import Gt, Lt, Ge, Le, Len, Predicate
class Product(BaseModel):
# Using annotated-types
price: Annotated[float, Gt(0), Lt(10000)]
name: Annotated[str, Len(1, 100)]
# Combining with Field
description: Annotated[
str,
Field(description="Product description"),
Len(10, 1000)
]
# Custom predicate
sku: Annotated[
str,
Predicate(lambda x: x.isupper()),
Len(8, 12)
]
# Reusable type aliases
Price = Annotated[float, Gt(0), Le(99999.99)]
Percentage = Annotated[float, Ge(0), Le(100)]
NonEmptyString = Annotated[str, Len(1, None)]
class Order(BaseModel):
total: Price
discount: Percentage
notes: NonEmptyString
Special Types
from pydantic import (
BaseModel,
EmailStr,
HttpUrl,
AnyUrl,
FilePath,
DirectoryPath,
SecretStr,
Json,
UUID4,
)
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from pathlib import Path
class UserProfile(BaseModel):
# Email validation
email: EmailStr
# URL validation
website: HttpUrl
callback_url: AnyUrl
# Secret (hidden in repr/str)
api_key: SecretStr
# UUID
user_id: UUID4
# Date/time types
birth_date: date
created_at: datetime
start_time: time
duration: timedelta
# Decimal for precise calculations
balance: Decimal
user = UserProfile(
email="user@example.com",
website="https://example.com",
callback_url="ws://localhost:8080",
api_key="super-secret-key",
user_id="550e8400-e29b-41d4-a716-446655440000",
birth_date="1990-05-15",
created_at="2024-01-15T10:30:00Z",
start_time="14:30:00",
duration="PT2H30M", # ISO 8601 duration
balance="1234.56"
)
# SecretStr hides value
print(user.api_key) # ********** (repr shows SecretStr('**********'))
print(user.api_key.get_secret_value()) # super-secret-key
# JSON string parsing
class Config(BaseModel):
settings: Json[dict]
config = Config(settings='{"debug": true, "timeout": 30}')
print(config.settings) # {'debug': True, 'timeout': 30}
Model Configuration
Key Concepts
- model_config: Class variable for model settings (Pydantic v2)
- ConfigDict: Type-safe configuration dictionary
- Behaviour customisation: Control validation, serialisation, and more
Configuration Options
from pydantic import BaseModel, ConfigDict, Field
class User(BaseModel):
model_config = ConfigDict(
# Validation behaviour
strict=False, # Allow type coercion
validate_assignment=True, # Validate on attribute assignment
validate_default=True, # Validate default values
extra="forbid", # Forbid extra fields ("allow", "ignore", "forbid")
# String handling
str_strip_whitespace=True, # Strip whitespace from strings
str_min_length=1, # Minimum string length
# Serialisation
populate_by_name=True, # Allow population by field name or alias
use_enum_values=True, # Use enum values instead of enum objects
# JSON schema
json_schema_extra={
"examples": [
{"id": 1, "name": "Alice", "email": "alice@example.com"}
]
},
# Miscellaneous
frozen=False, # Make model immutable if True
from_attributes=True, # Allow creating from ORM objects
arbitrary_types_allowed=True, # Allow arbitrary types
)
id: int
name: str
email: str
# Immutable model
class ImmutableConfig(BaseModel):
model_config = ConfigDict(frozen=True)
name: str
value: int
config = ImmutableConfig(name="test", value=42)
# config.value = 100 # Raises error - model is frozen
# Extra fields handling
class StrictUser(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
email: str
# This raises ValidationError
# StrictUser(name="Alice", email="alice@example.com", age=30)
class FlexibleUser(BaseModel):
model_config = ConfigDict(extra="allow")
name: str
email: str
user = FlexibleUser(name="Bob", email="bob@example.com", age=25)
print(user.model_extra) # {'age': 25}
ORM Mode
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.orm import declarative_base, Session
Base = declarative_base()
# SQLAlchemy model
class UserORM(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String(100))
email = Column(String(255))
# Pydantic model with ORM support
class UserSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
email: str
# Usage
orm_user = UserORM(id=1, name="Alice", email="alice@example.com")
pydantic_user = UserSchema.model_validate(orm_user)
print(pydantic_user.model_dump())
Computed Fields
from pydantic import BaseModel, computed_field, 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)
rect = Rectangle(width=10, height=5)
print(rect.area) # 50.0
print(rect.model_dump()) # {'width': 10.0, 'height': 5.0, 'area': 50.0, 'perimeter': 30.0}
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}"
Serialisation
Key Concepts
- model_dump(): Convert model to dictionary
- model_dump_json(): Convert model to JSON string
- Serialisation modes: Control output format and included fields
- Custom serialisers: Define custom serialisation logic
Basic Serialisation
from pydantic import BaseModel, Field
from datetime import datetime
class User(BaseModel):
id: int
name: str
email: str
password: str = Field(exclude=True) # Exclude from serialisation
created_at: datetime
is_active: bool = True
user = User(
id=1,
name="Alice",
email="alice@example.com",
password="secret123",
created_at=datetime(2024, 1, 15, 10, 30)
)
# To dictionary
data = user.model_dump()
print(data)
# {'id': 1, 'name': 'Alice', 'email': 'alice@example.com', 'created_at': datetime(...), 'is_active': True}
# To JSON string
json_str = user.model_dump_json()
print(json_str)
# '{"id":1,"name":"Alice","email":"alice@example.com","created_at":"2024-01-15T10:30:00","is_active":true}'
# Pretty JSON
json_str = user.model_dump_json(indent=2)
Serialisation Options
from pydantic import BaseModel, Field
from typing import Optional
class Product(BaseModel):
id: int
name: str
description: Optional[str] = None
price: float
internal_code: str = Field(exclude=True)
tags: list[str] = []
product = Product(
id=1,
name="Widget",
price=9.99,
internal_code="INT-001",
tags=["sale", "new"]
)
# Include specific fields
data = product.model_dump(include={"id", "name", "price"})
# {'id': 1, 'name': 'Widget', 'price': 9.99}
# Exclude specific fields
data = product.model_dump(exclude={"tags"})
# Exclude None values
data = product.model_dump(exclude_none=True)
# Exclude unset values (not explicitly set)
data = product.model_dump(exclude_unset=True)
# Exclude default values
data = product.model_dump(exclude_defaults=True)
# Use aliases in output
class AliasedProduct(BaseModel):
product_id: int = Field(alias="productId", serialization_alias="product_id")
product_name: str = Field(alias="productName")
prod = AliasedProduct(productId=1, productName="Test")
print(prod.model_dump(by_alias=True))
# {'product_id': 1, 'productName': 'Test'}
# Nested exclude/include
class Order(BaseModel):
id: int
product: Product
order = Order(id=1, product=product)
data = order.model_dump(
exclude={"product": {"internal_code", "tags"}}
)
Custom Serialisers
from pydantic import BaseModel, field_serializer, model_serializer
from datetime import datetime, date
from decimal import Decimal
class Transaction(BaseModel):
id: int
amount: Decimal
currency: str
timestamp: datetime
# Field serialiser
@field_serializer("amount")
def serialise_amount(self, value: Decimal) -> str:
return f"{value:.2f}"
@field_serializer("timestamp")
def serialise_timestamp(self, value: datetime) -> str:
return value.strftime("%Y-%m-%d %H:%M:%S")
@field_serializer("timestamp", when_used="json")
def serialise_timestamp_json(self, value: datetime) -> str:
return value.isoformat()
tx = Transaction(
id=1,
amount=Decimal("123.456"),
currency="GBP",
timestamp=datetime(2024, 1, 15, 10, 30)
)
print(tx.model_dump())
# {'id': 1, 'amount': '123.46', 'currency': 'GBP', 'timestamp': '2024-01-15 10:30:00'}
# Model serialiser for complete control
class CustomModel(BaseModel):
name: str
value: int
@model_serializer
def serialise_model(self) -> dict:
return {
"data": {
"name": self.name,
"value": self.value
},
"meta": {
"type": self.__class__.__name__
}
}
model = CustomModel(name="test", value=42)
print(model.model_dump())
# {'data': {'name': 'test', 'value': 42}, 'meta': {'type': 'CustomModel'}}
Parsing and Validation Errors
Key Concepts
- ValidationError: Exception containing all validation failures
- Error structure: Location, message, type, and input value
- Multiple errors: All validation errors collected and reported together
- Custom error messages: Override default error messages
Handling Validation Errors
from pydantic import BaseModel, ValidationError, field_validator
class User(BaseModel):
name: str
email: str
age: int
@field_validator("age")
@classmethod
def validate_age(cls, v):
if v < 0:
raise ValueError("Age must be positive")
if v > 150:
raise ValueError("Age seems unrealistic")
return v
# Handling validation errors
try:
user = User(name=123, email="invalid", age=-5)
except ValidationError as e:
# Get error count
print(f"Errors: {e.error_count()}")
# Get errors as list of dicts
errors = e.errors()
for error in errors:
print(f"Field: {error['loc']}")
print(f"Message: {error['msg']}")
print(f"Type: {error['type']}")
print(f"Input: {error['input']}")
print("---")
# Get JSON representation
print(e.json(indent=2))
# Output:
# Errors: 2
# Field: ('name',)
# Message: Input should be a valid string
# Type: string_type
# Input: 123
# ---
# Field: ('age',)
# Message: Value error, Age must be positive
# Type: value_error
# Input: -5
# (Note: email is a plain str here, so "invalid" passes; use EmailStr for email validation)
Custom Error Messages
from pydantic import BaseModel, field_validator, ValidationError
from pydantic_core import PydanticCustomError
class Product(BaseModel):
name: str
price: float
quantity: int
@field_validator("price")
@classmethod
def validate_price(cls, v):
if v <= 0:
raise PydanticCustomError(
"invalid_price",
"Price must be positive, got {price}",
{"price": v}
)
return v
@field_validator("quantity")
@classmethod
def validate_quantity(cls, v):
if v < 0:
raise PydanticCustomError(
"negative_quantity",
"Quantity cannot be negative"
)
return v
try:
product = Product(name="Widget", price=-10, quantity=-5)
except ValidationError as e:
for error in e.errors():
print(f"{error['type']}: {error['msg']}")
# Output:
# invalid_price: Price must be positive, got -10
# negative_quantity: Quantity cannot be negative
Safe Parsing
from pydantic import BaseModel, ValidationError
from typing import Optional
class User(BaseModel):
name: str
email: str
age: int
# Safe validation with try-except
def parse_user(data: dict) -> Optional[User]:
try:
return User.model_validate(data)
except ValidationError as e:
print(f"Validation failed: {e.error_count()} errors")
return None
# Using model_validate with strict mode
user = User.model_validate({"name": "Alice", "email": "a@b.com", "age": "30"})
# Strict validation (no coercion)
try:
user = User.model_validate(
{"name": "Alice", "email": "a@b.com", "age": "30"},
strict=True
)
except ValidationError as e:
print("Strict validation failed")
# From JSON string
json_str = '{"name": "Alice", "email": "alice@example.com", "age": 30}'
user = User.model_validate_json(json_str)
# Context for validation
class ContextUser(BaseModel):
name: str
@field_validator("name")
@classmethod
def validate_name(cls, v, info):
if info.context and info.context.get("uppercase"):
return v.upper()
return v
user = ContextUser.model_validate(
{"name": "alice"},
context={"uppercase": True}
)
print(user.name) # ALICE
Settings Management
Key Concepts
- BaseSettings: Model that reads from environment variables
- Dotenv support: Load variables from .env files
- Priority: Init args > env vars > .env file > defaults
- Nested settings: Complex configuration structures
Basic Settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, SecretStr
from typing import Optional
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Environment variables
app_name: str = "MyApp"
debug: bool = False
# Required settings (no default)
database_url: str
# Secret values
secret_key: SecretStr
api_key: SecretStr
# With env var prefix
redis_host: str = Field(default="localhost", alias="REDIS_HOST")
redis_port: int = Field(default=6379, alias="REDIS_PORT")
# Create settings (reads from environment)
settings = Settings()
print(settings.app_name)
print(settings.debug)
print(settings.database_url)
print(settings.secret_key.get_secret_value())
# Override with explicit values
settings = Settings(debug=True, database_url="postgresql://localhost/mydb")
Environment Variable Patterns
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
from typing import List
class DatabaseSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="DB_", # All vars prefixed with DB_
)
host: str = "localhost"
port: int = 5432
name: str = "mydb"
user: str
password: str
# Reads: DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(
env_nested_delimiter="__", # For nested models
)
app_name: str = "MyApp"
# Nested settings
database: DatabaseSettings = DatabaseSettings()
# Reads: DATABASE__HOST, DATABASE__PORT, etc.
# List from environment variable
class Settings(BaseSettings):
allowed_hosts: List[str] = ["localhost"]
model_config = SettingsConfigDict(
env_parse_none_str="None", # Parse "None" as None
)
# ALLOWED_HOSTS='["example.com", "api.example.com"]'
# (complex types such as lists must be JSON-encoded in env vars)
Settings with Multiple Sources
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
from functools import lru_cache
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
secrets_dir="/run/secrets", # Docker secrets
)
# Different sources
app_name: str = Field(default="MyApp")
database_password: str # Can come from /run/secrets/database_password
api_key: str # From env var or .env file
# Cached settings (singleton pattern)
@lru_cache
def get_settings() -> Settings:
return Settings()
# Usage
settings = get_settings()
# Different env files for environments
class DevSettings(Settings):
model_config = SettingsConfigDict(
env_file=".env.development",
)
class ProdSettings(Settings):
model_config = SettingsConfigDict(
env_file=".env.production",
)
JSON Schema Generation
Key Concepts
- model_json_schema(): Generate JSON Schema from model
- Schema customisation: Control schema output with Field and config
- OpenAPI integration: Schemas compatible with OpenAPI/Swagger
Generating Schemas
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum
import json
class Status(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class Author(BaseModel):
name: str = Field(description="Author's full name")
email: str = Field(description="Contact email")
class Article(BaseModel):
"""Blog article model."""
id: int = Field(description="Unique identifier")
title: str = Field(
min_length=1,
max_length=200,
description="Article title",
examples=["Introduction to Pydantic"]
)
content: str = Field(description="Article body in Markdown")
author: Author
tags: List[str] = Field(
default=[],
max_length=10,
description="Article tags"
)
status: Status = Status.DRAFT
published_at: Optional[datetime] = None
model_config = {
"json_schema_extra": {
"examples": [
{
"id": 1,
"title": "Getting Started",
"content": "# Introduction\n\nWelcome...",
"author": {"name": "Alice", "email": "alice@example.com"},
"tags": ["tutorial", "beginner"],
"status": "published",
"published_at": "2024-01-15T10:30:00Z"
}
]
}
}
# Generate schema
schema = Article.model_json_schema()
print(json.dumps(schema, indent=2))
# Schema for multiple models (definitions)
from pydantic.json_schema import models_json_schema
_, schemas = models_json_schema(
[(Article, "validation"), (Author, "validation")],
title="Blog API Schema"
)
print(json.dumps(schemas, indent=2))
Schema Customisation
from pydantic import BaseModel, Field, field_validator
from pydantic.json_schema import SkipJsonSchema, WithJsonSchema
from typing import Annotated, Any
class CustomModel(BaseModel):
# Skip field from schema
internal_id: SkipJsonSchema[str] = "internal"
# Custom schema for field
custom_field: Annotated[
str,
WithJsonSchema({"type": "string", "format": "custom-format"})
]
# Field with comprehensive schema
amount: float = Field(
json_schema_extra={
"format": "currency",
"minimum": 0,
"examples": [9.99, 19.99]
}
)
# Generate schema with different modes
schema = CustomModel.model_json_schema(mode="serialization") # Output schema
schema = CustomModel.model_json_schema(mode="validation") # Input schema
# Custom schema generation
from pydantic.json_schema import GenerateJsonSchema
class CustomSchemaGenerator(GenerateJsonSchema):
def generate(self, schema, mode="validation"):
json_schema = super().generate(schema, mode=mode)
json_schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
return json_schema
schema = Article.model_json_schema(schema_generator=CustomSchemaGenerator)
Parsing from JSON Schema
from pydantic import BaseModel, TypeAdapter
from typing import List
import json
# Validate arbitrary data against a type
adapter = TypeAdapter(List[int])
result = adapter.validate_python(["1", "2", "3"])
print(result) # [1, 2, 3]
# JSON validation
json_data = '["1", "2", "3"]'
result = adapter.validate_json(json_data)
print(result) # [1, 2, 3]
# Generate schema for any type
schema = adapter.json_schema()
print(schema) # {'items': {'type': 'integer'}, 'type': 'array'}
# Complex type adapter
from typing import Dict, Union
ComplexType = Dict[str, Union[int, List[str]]]
adapter = TypeAdapter(ComplexType)
data = {"count": 5, "tags": ["a", "b"]}
validated = adapter.validate_python(data)
schema = adapter.json_schema()
Quick Reference
| Category | Code | Description |
|---|---|---|
| Model Definition | class User(BaseModel): |
Define a Pydantic model |
name: str = Field(...) |
Field with constraints | |
age: Optional[int] = None |
Optional field | |
items: List[str] = [] |
List field with default | |
| Validation | @field_validator("name") |
Field validator decorator |
@model_validator(mode="after") |
Model validator decorator | |
raise ValueError("msg") |
Raise validation error | |
| Constraints | Field(min_length=1) |
String minimum length |
Field(gt=0, le=100) |
Numeric bounds | |
Field(pattern=r"^[a-z]+$") |
Regex pattern | |
constr(to_upper=True) |
Constrained string type | |
| Configuration | model_config = ConfigDict(...) |
Model configuration |
extra="forbid" |
Forbid extra fields | |
validate_assignment=True |
Validate on assignment | |
frozen=True |
Immutable model | |
| Serialisation | model.model_dump() |
Convert to dict |
model.model_dump_json() |
Convert to JSON string | |
exclude={"password"} |
Exclude fields | |
by_alias=True |
Use aliases in output | |
| Parsing | Model.model_validate(data) |
Parse dict to model |
Model.model_validate_json(s) |
Parse JSON to model | |
Model.model_construct(...) |
Create without validation | |
| Settings | class Settings(BaseSettings): |
Settings from environment |
env_file=".env" |
Load from .env file | |
env_prefix="APP_" |
Environment variable prefix | |
| Schema | Model.model_json_schema() |
Generate JSON schema |
TypeAdapter(List[int]) |
Adapter for any type |
Common Issues and Solutions
| Issue | Solution |
|---|---|
ValidationError: Field required |
Provide the required field or add a default value |
ValidationError: Input should be a valid string |
Check input type; use StrictStr to prevent coercion |
ValidationError: Extra inputs not permitted |
Set extra="allow" or extra="ignore" in model_config |
field_validator not called |
Ensure validator is decorated with @classmethod |
Settings not reading env vars |
Check env var names match field names (case-insensitive by default) |
Circular import with models |
Use from __future__ import annotations or string type hints |
Type not JSON serialisable |
Add custom serialiser with @field_serializer |
model_validate from ORM fails |
Set from_attributes=True in model_config |
Mutable default value shared |
Use Field(default_factory=list) instead of [] |
Alias not working in output |
Set populate_by_name=True and use by_alias=True in dump |
computed_field not in output |
Ensure property returns correct type hint |
SecretStr showing in logs |
Use get_secret_value() only when needed; repr is masked |
env_file not found |
Provide absolute path or ensure CWD is correct |
Nested model validation |
Pass dict for nested model; Pydantic handles conversion |
UUID not parsing |
Ensure string is valid UUID format; use UUID4 for v4 only |
Related Topics
The following topics complement Pydantic development and would make excellent additions to your cheatsheet collection:
- FastAPI - Web framework built on Pydantic for automatic request validation, serialisation, and OpenAPI documentation
- SQLAlchemy - ORM integration with Pydantic models using
from_attributesfor database operations - Python Requests - HTTP client for API calls with Pydantic models for response parsing
- Redis - Caching Pydantic models and using them with Redis data structures
- Python Patterns - Design patterns (Factory, Repository) that work well with Pydantic models
- API Design Patterns - RESTful API design using Pydantic for request/response schemas