Skip to content
Codeloom
Python

Pydantic v2: Data Validation and Settings Management

Learn Pydantic v2 for data validation, serialization, and settings management in Python. Covers models, validators, computed fields, and BaseSettings.

·8 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • Define validated data models with Pydantic BaseModel
  • Write custom field and model validators
  • Use computed fields and model serialization
  • Manage application settings with BaseSettings

Prerequisites

  • Python type hints
  • Basic understanding of JSON and APIs

Pydantic v2 is a complete rewrite of Python’s most popular data validation library. The core validation engine is now written in Rust, making it 5-50x faster than v1. It validates data, converts types, generates JSON schemas, and manages application settings. If you build APIs with FastAPI, work with external data, or need runtime type checking, Pydantic is the standard tool.

Getting Started

# pip install pydantic

from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int
    email: str
    active: bool = True

# Valid data
user = User(name="Alice", age=30, email="alice@example.com")
print(user)
# name='Alice' age=30 email='alice@example.com' active=True

# Pydantic coerces compatible types
user2 = User(name="Bob", age="25", email="bob@test.com")
print(user2.age)       # 25 (int, converted from str)
print(type(user2.age)) # <class 'int'>

# Invalid data raises ValidationError
try:
    User(name="Charlie", age="not a number", email="bad")
except Exception as e:
    print(e)

Pydantic validates at model creation time. If the data does not match the type annotations, you get a detailed ValidationError with the field name, value, and error type.

Field Configuration

Use Field to add constraints, defaults, descriptions, and metadata:

from pydantic import BaseModel, Field

