Pydantic V2: Data Validation in Python (2026)

Published June 6, 2026 • 13 min read

Pydantic V2 was rewritten in Rust, delivering 5–50× faster validation than V1. Beyond performance, V2 brought cleaner validator syntax, first-class support for computed fields, a new model_config dict, and a tighter integration story with FastAPI and SQLAlchemy. This guide covers everything you need to use Pydantic V2 effectively in production — from basic models to discriminated unions and environment-based settings.

BaseModel Basics

pip install pydantic[email]  # includes email-validator
from pydantic import BaseModel, EmailStr
from datetime import datetime
from typing import Optional

class User(BaseModel):
    id        : int
    username  : str
    email     : EmailStr
    is_active : bool = True
    created_at: datetime = datetime.utcnow()
    bio       : Optional[str] = None

# Parse from dict — coercion happens automatically
user = User(id="42", username="alice", email="alice@example.com")
print(user.id)        # 42 (int, not "42")
print(user.is_active) # True

# Validation error on bad input
try:
    User(id="not-an-int", username="bob", email="bad-email")
except ValueError as e:
    print(e)  # 2 validation errors: id, email
Note: Pydantic V2 performs strict coercion by default: "42" becomes 42 for an int field. Enable strict=True in model_config to disable coercion and require exact types.

Field() Constraints and Aliases

Field() attaches constraints and metadata to individual fields:

from pydantic import BaseModel, Field
from typing import Annotated

class Product(BaseModel):
    name        : str               = Field(min_length=2, max_length=100)
    price       : float             = Field(gt=0, le=1_000_000, description="USD price")
    sku         : str               = Field(pattern=r'^[A-Z]{2}\d{6}$')
    quantity    : int               = Field(default=0, ge=0)
    # Alias: JSON uses snake_case but Python uses camelCase client
    external_id : str               = Field(alias="externalId")
    # Annotated syntax — useful for reuse across models
    tags        : list[str]         = Field(default_factory=list, max_length=10)

class OrderItem(BaseModel):
    product_id : int
    # Annotated lets you define a reusable constrained type
    quantity   : Annotated[int, Field(gt=0, le=999)]

# Parse with alias
p = Product(name="Widget", price=9.99, sku="AB123456", externalId="ext-001")
print(p.external_id)  # "ext-001"

# model_validate accepts both alias and field name (with populate_by_name=True)

Field Validators and Model Validators

V2 replaced the V1 @validator decorator with two cleaner decorators:

from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Self
import re

class RegistrationForm(BaseModel):
    username  : str          = Field(min_length=3, max_length=30)
    email     : str
    password  : str          = Field(min_length=8)
    confirm_pw: str
    age       : int          = Field(ge=13)

    # Single-field validator — runs after type coercion
    @field_validator('username')
    @classmethod
    def username_alphanumeric(cls, v: str) -> str:
        if not re.match(r'^[a-zA-Z0-9_]+$', v):
            raise ValueError('Username must be alphanumeric (underscores allowed)')
        return v.lower()   # normalize to lowercase

    @field_validator('email')
    @classmethod
    def email_must_be_corporate(cls, v: str) -> str:
        if v.endswith('@example.com'):
            raise ValueError('Personal @example.com addresses are not allowed')
        return v.lower()

    # Cross-field validator — runs after all field validators
    @model_validator(mode='after')
    def passwords_match(self) -> Self:
        if self.password != self.confirm_pw:
            raise ValueError('Passwords do not match')
        # Clear confirm_pw from the model
        return self

# Usage
try:
    form = RegistrationForm(
        username="Alice_99",
        email="alice@techoral.com",
        password="s3cr3tPass",
        confirm_pw="s3cr3tPass",
        age=25,
    )
    print(form.username)  # "alice_99"
except ValueError as e:
    print(e)
Pro Tip: Use mode='before' on @field_validator when you need to transform the raw input before type coercion — for example, stripping whitespace from strings or converting "true"/"false" strings to booleans before Pydantic's bool parser runs.

Computed Fields

@computed_field exposes a property as part of the model's schema and serialized output:

from pydantic import BaseModel, computed_field

class Circle(BaseModel):
    radius: float

    @computed_field
    @property
    def area(self) -> float:
        import math
        return round(math.pi * self.radius ** 2, 4)

    @computed_field
    @property
    def circumference(self) -> float:
        import math
        return round(2 * math.pi * self.radius, 4)

c = Circle(radius=5.0)
print(c.area)           # 78.5398
print(c.model_dump())   # {'radius': 5.0, 'area': 78.5398, 'circumference': 31.4159}

model_config

V2 replaces the inner class Config with a model_config class variable of type ConfigDict:

from pydantic import BaseModel, ConfigDict

class Article(BaseModel):
    model_config = ConfigDict(
        # Read ORM objects (replaces V1's orm_mode=True)
        from_attributes=True,
        # Allow both alias and field name on input
        populate_by_name=True,
        # Strip extra fields instead of raising an error
        extra='ignore',
        # Freeze the model — makes it hashable and immutable
        frozen=False,
        # Use enum values, not enum objects, in serialization
        use_enum_values=True,
        # Validate default values on construction
        validate_default=True,
    )

    title   : str
    content : str

# Read a SQLAlchemy ORM object directly (from_attributes=True)
# article = Article.model_validate(db_article_orm_obj)

pydantic-settings for Environment Config

Replace os.environ.get() scattered across your codebase with a typed settings model:

