Python Dataclasses

A dataclass is a regular Python class that gets a set of boilerplate methods — __init__, __repr__, __eq__, and optionally comparison and immutability behavior — generated automatically from a list of type-annotated fields. Instead of hand-writing constructors and equality checks for simple data-holding classes, you describe the fields once and let the @dataclass decorator write the repetitive code for you. This matters because most classes in real programs exist just to bundle related values together, and dataclasses make that pattern fast, readable, and far less error-prone.

Overview / How Dataclasses Work

The dataclasses module (part of the standard library since Python 3.7) provides a class decorator, @dataclass, and a helper function, field(). When you decorate a class, Python inspects the class body for annotated class attributes (name and a type, e.g. x: int) and treats each one as a field. At decoration time — not at instance-creation time — the decorator generates new methods and attaches them directly to the class, exactly as if you had typed them yourself.

By default, @dataclass generates:

  • __init__ — assigns each field to self in declaration order, using any defaults you specified.
  • __repr__ — produces a readable string like ClassName(field1=value1, field2=value2).
  • __eq__ — compares two instances of the same class field-by-field (as a tuple comparison), not by identity.

Crucially, a dataclass is still a completely ordinary Python class under the hood. It has a normal __dict__ (unless you opt into slots), you can add your own methods, inherit from it, and use isinstance() on it. The decorator only pre-writes a few dunder methods based on the annotations it finds; it does not change how attribute lookup, inheritance, or the object model work. This is different from, say, a namedtuple, which builds an entirely new tuple-based type — a dataclass instance is a regular mutable object by default, with real instance attributes.

Type annotations on fields (x: float) are not enforced at runtime. Python does not check that you actually pass a float; the annotation only documents intent and is used by the decorator to detect which class attributes should become fields. If you need runtime validation, you write it yourself, typically inside a special method called __post_init__, which the generated __init__ calls automatically after assigning all fields.

Syntax

from dataclasses import dataclass, field

@dataclass(init=True, repr=True, eq=True, order=False, frozen=False)
class ClassName:
    plain_field: SomeType
    field_with_default: SomeType = default_value
    field_with_factory: list = field(default_factory=list)
Part Meaning
@dataclass(...) Decorator applied to the class; keyword arguments configure which methods get generated.
init Generate __init__ (default True).
repr Generate __repr__ (default True).
eq Generate __eq__ comparing all fields (default True).
order Generate <, <=, >, >= based on field order (default False).
frozen Make instances immutable after __init__ (default False).
field_name: type A required field with no default; must be passed to the constructor.
field_name: type = value A field with a simple, immutable default (numbers, strings, None, tuples).
field(default_factory=fn) Required for mutable defaults (list, dict, set, or any custom object); fn is called with no arguments once per instance.

Examples

Example 1: A basic dataclass

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

p1 = Point(3.0, 4.0)
p2 = Point(3.0, 4.0)
print(p1)
print(p1 == p2)
print(p1.x, p1.y)
Output:
Point(x=3.0, y=4.0)
True
3.0 4.0

Just annotating x and y is enough for @dataclass to generate a two-argument constructor, a readable __repr__, and value-based equality. Notice p1 == p2 is True even though they are different objects in memory — a plain class without @dataclass would compare by identity and print False here.

Example 2: Defaults, default_factory, and validation

from dataclasses import dataclass, field

@dataclass
class InventoryItem:
    name: str
    unit_price: float
    quantity: int = 0
    tags: list[str] = field(default_factory=list)

    def __post_init__(self) -> None:
        if self.unit_price < 0:
            raise ValueError("unit_price cannot be negative")

    def total_cost(self) -> float:
        return self.unit_price * self.quantity

item = InventoryItem("widget", 2.5, 4)
item.tags.append("sale")
print(item)
print(f"Total cost: {item.total_cost()}")

item2 = InventoryItem("gadget", 9.99)
print(item2.tags)
Output:
InventoryItem(name='widget', unit_price=2.5, quantity=4, tags=['sale'])
Total cost: 10.0
[]

This example mixes a required field (name), a field with a plain default (quantity), and a mutable field created via default_factory (tags). Because list is called fresh for every new instance, appending to item.tags does not affect item2.tags, which stays empty. __post_init__ runs automatically right after the generated __init__ finishes assigning fields, so it’s the natural place to validate input or compute derived state. total_cost() is an ordinary method you add yourself — dataclasses don’t stop you from writing normal class behavior alongside the generated code.

Example 3: Frozen (immutable) and ordered dataclasses

from dataclasses import dataclass

@dataclass(frozen=True, order=True)
class Card:
    rank: int
    suit: str

cards = [Card(10, "hearts"), Card(2, "clubs"), Card(7, "spades")]
print(sorted(cards))

low_card = cards[1]
print(low_card)

try:
    low_card.rank = 99
except Exception as e:
    print(f"{type(e).__name__}: {e}")
Output:
[Card(rank=2, suit='clubs'), Card(rank=7, suit='spades'), Card(rank=10, suit='hearts')]
Card(rank=2, suit='clubs')
FrozenInstanceError: cannot assign to field 'rank'