class Product(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    price: float = Field(gt=0, description="Price in USD")
    quantity: int = Field(ge=0, default=0)
    sku: str = Field(pattern=r"^[A-Z]{2}-\d{4}$")
    tags: list[str] = Field(default_factory=list, max_length=10)

# Valid
p = Product(name="Widget", price=9.99, sku="AB-1234")
print(p)

# Validation failures
try:
    Product(name="", price=-5, quantity=-1, sku="invalid")
except Exception as e:
    print(e)
    # Multiple errors: name too short, price must be > 0,
    # quantity must be >= 0, sku doesn't match pattern

Common Field Constraints

TypeConstraints
strmin_length, max_length, pattern
int, floatgt, ge, lt, le, multiple_of
list, setmin_length, max_length
All typesdefault, default_factory, alias, exclude, frozen

Nested Models

Pydantic models can contain other models, creating validated nested structures:

from pydantic import BaseModel, Field
from datetime import datetime

class Address(BaseModel):
    street: str
    city: str
    state: str = Field(min_length=2, max_length=2)
    zip_code: str = Field(pattern=r"^\d{5}(-\d{4})?$")

class Company(BaseModel):
    name: str
    address: Address
    founded: int = Field(ge=1800, le=2026)

class Employee(BaseModel):
    name: str
    email: str
    company: Company
    hired_at: datetime

data = {
    "name": "Alice Johnson",
    "email": "alice@acme.com",
    "company": {
        "name": "Acme Corp",
        "address": {
            "street": "123 Main St",
            "city": "Springfield",
            "state": "IL",
            "zip_code": "62701"
        },
        "founded": 1995
    },
    "hired_at": "2024-01-15T09:00:00"
}

employee = Employee(**data)
print(employee.company.address.city)  # Springfield

Nested dictionaries are automatically parsed into the appropriate model classes. Dates and datetimes are parsed from ISO strings.

Custom Validators

Field Validators

Use @field_validator to add custom validation logic to specific fields:

from pydantic import BaseModel, field_validator

class User(BaseModel):
    username: str
    email: str
    age: int

    @field_validator("username")
    @classmethod
    def username_alphanumeric(cls, v: str) -> str:
        if not v.isalnum():
            raise ValueError("Username must be alphanumeric")
        return v.lower()  # normalize to lowercase

    @field_validator("email")
    @classmethod
    def email_valid(cls, v: str) -> str:
        if "@" not in v or "." not in v.split("@")[-1]:
            raise ValueError("Invalid email format")
        return v.lower()

    @field_validator("age")
    @classmethod
    def age_reasonable(cls, v: int) -> int:
        if v < 13:
            raise ValueError("Must be at least 13 years old")
        if v > 120:
            raise ValueError("Age seems unrealistic")
        return v

user = User(username="AliceSmith", email="ALICE@Example.COM", age=30)
print(user.username)  # alicesmith (normalized)
print(user.email)     # alice@example.com (normalized)

Model Validators

Use @model_validator for validation that involves multiple fields:

from pydantic import BaseModel, model_validator

class DateRange(BaseModel):
    start_date: str
    end_date: str
    label: str = ""

    @model_validator(mode="after")
    def check_dates(self) -> "DateRange":
        if self.start_date >= self.end_date:
            raise ValueError("start_date must be before end_date")
        if not self.label:
            self.label = f"{self.start_date} to {self.end_date}"
        return self

class PasswordForm(BaseModel):
    password: str
    confirm_password: str

    @model_validator(mode="after")
    def passwords_match(self) -> "PasswordForm":
        if self.password != self.confirm_password:
            raise ValueError("Passwords do not match")
        return self

# Pre-validation (before Pydantic parses the data)
class FlexibleInput(BaseModel):
    values: list[int]

    @model_validator(mode="before")
    @classmethod
    def coerce_single_to_list(cls, data: dict) -> dict:
        if isinstance(data, dict) and isinstance(data.get("values"), int):
            data["values"] = [data["values"]]
        return data

f = FlexibleInput(values=42)
print(f.values)  # [42]

Computed Fields

@computed_field creates read-only properties that appear in serialization:

from pydantic import BaseModel, computed_field

class Rectangle(BaseModel):
    width: float
    height: float

    @computed_field
    @property
    def area(self) -> float:
        return self.width * self.height

    @computed_field
    @property
    def perimeter(self) -> float:
        return 2 * (self.width + self.height)

    @computed_field
    @property
    def is_square(self) -> bool:
        return self.width == self.height

rect = Rectangle(width=10, height=5)
print(rect.area)       # 50.0
print(rect.perimeter)  # 30.0

# Computed fields appear in dict/JSON output
print(rect.model_dump())
# {'width': 10.0, 'height': 5.0, 'area': 50.0, 'perimeter': 30.0, 'is_square': False}

Serialization

Pydantic provides flexible serialization to dicts and JSON:

from pydantic import BaseModel, Field
from datetime import datetime

class Event(BaseModel):
    name: str
    start: datetime
    tags: list[str] = []
    internal_id: int = Field(exclude=True)  # never serialized

event = Event(
    name="Launch", start="2026-07-07T10:00:00", tags=["important"], internal_id=42
)

# To dictionary
d = event.model_dump()
print(d)
# {'name': 'Launch', 'start': datetime(2026, 7, 7, 10, 0), 'tags': ['important']}

# To JSON string
j = event.model_dump_json()
print(j)
# {"name":"Launch","start":"2026-07-07T10:00:00","tags":["important"]}

# Selective serialization
print(event.model_dump(include={"name", "tags"}))
# {'name': 'Launch', 'tags': ['important']}

print(event.model_dump(exclude_defaults=True))
# {'name': 'Launch', 'start': datetime(2026, 7, 7, 10, 0), 'internal_id': 42}

Custom Serializers

from pydantic import BaseModel, field_serializer
from datetime import datetime

class LogEntry(BaseModel):
    message: str
    timestamp: datetime
    level: str

    @field_serializer("timestamp")
    def serialize_timestamp(self, value: datetime) -> str:
        return value.strftime("%Y-%m-%d %H:%M:%S")

    @field_serializer("level")
    def serialize_level(self, value: str) -> str:
        return value.upper()

entry = LogEntry(message="Server started", timestamp="2026-07-07T10:30:00", level="info")
print(entry.model_dump())
# {'message': 'Server started', 'timestamp': '2026-07-07 10:30:00', 'level': 'INFO'}

Aliases

Use aliases when external data has different field names than your Python code:

from pydantic import BaseModel, Field

class APIResponse(BaseModel):
    user_id: int = Field(alias="userId")
    first_name: str = Field(alias="firstName")
    last_name: str = Field(alias="lastName")
    email_address: str = Field(alias="emailAddress")

# Parse camelCase JSON into snake_case Python
data = {
    "userId": 1,
    "firstName": "Alice",
    "lastName": "Johnson",
    "emailAddress": "alice@test.com"
}
response = APIResponse(**data)
print(response.user_id)     # 1
print(response.first_name)  # Alice

# Serialize back with aliases
print(response.model_dump(by_alias=True))
# {'userId': 1, 'firstName': 'Alice', ...}

Discriminated Unions

Handle polymorphic data with discriminated unions:

from pydantic import BaseModel, Field
from typing import Literal, Annotated

class TextBlock(BaseModel):
    type: Literal["text"]
    content: str

class ImageBlock(BaseModel):
    type: Literal["image"]
    url: str
    alt_text: str = ""

class CodeBlock(BaseModel):
    type: Literal["code"]
    language: str
    source: str

Block = Annotated[
    TextBlock | ImageBlock | CodeBlock,
    Field(discriminator="type")
]

class Document(BaseModel):
    title: str
    blocks: list[Block]

doc = Document(
    title="My Post",
    blocks=[
        {"type": "text", "content": "Hello world"},
        {"type": "code", "language": "python", "source": "print('hi')"},
        {"type": "image", "url": "https://example.com/img.png"},
    ]
)

for block in doc.blocks:
    print(type(block).__name__, "->", block)
# TextBlock -> type='text' content='Hello world'
# CodeBlock -> type='code' language='python' source="print('hi')"
# ImageBlock -> type='image' url='https://example.com/img.png' alt_text=''

The discriminator field tells Pydantic which model to use based on the value of type, making parsing fast and unambiguous.

BaseSettings: Configuration Management

pydantic-settings reads configuration from environment variables, .env files, and other sources:

# pip install pydantic-settings

from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field

class DatabaseSettings(BaseSettings):
    host: str = "localhost"
    port: int = 5432
    name: str = "mydb"
    user: str = "postgres"
    password: str

    model_config = SettingsConfigDict(env_prefix="DB_")

class AppSettings(BaseSettings):
    debug: bool = False
    secret_key: str
    allowed_hosts: list[str] = ["localhost"]
    database: DatabaseSettings = DatabaseSettings()

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
    )

