Available for day contractsFrom 21st September I have availability for day and half day contracts. Please contact for more information.

Contact →
mikepreston.org

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.

Output FormatsPydantic Validation FlowPassFailPassFailModel FeaturesBaseModelType HintsField ConstraintsValidatorsConfigSerialisationInput DataType CoercionField ValidatorsModel ValidatorsValidationErrorValid Model Instancedict/JSONJSON SchemaORM ObjectsOutput FormatsPydantic Validation FlowPassFailPassFailModel FeaturesBaseModelType HintsField ConstraintsValidatorsConfigSerialisationInput DataType CoercionField ValidatorsModel ValidatorsValidationErrorValid Model Instancedict/JSONJSON SchemaORM Objects

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, wrap for different validation stages
  • ValidationError: Exception containing all validation failures
Validation OrderRaw Inputbefore validatorsType Coercionafter validatorsmodel_validatorValid InstanceValidation OrderRaw Inputbefore validatorsType Coercionafter validatorsmodel_validatorValid Instance

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:

  1. FastAPI - Web framework built on Pydantic for automatic request validation, serialisation, and OpenAPI documentation
  2. SQLAlchemy - ORM integration with Pydantic models using from_attributes for database operations
  3. Python Requests - HTTP client for API calls with Pydantic models for response parsing
  4. Redis - Caching Pydantic models and using them with Redis data structures
  5. Python Patterns - Design patterns (Factory, Repository) that work well with Pydantic models
  6. API Design Patterns - RESTful API design using Pydantic for request/response schemas