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.

Key Feature
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_parsing

Two 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.

A mutable default that is actually safe: 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

FeatureDataclassesPydantic BaseModelTypedDict
Primary GoalCode generation (Boilerplate reduction)Data Parsing & ValidationType Hinting for Dicts
Runtime ValidationNone (Types are hints only)Robust (Types are enforced)None (Standard Dict)
Type CoercionNo ("5" stays a str)Yes ("5" → 5)No
PerformanceFast (Native Python class)Slower (Validation overhead)*Fastest (Zero overhead)
JSON SerializationManual (via dataclasses.asdict)Built-in (.model_dump_json())Native (It's just a dict!)
Best Use CaseInternal application state, configurationAPI payloads, External data inputsLarge 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