108. Dataclasses Module
Clean data classes with the @dataclass decorator
108. Dataclasses Module
Rohan has been using plain dicts for snapshots. They work, but there’s no type checking, no IDE autocomplete, and every snapshot["percent"] typo silently returns None instead of crashing immediately.
He converts to a dataclass:
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class ProcessInfo:
name: str
pid: int
rss_mb: float
@dataclass
class RAMSnapshot:
percent: float
available_mb: float
timestamp: datetime
processes: list = field(default_factory=list) # MUST use field() for mutable defaultsNow snap.percent instead of snap["percent"]. Typos become AttributeError immediately. IDEs show field names with autocomplete.
The mutable default trap — this is the classic Python bug:
# WRONG — all instances share the SAME list:
@dataclass
class RAMSnapshot:
processes: list = [] # raises ValueError in Python 3.7+ — dataclass catches this
# CORRECT — each instance gets its own fresh list:
processes: list = field(default_factory=list)Python evaluates default values once at class definition time, not once per instance. field(default_factory=list) tells dataclass: “call list() to create a new empty list for each new instance.”
snap = RAMSnapshot(percent=82.3, available_mb=1340.0, timestamp=datetime.now())
# processes defaults to [] — its own fresh list, not shared with other snapshots💡 Fun fact: @dataclass was added in Python 3.7 (2018) via PEP 557, written by Eric V. Smith. Before it, developers either wrote tedious boilerplate __init__ methods or used third-party attrs library. Python’s @dataclass was directly inspired by attrs. The asdict() helper converts any dataclass to a plain dict recursively — making it trivial to serialize to JSON.
⚠️ Watch out: A class with type annotations but no @dataclass decorator is just a plain class with no auto-generated __init__. Trying to instantiate it with User("Alice", "[email protected]", 20) raises TypeError: object.__init__() takes exactly one argument. This is one of the most confusing errors beginners encounter — the fix is simply adding @dataclass above the class definition.
🤔 Think about it: @dataclass generates __eq__ by comparing all fields. But it does NOT generate __hash__ by default (because mutable objects shouldn’t be hashable — you can’t put them in a set). What happens if you try to add a @dataclass instance to a Python set? When would you use @dataclass(frozen=True)?
Learning objectives
- Apply @dataclass decorator to auto-generate init, repr, and eq
- Use field(default_factory=list) to avoid shared mutable defaults
- Convert dataclass instances to dicts with asdict()
- Understand the mutable default trap and why it causes bugs
Key concepts
- @dataclass decorator
- field(default_factory=list) — fresh mutable default per instance
- asdict() — convert to dict
- Auto-generated init, repr, eq
- Type annotations as field declarations
Try it
Concept detail
dataclasses — Boilerplate-Free Data Classes
The @dataclass decorator auto-generates __init__, __repr__, and __eq__ from your type-annotated class fields. No more writing the same constructor code.
Basic Usage
from dataclasses import dataclass
@dataclass
class User:
name: str
email: str
age: int = 0 # field with default value
alice = User("Alice", "[email protected]", 20)
print(alice) # User(name='Alice', email='[email protected]', age=20)
alice == User(...) # __eq__ auto-generated from fieldsThe Mutable Default Trap
Python evaluates default values ONCE at class definition, not per instance. All instances would share the same list — a classic, silent bug:
# WRONG — raises ValueError in dataclass (and causes silent bugs in regular classes):
@dataclass
class Record:
scores: list = []
# CORRECT — each instance gets its own fresh list:
from dataclasses import field
@dataclass
class Record:
scores: list = field(default_factory=list)asdict() — Convert to Dictionary
from dataclasses import asdict
u = User("Alice", "[email protected]", 20)
asdict(u) # {"name": "Alice", "email": "[email protected]", "age": 20}| Feature | Auto-generated by @dataclass |
|---|---|
__init__ | Yes — constructor with all fields |
__repr__ | Yes — readable string representation |
__eq__ | Yes — compares all fields by value |
__hash__ | Only if frozen=True |
field(default_factory=...) | Fresh mutable default per instance |
Solution
from dataclasses import dataclass, field, asdict
@dataclass # decorator auto-generates __init__, __repr__, __eq__
class User:
name: str
email: str
age: int
@dataclass # use @dataclass with field(default_factory=list) for mutable defaults
class StudentRecord:
name: str
student_id: str
scores: list = field(default_factory=list) # fresh list per instance
def make_user(name: str, email: str, age: int) -> User:
"""Create a User instance."""
return User(name, email, age)
def add_score(record: StudentRecord, score: int) -> None:
"""Add a score to a student's record."""
record.scores.append(score)
def average_score(record: StudentRecord) -> float:
"""Return the average of a student's scores."""
if not record.scores:
return 0.0
return sum(record.scores) / len(record.scores)
def user_to_dict(name: str, email: str, age: int) -> dict:
"""Create a User and return it as a dict using asdict()."""
u = make_user(name, email, age)
return asdict(u)Tests
def test_user_init_works():
# @dataclass auto-generates __init__ — without it, User(name, email, age) raises TypeError
u = make_user("Alice", "[email protected]", 20)
assert u.name == "Alice"
assert u.email == "[email protected]"
assert u.age == 20
def test_user_repr_works():
u = make_user("Alice", "[email protected]", 20)
r = repr(u)
assert "Alice" in r, f"__repr__ should include name, got: {r}"
def test_user_equality():
u1 = make_user("Bob", "[email protected]", 22)
u2 = make_user("Bob", "[email protected]", 22)
assert u1 == u2, "@dataclass generates __eq__ so equal instances compare as equal"
def test_scores_default_is_empty_list():
r = StudentRecord(name="Alice", student_id="S001")
assert r.scores == [], f"New record should have empty scores, got {r.scores}"
def test_scores_not_shared_between_instances():
# The classic mutable default bug: without field(default_factory=list),
# all instances share the SAME list object
r1 = StudentRecord(name="Alice", student_id="S001")
r2 = StudentRecord(name="Bob", student_id="S002")
add_score(r1, 95)
assert r2.scores == [], (
f"r2.scores should be empty but got {r2.scores} — "
"mutable default causes shared state"
)
def test_add_score_appends():
r = StudentRecord(name="Charlie", student_id="S003")
add_score(r, 88)
add_score(r, 92)
assert r.scores == [88, 92], f"Expected [88, 92], got {r.scores}"
def test_average_score_correct():
r = StudentRecord(name="Diana", student_id="S004")
add_score(r, 80)
add_score(r, 90)
add_score(r, 100)
result = average_score(r)
assert result == 90.0, f"Expected 90.0, got {result}"
def test_user_to_dict():
result = user_to_dict("Eve", "[email protected]", 19)
assert isinstance(result, dict), "user_to_dict should return a dict"
assert result == {"name": "Eve", "email": "[email protected]", "age": 19}