# Reads from environment variables:
# DB_HOST=db.example.com
# DB_PASSWORD=secret123
# SECRET_KEY=my-secret-key
# DEBUG=true
# ALLOWED_HOSTS=["example.com","api.example.com"]

settings = AppSettings()
print(settings.database.host)  # from DB_HOST env var
print(settings.debug)          # from DEBUG env var

Settings Hierarchy

BaseSettings reads values in this priority order (highest to lowest):

  1. Constructor arguments
  2. Environment variables
  3. .env file
  4. Default values
from pydantic_settings import BaseSettings

class Config(BaseSettings):
    api_key: str = "default-key"

# Environment has API_KEY=env-key
config = Config()
print(config.api_key)  # "env-key" (from environment)

config = Config(api_key="explicit-key")
print(config.api_key)  # "explicit-key" (constructor wins)

JSON Schema Generation

Pydantic models generate JSON schemas automatically, which is how FastAPI produces its API documentation:

from pydantic import BaseModel, Field
import json

class CreateUserRequest(BaseModel):
    """Request body for creating a new user."""
    name: str = Field(min_length=1, max_length=50, description="User's full name")
    email: str = Field(description="User's email address")
    age: int = Field(ge=13, le=120, description="User's age")
    role: str = Field(default="user", description="User role")

schema = CreateUserRequest.model_json_schema()
print(json.dumps(schema, indent=2))

This generates a complete JSON Schema with types, constraints, descriptions, and required fields.

Strict Mode

By default, Pydantic coerces compatible types (e.g., "25" to 25). Use strict mode to disable coercion:

from pydantic import BaseModel, ConfigDict

class StrictUser(BaseModel):
    model_config = ConfigDict(strict=True)

    name: str
    age: int

# This works
user = StrictUser(name="Alice", age=30)

# This fails in strict mode (string "30" is not int)
try:
    StrictUser(name="Alice", age="30")
except Exception as e:
    print(e)  # Input should be a valid integer

Wrapping Up

Pydantic v2 covers the full lifecycle of external data in Python applications. Use BaseModel to define validated schemas with type annotations and Field constraints. Add @field_validator and @model_validator for custom logic. Use computed_field for derived values and serializers for output formatting. Handle polymorphic data with discriminated unions. Manage application configuration with BaseSettings and environment variables. The Rust-powered core makes all of this fast enough for high-throughput APIs. Whether you are building a FastAPI service, processing CSV imports, or reading configuration files, Pydantic turns unstructured data into validated, typed Python objects with minimal code.