# config.py
from pydantic import Field, PostgresDsn, RedisDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file='.env',
        env_file_encoding='utf-8',
        case_sensitive=False,
    )

    # Required — will raise if not set in env
    database_url  : PostgresDsn
    secret_key    : str
    redis_url     : RedisDsn

    # Optional with defaults
    debug         : bool  = False
    allowed_hosts : list[str] = ['localhost']
    page_size     : int   = Field(default=20, ge=1, le=100)
    jwt_algorithm : str   = 'HS256'
    log_level     : str   = 'INFO'

@lru_cache
def get_settings() -> Settings:
    return Settings()

# Usage in FastAPI
from fastapi import Depends
def get_db(settings: Settings = Depends(get_settings)):
    ...
# .env file
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/blogdb
SECRET_KEY=supersecretkey123
REDIS_URL=redis://localhost:6379/0
DEBUG=true

Nested Models

from pydantic import BaseModel
from typing import List, Optional

class Address(BaseModel):
    street : str
    city   : str
    country: str = 'US'
    zip    : Optional[str] = None

class ContactInfo(BaseModel):
    phone   : Optional[str] = None
    address : Optional[Address] = None

class Employee(BaseModel):
    id      : int
    name    : str
    contact : ContactInfo
    reports : List['Employee'] = []  # recursive model

Employee.model_rebuild()  # required for forward references

# Parse nested dict
emp = Employee(
    id=1,
    name="Alice",
    contact={
        "phone": "+1-555-0100",
        "address": {"street": "123 Main St", "city": "Portland"},
    },
)
print(emp.contact.address.city)  # "Portland"
print(type(emp.contact))         # 

Discriminated Unions

Discriminated unions let Pydantic pick the right model variant based on a literal field — without ambiguity:

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

class EmailNotification(BaseModel):
    type   : Literal['email']
    to     : str
    subject: str
    body   : str

class SMSNotification(BaseModel):
    type   : Literal['sms']
    to     : str
    message: str

class PushNotification(BaseModel):
    type   : Literal['push']
    device_token: str
    title  : str
    body   : str

Notification = Annotated[
    Union[EmailNotification, SMSNotification, PushNotification],
    Field(discriminator='type')
]

class NotificationRequest(BaseModel):
    notification: Notification

req = NotificationRequest(notification={'type': 'sms', 'to': '+15550001234', 'message': 'Hello!'})
print(type(req.notification))  # 
Pro Tip: Discriminated unions are dramatically faster than regular Union types because Pydantic looks up the correct model class directly from the discriminator field without trying each model in sequence.

Serialization with model_dump and model_dump_json

from pydantic import BaseModel, Field
from datetime import datetime

class Post(BaseModel):
    id        : int
    title     : str
    created_at: datetime
    internal_note: str = Field(exclude=True)  # never serialized

post = Post(id=1, title="Hello", created_at=datetime.utcnow(), internal_note="draft")

# Python dict
d = post.model_dump()
# {'id': 1, 'title': 'Hello', 'created_at': datetime(...)}

# Exclude None values
post.model_dump(exclude_none=True)

# Include only specific fields
post.model_dump(include={'id', 'title'})

# JSON string (uses Pydantic's fast Rust serializer)
json_str = post.model_dump_json()
# '{"id":1,"title":"Hello","created_at":"2026-06-06T10:00:00"}'

# Round-trip: parse from JSON
post2 = Post.model_validate_json(json_str)  # note: need to add internal_note

V1 to V2 Migration Cheatsheet

Pydantic V1Pydantic V2
@validator('field')@field_validator('field')
@root_validator@model_validator(mode='after')
class Config: orm_mode = Truemodel_config = ConfigDict(from_attributes=True)
class Config: allow_population_by_field_name = Truemodel_config = ConfigDict(populate_by_name=True)
.dict().model_dump()
.json().model_dump_json()
Model.parse_obj(d)Model.model_validate(d)
Model.parse_raw(s)Model.model_validate_json(s)
Model.schema()Model.model_json_schema()
Field(regex=...)Field(pattern=...)

Frequently Asked Questions

Can I use Pydantic V2 with FastAPI?
Yes — FastAPI has required Pydantic V2 since version 0.100.0. All request/response models, query parameters, and dependency injection models automatically use V2 validation. The performance improvement from V2 is most noticeable on high-traffic endpoints with complex nested models.
How do I handle unknown fields in input data?
Set extra='ignore' in model_config to silently drop extra fields, extra='forbid' to raise a ValidationError, or the default extra='ignore'. Use extra='allow' only when you explicitly need to pass arbitrary data through (rare).
How do I share validation logic across multiple models?
Create a base model with shared validators and inherit from it. You can also define reusable annotated types with Annotated[str, Field(...)] and use them across models. For cross-cutting concerns like "always strip whitespace," create a custom type with a __get_validators__ method or use BeforeValidator.
Does Pydantic V2 support custom JSON serializers?
Yes. Use @field_serializer('field_name') to customize how individual fields are serialized. Use @model_serializer to completely control the model's serialized representation. Both work with model_dump() and model_dump_json().
What is the performance difference between V1 and V2?
Pydantic's own benchmarks show 5–17× faster validation for typical models and up to 50× for simple string/int models. The Rust core (pydantic-core) handles all the hot-path validation logic. In practice, for a FastAPI app processing 1,000 req/s with complex bodies, the validation overhead drops from ~2ms to ~0.1ms per request.