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 toselfin declaration order, using any defaults you specified.__repr__— produces a readable string likeClassName(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 theValueError, just write it correctly from the start. - Reach for
frozen=Truewhenever 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
@dataclassto recognize an attribute as a field, and they make the generated__init__self-documenting. - On Python 3.10+, pass
slots=Trueto@dataclassfor 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
namedtupleor plain tuple may fit better there. - Use
dataclasses.asdict()ordataclasses.astuple()sparingly — they deep-copy nested dataclasses and can be surprisingly expensive on large structures.
Practice Exercises
- Write a
frozen=True,order=Truedataclass namedVersionwith fieldsmajor,minor, andpatch(allint). Create a list of severalVersioninstances and print them sorted from oldest to newest. - Write a dataclass
Playlistwith a requiredname: strfield and asongs: list[str]field usingdefault_factory=list. Add a methodadd_song(self, title: str)that appends tosongs. Create two separate playlists and confirm that adding a song to one does not affect the other. - Write a dataclass
Temperaturewith a single fieldcelsius: floatand a__post_init__that raisesValueErrorifcelsiusis below-273.15(absolute zero). Add a property or methodto_fahrenheit()that returns the Fahrenheit equivalent. Test it with a valid value and confirm the error is raised for an invalid one.
Summary
@dataclassgenerates__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 raisesValueErrorat class-definition time. frozen=Truemakes instances immutable and enables hashing; without it, dataclasses with defaulteq=Trueare unhashable.order=Trueadds 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.
