Introduction
If you've ever written a Python class that's mostly just storing data, you know the drill: __init__, maybe __repr__, sometimes __eq__... It's repetitive, error-prone, and boring.
Python's dataclasses module (introduced in Python 3.7) eliminates all that boilerplate. By the end of this guide, you'll write cleaner, more maintainable data classes with half the code — and zero boilerplate.
What's Wrong with Regular Classes?
Let's look at a typical data-holding class:
class Person:
def __init__(self, name: str, age: int, email: str):
self.name = name
self.age = age
self.email = email
def __repr__(self):
return f"Person(name={self.name!r}, age={self.age!r}, email={self.email!r})"
def __eq__(self, other):
if not isinstance(other, Person):
return NotImplemented
return (self.name, self.age, self.email) == (other.name, other.age, other.email)
That's 14 lines for a simple data container. And you'd need to manually update __repr__ and __eq__ every time you add or remove a field.
Enter Dataclasses
Here's the same class with @dataclass:
from dataclasses import dataclass
@dataclass
class Person:
name: str
age: int
email: str
4 lines. You get __init__, __repr__, __eq__, and __hash__ (when frozen) for free. Fields are declared with type annotations — clean, explicit, and self-documenting.
Let's verify:
p1 = Person("Alice", 30, "alice@example.com")
p2 = Person("Alice", 30, "alice@example.com")
print(p1) # Person(name='Alice', age=30, email='alice@example.com')
print(p1 == p2) # True
Default Values and Field Configuration
Dataclasses support default values naturally:
from dataclasses import dataclass, field
from typing import List
@dataclass
class Config:
host: str = "localhost"
port: int = 8080
debug: bool = False
tags: List[str] = field(default_factory=list) # mutable default!
Key insight: For mutable defaults (lists, dicts), you must use default_factory instead of = []. Without it, all instances would share the same list object — a classic Python gotcha that dataclasses help you avoid.
The field() function gives you fine-grained control:
| Parameter | Purpose |
|---|---|
default |
Simple default value |
default_factory |
Callable for mutable defaults |
init |
Include in __init__? (default: True) |
repr |
Include in __repr__? (default: True) |
compare |
Include in equality checks? (default: True) |
hash |
Include in __hash__? (default: None) |
metadata |
Dict for extra info (frameworks use this) |
@dataclass
class User:
username: str
password: str = field(repr=False) # never leak in repr
id: int = field(init=False) # auto-generated, not in __init__
Frozen (Immutable) Dataclasses
For value objects that shouldn't change after creation:
@dataclass(frozen=True)
class Point:
x: float
y: float
Any attempt to modify a field raises FrozenInstanceError. This is perfect for coordinates, configuration objects, or any data that should remain constant throughout its lifetime.
Inheritance with Dataclasses
Dataclasses support inheritance naturally:
@dataclass
class Base:
x: int = 0
y: int = 0
@dataclass
class ColorPoint(Base):
color: str = "black"
Fields are ordered: parent fields come first, then child fields. This matters because __init__ parameter order follows field declaration order.
Advanced: __post_init__
Need validation or derived fields after initialization? Use __post_init__:
from dataclasses import dataclass
from typing import Optional
import re
@dataclass
class EmailContact:
name: str
email: str
def __post_init__(self):
if not re.match(r'^[^@]+@[^@]+\.[^@]+$', self.email):
raise ValueError(f"Invalid email: {self.email}")
# Validates on creation:
# contact = EmailContact("Bob", "not-an-email") # ValueError!
This hook runs right after the auto-generated __init__ finishes — perfect for data validation, normalization, or computing derived fields.
Slots: Memory Optimization
Python 3.10+ lets you add __slots__ to dataclasses, reducing memory usage significantly:
@dataclass(slots=True)
class SlimPoint:
x: float
y: float
Each regular Python object has a __dict__ that consumes ~40% more memory. Slotted dataclasses store attributes in a fixed array instead. For thousands of objects (e.g., parsed CSV rows or API responses), the savings add up fast.
Real-World Example: Configuration Manager
Here's how I use dataclasses in a real project:
from dataclasses import dataclass, field
from pathlib import Path
import json
from typing import Optional
@dataclass
class DatabaseConfig:
host: str = "localhost"
port: int = 5432
name: str = "appdb"
user: str = "app"
password: str = field(repr=False) # hide from logs
@property
def connection_string(self) -> str:
return f"postgresql://{self.user}:***@{self.host}:{self.port}/{self.name}"
@dataclass
class AppConfig:
debug: bool = False
database: DatabaseConfig = field(default_factory=DatabaseConfig)
allowed_origins: list = field(default_factory=list)
max_upload_mb: int = 10
@classmethod
def from_json(cls, path: Path) -> "AppConfig":
with open(path) as f:
data = json.load(f)
return cls(**data)
# Usage
config = AppConfig.from_json(Path("config.json"))
print(config.database.connection_string) # only for debugging, never log this!
Notice how nested dataclasses compose naturally — the DatabaseConfig is itself a dataclass, giving you structured, typed configuration at every level.
Dataclasses vs Namedtuples vs TypedDict
| Feature | Dataclass | NamedTuple | TypedDict |
|---|---|---|---|
| Mutable | ✅ (or frozen) | ❌ | ✅ |
| Type hints | ✅ | ✅ | ✅ |
| Methods | ✅ | ✅ | ❌ |
| Inheritance | ✅ | ✅ | ✅ |
| Default factory | ✅ | ❌ | ❌ |
__post_init__ |
✅ | ❌ | ❌ |
| Performance | Good | Best (C-based) | Dict-based |
When to use what:
- Dataclasses: General-purpose data containers with validation, methods, or mutable state
- Namedtuples: Immutable, lightweight structures where performance matters
- TypedDict: When you need dict compatibility (e.g., JSON serialization) with type hints
Summary
Python dataclasses make you more productive by eliminating boilerplate while keeping your code explicit and type-safe. Here's what we covered:
-
Zero boilerplate:
__init__,__repr__,__eq__generated automatically -
Mutable defaults handled safely with
default_factory -
Immutability on demand with
frozen=True -
Validation via
__post_init__ -
Memory optimization with
slots=True(Python 3.10+) - Real patterns like nested config objects
If you're still writing manual __init__ methods for data-holding classes, give dataclasses a try. Your fingers — and your code reviewers — will thank you.
Have you used dataclasses in a project? What's your favorite feature? Let me know in the comments below!
Top comments (0)