Skip to content
Codeloom
Python

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.

·5 min read · By Codeloom
Intermediate 11 min read

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

ScenarioUse
Internal domain objects where you control inputDataclass
API request/response modelsPydantic
Configuration files and environment variablesPydantic (with BaseSettings)
High-performance inner loops with millions of objectsDataclass
Data from external sources (files, APIs, user input)Pydantic
Simple value objects (Point, Color, Range)Dataclass
Database ORM-style modelsPydantic (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.