← Home

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 defaults

Now 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 fields

The 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}
FeatureAuto-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}

Resources