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
"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)
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)) #
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 V1 | Pydantic V2 |
|---|---|
@validator('field') | @field_validator('field') |
@root_validator | @model_validator(mode='after') |
class Config: orm_mode = True | model_config = ConfigDict(from_attributes=True) |
class Config: allow_population_by_field_name = True | model_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'inmodel_configto silently drop extra fields,extra='forbid'to raise aValidationError, or the defaultextra='ignore'. Useextra='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 useBeforeValidator. - Does Pydantic V2 support custom JSON serializers?
- Yes. Use
@field_serializer('field_name')to customize how individual fields are serialized. Use@model_serializerto completely control the model's serialized representation. Both work withmodel_dump()andmodel_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.