Dataclasses & Pydantic & TypedDict
Modern data structures: boilerplate killers and runtime validators.
Introduction
Managing classes primarily used to store data has historically involved a lot of boilerplate: writing __init__, __repr__, and __eq__ methods by hand. Dataclasses (Python 3.7+) and Pydantic solve this, but they serve different purposes. One optimizes for speed and syntax, the other for validation and correctness.
The Boilerplate Killer
The Old Way (Standard Class)
class Product:
def __init__(self, name, price, stock=0):
self.name = name
self.price = price
self.stock = stock
def __repr__(self):
return f"Product(name='{self.name}', ...)"
def __eq__(self, other):
if not isinstance(other, Product):
return NotImplemented
return (self.name == other.name and
self.price == other.price)
p = Product("Widget", 9.99, stock=5)
print(p)
print(p == Product("Widget", 9.99))Expected Output:
Product(name='Widget', ...) True
The New Way (Dataclass)
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
stock: int = 0
# You get for free:
# __init__, __repr__, __eq__, __hash__ (optional)
p = Product("Widget", 9.99, stock=5)
print(p)
print(p == Product("Widget", 9.99, stock=5))
# Types are hints, not rules, nothing
# here is checked at runtime...
print(Product(name=42, price="free"))Expected Output:
Product(name='Widget', price=9.99, stock=5) True Product(name=42, price='free', stock=0)
Native Dataclasses
Built into the standard library, dataclasses are lightweight code generators. They generate special methods based on type hints, but they DO NOT validate data at runtime.
Immutability with frozen=True
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
host: str
port: int
c = Config("localhost", 8080)
try:
c.host = "127.0.0.1" # Raises FrozenInstanceError!
except Exception as e:
print(f"{type(e).__name__}: {e}")
# Also makes the object hashable (usable as dict keys)
print({Config("localhost", 8080): "primary"})Expected Output:
FrozenInstanceError: cannot assign to field 'host'
{Config(host='localhost', port=8080): 'primary'}Pros
- Built-in (Standard Library)
- Extremely lightweight (low memory overhead)
- Great IDE support
- Follows standard Python OOP semantics
Cons
- No runtime validation ( types are documentation only )
- JSON serialization requires custom logic (e.g.
asdict) - Limited configuration options compared to Pydantic
Pydantic: Dataclasses on Steroids
Pydantic's BaseModel looks like a dataclass but behaves like a parser. It guarantees that the types of the output model match the types you defined, coercing data where possible.
from pydantic import BaseModel, ValidationError
class User(BaseModel):
id: int
name: str = "Anonymous"
tags: list[str] = []
# 1. Happy path
print(User(id=123, name="Alice"))
# 2. Pydantic validates and converts types
print(User(id="123", tags=["admin"]))
# 3. What it cannot convert, it rejects
try:
User(id="not-a-number")
except ValidationError as e:
print(e)Expected Output:
id=123 name='Alice' tags=[]
id=123 name='Anonymous' tags=['admin']
1 validation error for User
id
Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='not-a-number', input_type=str]
For further information visit https://errors.pydantic.dev/2.13/v/int_parsingTwo details worth noting. print() on a model uses Pydantic's __str__, which omits the class name, so it reads id=123 name='Alice' rather than the repr form. And the error text above is Pydantic v2's: v1 phrased the same failure as value is not a valid integer, so error messages copied from older tutorials will not match what you see.
tags: list[str] = [] would be a bug in a plain dataclass, which rejects it with ValueError: mutable default. Pydantic deep-copies field defaults per instance, so each User gets its own list and appending to one does not touch the others.When to avoid dataclasses or Pydantic in favor of TypedDict
Sometimes you just want type hints for a regular dictionary without the overhead of a class or runtime validation. In these cases, use TypedDict.
Pattern: Pure Data Transfer
If you are receiving a massive JSON payload and just need to pass it to another function without inspecting or validating every field, creating thousands of Pydantic models might be too slow.
from typing import TypedDict
class UserDict(TypedDict):
id: int
name: str
# At runtime, this is just a plain dictionary! Zero overhead.
data: UserDict = {"id": 1, "name": "Alice"}
print(type(data), data)
# Nothing checks the annotation, so a type checker is your only safety net
wrong: UserDict = {"id": "not-an-int", "name": 5}
print(wrong)Expected Output:
<class 'dict'> {'id': 1, 'name': 'Alice'}
{'id': 'not-an-int', 'name': 5}Head-to-Head Comparison
| Feature | Dataclasses | Pydantic BaseModel | TypedDict |
|---|---|---|---|
| Primary Goal | Code generation (Boilerplate reduction) | Data Parsing & Validation | Type Hinting for Dicts |
| Runtime Validation | None (Types are hints only) | Robust (Types are enforced) | None (Standard Dict) |
| Type Coercion | No ("5" stays a str) | Yes ("5" → 5) | No |
| Performance | Fast (Native Python class) | Slower (Validation overhead)* | Fastest (Zero overhead) |
| JSON Serialization | Manual (via dataclasses.asdict) | Built-in (.model_dump_json()) | Native (It's just a dict!) |
| Best Use Case | Internal application state, configuration | API payloads, External data inputs | Large JSON blobs, High performance |
* Note: Pydantic V2 (written in Rust) is significantly faster than V1, narrowing the gap.
Best Practices
1. The Sandwich Pattern
Use Pydantic at the edges (API requests, database reads) to validate inputs. Use Dataclasses internally for business logic to keep it fast and dependency-free.
2. Freeze It
Prefer @dataclass(frozen=True) (or Pydantic's frozen=True). Immutability prevents a whole class of bugs and makes objects hashable.
3. Keep it Simple
Don't add methods to dataclasses if they don't operate on the data. Separating data containers from logic services is often cleaner (Anemic Domain Model).
Key Takeaways
- Dataclasses are for efficient code generation (methods) inside standard classes.
- Pydantic is for parsing and validating messy external data into structured models.
- TypedDict is for typing plain dictionaries when performance is critical.
- Validation is expensive; pay the cost at the system boundaries, not in every internal function loop.
What's Next?
You've learned modern Python data tools! Now let's explore data manipulation at scale with powerful libraries for processing large datasets.
- Pandas - Industry-standard data manipulation and analysis
- Dask & Vaex - Handle datasets larger than memory
- Polars - Lightning-fast data processing with Rust