Dataclasses
Generate __init__, __repr__ and __eq__ from a list of annotated fields, then add frozen, slots and keyword-only options as needed.
What you will learn
- Declare a class's fields with @dataclass and use the generated __init__, __repr__ and __eq__
- Give fields defaults, including mutable ones with default_factory
- Validate fields in __post_init__
- Make instances immutable with frozen=True, and create modified copies with replace
- Use slots=True and kw_only=True, and say which Python version added each
On this page
Many classes exist mainly to hold data: an inventory item, a point, a configuration. You have seen how much ceremony that takes. There is an __init__ that copies each parameter onto self, a __repr__ that lists them again, and an __eq__ that lists them a third time. A dataclass writes all of that for you from a list of field declarations.
The boilerplate you no longer write
Here is a plain class that holds three values and supports printing and comparison:
class Item: def __init__(self, name, price, quantity=0): self.name = name self.price = price self.quantity = quantity
def __repr__(self): return f"Item(name={self.name!r}, price={self.price!r}, quantity={self.quantity!r})"
def __eq__(self, other): if not isinstance(other, Item): return NotImplemented return (self.name, self.price, self.quantity) == (other.name, other.price, other.quantity)
print(Item("pen", 1.5, 10))Item(name='pen', price=1.5, quantity=10)Every field name appears in __init__, __repr__ and __eq__. Add a field and you must edit all three, or the class is quietly wrong. The decorator @dataclass from the dataclasses module removes the repetition. (Decorators are a later topic. Read @dataclass as “generate the usual methods for the fields declared below.”)
from dataclasses import dataclass
@dataclassclass Item: name: str price: float quantity: int = 0
pen = Item("pen", 1.5, 10)print(pen)print(pen == Item("pen", 1.5, 10))pen.quantity = 8print(pen.quantity)Item(name='pen', price=1.5, quantity=10)True8Each line in the body that has the form name: type declares a field. quantity: int = 0 gives it a default, so Item("pen", 1.5) works too. The generated __repr__ shows every field, and the generated __eq__ compares them as a group. The instance is an ordinary object, so you can still change pen.quantity.
The type annotations are required, because they are how @dataclass finds the fields. Python does not enforce them at run time. Item("pen", "cheap") would be accepted, and a type checker would flag it.
Defaults for lists and other mutable values
A mutable default would be shared between instances, exactly like the class attribute trap from the first lesson. Dataclasses refuse it outright:
from dataclasses import dataclass
try:
@dataclass class Basket: owner: str items: list[str] = []
except ValueError as error: print("ValueError:", error)ValueError: mutable default <class 'list'> for field items is not allowed: use default_factoryFor a field that needs a fresh list, dictionary or set per instance, use field(default_factory=...). The factory is a callable that runs once for each new instance:
from dataclasses import dataclass, field
@dataclassclass Basket: owner: str items: list[str] = field(default_factory=list)
a = Basket("Asha")b = Basket("Ben")a.items.append("apple")print(a)print(b)Basket(owner='Asha', items=['apple'])Basket(owner='Ben', items=[])list was passed without parentheses. It is the function to call, not a list. Each Basket got its own, so b stayed empty.
Checking values: __post_init__
The generated __init__ copies values in without looking at them. To validate, or to compute extra fields, define __post_init__. The generated __init__ calls it after all fields are set:
from dataclasses import dataclass
@dataclassclass Item: name: str price: float quantity: int = 0
def __post_init__(self): if self.price < 0: raise ValueError(f"price cannot be negative: {self.price}")
print(Item("pen", 1.5))try: Item("pen", -1)except ValueError as error: print("ValueError:", error)Item(name='pen', price=1.5, quantity=0)ValueError: price cannot be negative: -1Ordering and hashing
Pass options to the decorator in parentheses. order=True adds <, <=, > and >=, comparing the fields in the order they were declared. That is the tuple comparison you wrote by hand in the data model lesson:
from dataclasses import dataclass
@dataclass(order=True)class Version: major: int minor: int
versions = [Version(3, 12), Version(2, 7), Version(3, 9)]print(sorted(versions))print(Version(3, 9) < Version(3, 12))[Version(major=2, minor=7), Version(major=3, minor=9), Version(major=3, minor=12)]TrueThe data model lesson warned that defining __eq__ removes __hash__. A dataclass follows the same rule, so an ordinary one cannot go into a set:
from dataclasses import dataclass
@dataclassclass Point: x: int y: int
p = Point(1, 2)print(p == Point(1, 2))try: {p}except TypeError: print("TypeError: a mutable dataclass is unhashable")TrueTypeError: a mutable dataclass is unhashableImmutable instances: frozen=True
frozen=True makes assignment to a field an error after the object is created. A frozen dataclass with eq=True, the default, also gets a __hash__, because it is now safe to hash:
from dataclasses import dataclass, FrozenInstanceError
@dataclass(frozen=True)class Point: x: int y: int
p = Point(1, 2)try: p.x = 10except FrozenInstanceError as error: print("FrozenInstanceError:", error)print({p, Point(1, 2)})FrozenInstanceError: cannot assign to field 'x'{Point(x=1, y=2)}Freezing gives you the safe, hashable value objects the data model lesson described, with a single option. The error message text is not a promise, so catch FrozenInstanceError by type.
Making a changed copy with replace
A frozen object cannot be updated, but you can make a near-identical new one. dataclasses.replace takes the object and the fields to change:
from dataclasses import dataclass, replace
@dataclass(frozen=True)class Item: name: str quantity: int
old = Item("pen", 10)new = replace(old, quantity=7)print(old)print(new)Item(name='pen', quantity=10)Item(name='pen', quantity=7)The original stays as it was. Notice that replace calls the class again, so __post_init__ runs and validation applies to the new value too.
Python 3.13 Requires version 3.13 or later added copy.replace(), a general function that does the same for dataclasses and for any class that provides a __replace__ method:
import copyfrom dataclasses import dataclass
@dataclass(frozen=True)class Item: name: str quantity: int
old = Item("pen", 10)new = copy.replace(old, quantity=7)print(old, new)Item(name='pen', quantity=10) Item(name='pen', quantity=7)On older versions, dataclasses.replace is the one to use. It is also the clearer name when you know you have a dataclass.
Smaller instances: slots=True
Normally each instance stores its attributes in a dictionary, which is what lets you attach new attributes at any time. With slots=True, Python generates a __slots__ declaration instead. Instances become smaller and a little faster to use, and they no longer accept attributes the class did not declare:
from dataclasses import dataclass
@dataclass(slots=True)class Point: x: int y: int
p = Point(1, 2)print(hasattr(p, "__dict__"))try: p.z = 3except AttributeError: print("AttributeError: no room for a new attribute")FalseAttributeError: no room for a new attributeThis also closes the typo trap from the first lesson: p.z = 3 is an error, not a new attribute.
Keyword-only fields: kw_only=True
Dataclasses with many fields are hard to read at the call site: Connection("db", 5432, False) says nothing about which value is which. kw_only=True makes every field keyword-only in the generated __init__:
from dataclasses import dataclass
@dataclass(kw_only=True)class Connection: host: str port: int = 5432 use_tls: bool = True
print(Connection(host="db.example", use_tls=False))try: Connection("db.example")except TypeError: print("TypeError: positional arguments are not accepted")Connection(host='db.example', port=5432, use_tls=False)TypeError: positional arguments are not acceptedThe call with a positional value was rejected. A side benefit is that keyword-only fields may have defaults in any order, because the usual “non-default after default” rule applies only to positional parameters. For a single field, field(kw_only=True) does the same.
When not to use a dataclass
A dataclass fits classes whose main job is to hold values. If the class has complex setup, hides its data behind properties, or is mostly behaviour, a plain class is clearer. You can mix the two: a dataclass can have methods and properties like any other class.
Common pitfalls
Forgetting the annotation. A line quantity = 0 with no annotation is a class attribute, not a field. The generated __init__ ignores it. Write quantity: int = 0.
A mutable default. items: list = [] raises ValueError. Use field(default_factory=list).
A required field after one with a default. Declaring y: int below x: int = 0 fails with TypeError, for the same reason that a function cannot have a required parameter after an optional one. Reorder the fields, or use kw_only=True.
Expecting frozen=True to be deep. It only blocks assignment to the fields. A frozen dataclass holding a list can still have that list modified by item.tags.append(...). Hold tuples if you need real immutability.
Mutating a shared object. If two names refer to the same non-frozen dataclass, changing one changes the other. Use replace to make an independent copy.
Write a frozen dataclass Item with fields name: str, price: float and quantity: int = 0. A __post_init__ should raise ValueError with the message quantity cannot be negative: -5 (using the real number) when the quantity is below zero.
Then write two functions, restock(item, amount) and sell(item, amount). Each returns a new item and leaves the original untouched.
Test script:
pen = Item("pen", 1.5, 10)more = restock(pen, 5)print(pen.quantity, more.quantity)try: sell(more, 20)except ValueError as error: print("ValueError:", error)print(more)Expected output:
10 15ValueError: quantity cannot be negative: -5Item(name='pen', price=1.5, quantity=15)Show solution
from dataclasses import dataclass, replace
@dataclass(frozen=True)class Item: name: str price: float quantity: int = 0
def __post_init__(self): if self.quantity < 0: raise ValueError(f"quantity cannot be negative: {self.quantity}")
def restock(item, amount): return replace(item, quantity=item.quantity + amount)
def sell(item, amount): return replace(item, quantity=item.quantity - amount)
pen = Item("pen", 1.5, 10)more = restock(pen, 5)print(pen.quantity, more.quantity)try: sell(more, 20)except ValueError as error: print("ValueError:", error)print(more)10 15ValueError: quantity cannot be negative: -5Item(name='pen', price=1.5, quantity=15)replace calls __init__ again, so selling more than is in stock triggers the check in __post_init__ without any extra code in sell. The original more is untouched, because frozen instances are never modified in place.
Write a dataclass Settings with kw_only=True and slots=True, with fields host: str, port: int = 8000 and debug: bool = False. In __post_init__, raise ValueError with the message port out of range: 70000 (real value) unless the port is from 1 to 65535.
Create Settings(host="localhost"), Settings(host="example.org", port=443), then try Settings(host="x", port=70000) and Settings("localhost"). Print each result, the ValueError message for the bad port, and TypeError: keyword arguments only for the positional call. Expected output:
Settings(host='localhost', port=8000, debug=False)Settings(host='example.org', port=443, debug=False)ValueError: port out of range: 70000TypeError: keyword arguments onlyShow solution
from dataclasses import dataclass
@dataclass(kw_only=True, slots=True)class Settings: host: str port: int = 8000 debug: bool = False
def __post_init__(self): if not 1 <= self.port <= 65535: raise ValueError(f"port out of range: {self.port}")
print(Settings(host="localhost"))print(Settings(host="example.org", port=443))try: Settings(host="x", port=70000)except ValueError as error: print("ValueError:", error)try: Settings("localhost")except TypeError: print("TypeError: keyword arguments only")Settings(host='localhost', port=8000, debug=False)Settings(host='example.org', port=443, debug=False)ValueError: port out of range: 70000TypeError: keyword arguments onlyThe two options combine freely. Because the fields are keyword-only, the order of the declarations no longer matters to callers. The condition not 1 <= self.port <= 65535 is Python’s chained comparison: it is true only when the port is outside the range.
This frozen dataclass has a weight that defaults to 1. Write down the three lines before running it, and say why the set has the size it does.
from dataclasses import dataclass
@dataclass(frozen=True)class Tag: label: str weight: int = 1
a = Tag("py")b = Tag("py", 1)c = Tag("py", 2)print(a == b)print(a == c)print(len({a, b, c}))Show solution
TrueFalse2a and b have the same fields, so they are equal. c differs in weight, so it is not equal to a. The set keeps one entry for the equal pair a and b, plus one for c, so its length is 2. The set works because frozen=True with the default eq=True generates a __hash__ over the same fields.
Summary
@dataclassgenerates__init__,__repr__and__eq__from the annotated fields in the class body.- Use
field(default_factory=list)for mutable defaults. A plain mutable default is an error. __post_init__runs after the generated__init__, which makes it the place to validate.order=Trueadds comparisons.frozen=Truemakes the instance immutable and hashable.dataclasses.replacemakes a changed copy, andcopy.replace()Requires version 3.13 or later does the same.slots=Trueandkw_only=Truereduce memory and clarify calls.- Going further: the advanced lesson Annotations at runtime shows how the field annotations are stored and read and the advanced lesson Class customisation covers the hooks that run when a class is created.