Python Dataclasses vs Pydantic: When to Use Which
Compare Python dataclasses and Pydantic models side by side. Learn their strengths, performance traits, and how to pick the right tool for your project.
What you'll learn
- ✓What dataclasses and Pydantic each do well
- ✓How validation differs between the two
- ✓Performance characteristics and when they matter
- ✓Choosing the right tool for APIs, config, and domain models
Prerequisites
None — this post is self-contained.
Python dataclasses and Pydantic models both reduce boilerplate for data-holding classes, but they solve different problems. Dataclasses generate __init__, __repr__, and __eq__ so you stop writing repetitive code. Pydantic validates and coerces data at runtime so bad input never reaches your business logic. Understanding when to reach for each one saves you from over-engineering simple cases or under-validating critical ones.
The Basics Side by Side
Dataclass
from dataclasses import dataclass
@dataclass
class User:
name: str
email: str
age: int
active: bool = True
Pydantic Model
from pydantic import BaseModel, EmailStr
class User(BaseModel):
name: str
email: EmailStr
age: int
active: bool = True
At first glance they look almost identical. The differences emerge when you feed them data.
Validation Behavior
Dataclasses do no validation. They trust that you pass the right types:
from dataclasses import dataclass
@dataclass
class User:
name: str
age: int
# No error -- dataclass stores the string "not a number"
user = User(name="Ada", age="not a number")
print(user.age) # "not a number"
print(type(user.age)) # <class 'str'>
Pydantic validates every field and either coerces or rejects bad input:
from pydantic import BaseModel, ValidationError
class User(BaseModel):
name: str
age: int
# Coerces "25" to int 25
user = User(name="Ada", age="25")
print(user.age) # 25
print(type(user.age)) # <class 'int'>
# Rejects invalid input
try:
User(name="Ada", age="not a number")
except ValidationError as e:
print(e)
# 1 validation error for User
# age
# Input should be a valid integer ...
Custom Validators in Pydantic
Pydantic provides field_validator and model_validator for complex rules:
from pydantic import BaseModel, field_validator
class Product(BaseModel):
name: str
price: float
quantity: int
@field_validator("price")
@classmethod
def price_must_be_positive(cls, v: float) -> float:
if v <= 0:
raise ValueError("price must be positive")
return round(v, 2)
@field_validator("quantity")
@classmethod
def quantity_must_be_non_negative(cls, v: int) -> int:
if v < 0:
raise ValueError("quantity cannot be negative")
return v
Adding Validation to Dataclasses
You can add validation to dataclasses via __post_init__, but it is manual:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
quantity: int
def __post_init__(self):
if not isinstance(self.price, (int, float)):
raise TypeError("price must be a number")
if self.price <= 0:
raise ValueError("price must be positive")
if self.quantity < 0:
raise ValueError("quantity cannot be negative")
self.price = round(float(self.price), 2)
This works, but you are reimplementing what Pydantic gives you for free.
Serialization
Pydantic models serialize to dictionaries and JSON natively:
from pydantic import BaseModel
from datetime import datetime
class Event(BaseModel):
name: str
timestamp: datetime
tags: list[str]
event = Event(
name="deploy",
timestamp="2026-07-02T10:00:00",
tags=["production", "v2.1"],
)
print(event.model_dump())
# {'name': 'deploy', 'timestamp': datetime(2026, 7, 2, 10, 0), 'tags': [...]}
print(event.model_dump_json())
# '{"name":"deploy","timestamp":"2026-07-02T10:00:00","tags":["production","v2.1"]}'
Dataclasses have dataclasses.asdict(), but it does not handle complex types like datetime:
from dataclasses import dataclass, asdict
from datetime import datetime
import json
@dataclass
class Event:
name: str
timestamp: datetime
tags: list[str]
event = Event("deploy", datetime(2026, 7, 2, 10, 0), ["production"])
d = asdict(event) # Works
json.dumps(d) # Fails: datetime is not JSON serializable
Performance
Dataclasses are faster to instantiate because they skip validation. If you are creating millions of internal objects where you control the input, this matters:
# Rough comparison (Python 3.12)
# Dataclass: ~0.4 microseconds per instance
# Pydantic: ~2.5 microseconds per instance
Pydantic v2 rewrote its core in Rust, closing the gap significantly compared to v1. For most applications handling web requests or reading config files, the validation overhead is negligible compared to I/O.
Immutability
Both support immutability, with different syntax:
# Dataclass
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
# Pydantic
from pydantic import BaseModel, ConfigDict
class Point(BaseModel):
model_config = ConfigDict(frozen=True)
x: float
y: float
Both raise errors when you try to modify an attribute after creation.
Nested Models
Pydantic handles nested structures and validates them recursively:
from pydantic import BaseModel
class Address(BaseModel):
street: str
city: str
country: str
class Company(BaseModel):
name: str
address: Address
employee_count: int
# Pydantic validates the nested Address too
company = Company(
name="Acme",
address={"street": "123 Main St", "city": "Portland", "country": "US"},
employee_count=50,
)
Dataclasses do not recursively validate or coerce nested structures from dictionaries.
Decision Guide
| Scenario | Use |
|---|---|
| Internal domain objects where you control input | Dataclass |
| API request/response models | Pydantic |
| Configuration files and environment variables | Pydantic (with BaseSettings) |
| High-performance inner loops with millions of objects | Dataclass |
| Data from external sources (files, APIs, user input) | Pydantic |
| Simple value objects (Point, Color, Range) | Dataclass |
| Database ORM-style models | Pydantic (or SQLModel) |
Using Both Together
You do not have to choose one exclusively. A common pattern uses Pydantic at the boundary (API layer, config parsing) and dataclasses for internal domain logic:
from dataclasses import dataclass
from pydantic import BaseModel
# Pydantic at the boundary
class CreateUserRequest(BaseModel):
name: str
email: str
age: int
# Dataclass for internal domain
@dataclass
class User:
id: int
name: str
email: str
age: int
def handle_create_user(request: CreateUserRequest) -> User:
# Pydantic already validated the input
return User(
id=generate_id(),
name=request.name,
email=request.email,
age=request.age,
)
Key Takeaways
Dataclasses are a standard library tool for reducing boilerplate on plain data containers. Pydantic is a validation framework that ensures data correctness at runtime. Use dataclasses when you trust the input. Use Pydantic when you do not. For many projects, using both in their respective roles gives you the best of both worlds.
Related articles
- 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.
- FastAPI FastAPI Pydantic Models: A Deep Dive
Master Pydantic models in FastAPI: type coercion, validators, nested models, settings, and tips for clean request and response schemas.
- Python Advanced Python Dataclasses: Beyond the Basics
Go beyond basic dataclass usage with post-init processing, field factories, inheritance, frozen instances, and custom serialization.
- Python Python Dataclasses: A Complete Guide
Master Python dataclasses — automatic __init__, __repr__, ordering, immutability, default factories, and post-init processing.