order=True generates __lt__, __le__, __gt__, and __ge__ that compare instances as tuples of their fields in declaration order, so sorted() works without extra code. frozen=True overrides __setattr__ and __delattr__ to raise dataclasses.FrozenInstanceError the moment code tries to mutate a field after construction, which is exactly what happens when we try low_card.rank = 99.

Under the Hood

When Python executes @dataclass on a class, three things happen in order. First, it walks __annotations__ on the class to collect field names, types, and any default values or field() specifications, building an internal list of fields. Second, based on the decorator’s keyword arguments (init, repr, eq, order, frozen, and a few others), it compiles source strings for each requested method — yes, literally generates Python source text for something like def __init__(self, x, y): self.x = x; self.y = y — and executes that text with exec() to create real function objects, which it attaches to the class. Third, if __post_init__ is defined, the generated __init__ calls it as its last step. None of this touches instance creation itself: once the class is built, calling Point(3.0, 4.0) works exactly like calling any other class, invoking the now-attached __init__. This is why dataclass instances have no special runtime overhead compared to a hand-written class — the “magic” is all front-loaded into class creation, not into every instantiation.

Common Mistakes

Mistake 1: Using a mutable literal as a default value

Unlike a plain class or function, @dataclass actively refuses to let a mutable default sneak in, because every instance would otherwise share the exact same list:

from dataclasses import dataclass

@dataclass
class InventoryItem:
    name: str
    tags: list[str] = []

Defining this class raises ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory immediately, before you ever create an instance. The fix is to wrap the mutable value in field(default_factory=...), which calls the factory once per instance instead of sharing one object:

from dataclasses import dataclass, field

@dataclass
class InventoryItem:
    name: str
    tags: list[str] = field(default_factory=list)

item_a = InventoryItem("widget")
item_b = InventoryItem("gadget")
item_a.tags.append("clearance")
print(item_a.tags)
print(item_b.tags)
Output:
['clearance']
[]

Mistake 2: Assuming dataclass instances are hashable by default

Putting a plain dataclass instance into a set or using it as a dict key fails by default:

from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

points = {Point(0, 0), Point(1, 1)}

This raises TypeError: unhashable type: 'Point'. The reason is that generating __eq__ (the default, eq=True) causes Python to set __hash__ to None, since mutable objects that compare by value are not safe to hash — their hash could change after being inserted into a set. Adding frozen=True tells the decorator the instance can never change, so it’s safe to generate a real __hash__ based on the same fields used for equality:

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: int
    y: int

points = {Point(0, 0), Point(1, 1), Point(0, 0)}
print(len(points))
print(Point(1, 1) in points)
Output:
2
True

Best Practices

  • Always use field(default_factory=...) for list, dict, set, or other mutable defaults — never rely on catching the ValueError, just write it correctly from the start.
  • Reach for frozen=True whenever a value object should be immutable (coordinates, configuration, money amounts); it also unlocks hashing for free.
  • Put validation and derived-value setup in __post_init__ rather than overriding __init__, so you keep the generated constructor’s ordering and defaults.
  • Add type hints on every field — they are required for @dataclass to recognize an attribute as a field, and they make the generated __init__ self-documenting.
  • On Python 3.10+, pass slots=True to @dataclass for memory- and attribute-lookup-sensitive classes; it removes the per-instance __dict__, at the cost of not being able to add attributes dynamically.
  • Don’t use a dataclass when you truly need a plain tuple’s structural behavior (unpacking without names) — a namedtuple or plain tuple may fit better there.
  • Use dataclasses.asdict() or dataclasses.astuple() sparingly — they deep-copy nested dataclasses and can be surprisingly expensive on large structures.

Practice Exercises

  • Write a frozen=True, order=True dataclass named Version with fields major, minor, and patch (all int). Create a list of several Version instances and print them sorted from oldest to newest.
  • Write a dataclass Playlist with a required name: str field and a songs: list[str] field using default_factory=list. Add a method add_song(self, title: str) that appends to songs. Create two separate playlists and confirm that adding a song to one does not affect the other.
  • Write a dataclass Temperature with a single field celsius: float and a __post_init__ that raises ValueError if celsius is below -273.15 (absolute zero). Add a property or method to_fahrenheit() that returns the Fahrenheit equivalent. Test it with a valid value and confirm the error is raised for an invalid one.

Summary

  • @dataclass generates __init__, __repr__, and __eq__ from a class’s annotated fields, saving you from writing repetitive boilerplate.
  • Type annotations mark which attributes become fields but are not enforced at runtime — validate manually in __post_init__ if needed.
  • Mutable defaults must use field(default_factory=...); a bare mutable literal default raises ValueError at class-definition time.
  • frozen=True makes instances immutable and enables hashing; without it, dataclasses with default eq=True are unhashable.
  • order=True adds comparison operators based on field order, making instances directly sortable.
  • A dataclass is still an ordinary class — you can add methods, inherit from it, and use isinstance() normally.