Python Dataclasses Explained: @dataclass with Examples
A Python dataclass is a normal class where you list the fields with type hints and the @dataclass decorator writes __init__, __repr__ and __eq__ for you. A few lines give you a class you can create, print and compare:
from dataclasses import dataclass
@dataclass
class Student:
name: str
roll_no: int
cgpa: float = 0.0
s = Student("Asha", 42, 8.7)
print(s) # Student(name='Asha', roll_no=42, cgpa=8.7)
print(s == Student("Asha", 42, 8.7)) # True
print(Student("Ravi", 7)) # Student(name='Ravi', roll_no=7, cgpa=0.0)
No def __init__(self, name, roll_no, cgpa=0.0), no self.name = name three times, no hand-written __repr__. The rest of this guide covers the options you will actually use and the two errors everyone hits once.
What @dataclass replaces
Here is the same class written by hand, without the decorator:
class Student:
def __init__(self, name, roll_no, cgpa=0.0):
self.name = name
self.roll_no = roll_no
self.cgpa = cgpa
a = Student("Asha", 42, 8.7)
b = Student("Asha", 42, 8.7)
print(a == b) # False
Printing a shows something like <__main__.Student object at 0x7f...>, and two students with identical data are not equal, because a plain class compares by identity. To fix both you would write __repr__ and __eq__ yourself. A dataclass generates all three from the field list. It is still an ordinary class: you can add methods, properties and inheritance exactly as in the classes and objects lesson.
The type hints are required, since that is how @dataclass finds the fields, but they are not enforced at runtime. Student("Asha", "forty-two") is accepted silently. See the type hints lesson for how tools like mypy use them.
Defaults, and fields without defaults
Fields with defaults must come after fields without them, the same rule as function arguments:
from dataclasses import dataclass
@dataclass
class A:
x: int = 0
y: int
# TypeError: non-default argument 'y' follows default argument
Move y above x, or use kw_only (covered below).
The mutable default trap: field(default_factory=list)
This is the dataclass error you will meet first. A list, dict or set default is refused:
from dataclasses import dataclass
@dataclass
class Cart:
items: list = []
# ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory
Python is protecting you. One [] would be created once and shared by every Cart, so adding to one cart would add to all of them. The fix is field(default_factory=list), which calls list() to build a fresh list for each object:
from dataclasses import dataclass, field
@dataclass
class Cart:
owner: str
items: list[str] = field(default_factory=list)
c1 = Cart("Asha")
c2 = Cart("Ravi")
c1.items.append("notebook")
print(c1) # Cart(owner='Asha', items=['notebook'])
print(c2) # Cart(owner='Ravi', items=[])
default_factory takes any function with no arguments, so field(default_factory=dict) or field(default_factory=lambda: ["python"]) work the same way.
frozen=True: read-only and hashable
frozen=True makes instances read-only. Assigning to a field raises FrozenInstanceError:
from dataclasses import dataclass, FrozenInstanceError
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(2, 3)
try:
p.x = 10
except FrozenInstanceError as e:
print(e) # cannot assign to field 'x'
visited = {Point(0, 0), Point(2, 3), Point(2, 3)}
print(len(visited)) # 2
print({Point(0, 0): "origin"}) # {Point(x=0, y=0): 'origin'}
The second half is the real benefit. A regular dataclass sets __hash__ to None because it defines __eq__ and its fields can change, so hash(obj) raises TypeError: unhashable type. A frozen one gets a __hash__ built from its fields, so it can go in a set or be a dictionary key. Frozen means the fields cannot be reassigned; a list stored inside a frozen dataclass can still be modified.
order=True: sorting objects
With order=True, dataclasses also generate <, <=, > and >=. Objects are compared like tuples of their fields, in the order the fields are declared:
from dataclasses import dataclass
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int
releases = [Version(3, 12, 1), Version(3, 9, 18), Version(3, 12, 0)]
print(sorted(releases))
# [Version(major=3, minor=9, patch=18), Version(major=3, minor=12, patch=0), Version(major=3, minor=12, patch=1)]
print(Version(3, 12, 0) > Version(3, 9, 18)) # True
Note that 3.12 sorts after 3.9 here, which string comparison gets wrong. If you want to sort by one field only, put it first or pass key= to sorted().
post_init: validation after init
The generated __init__ only assigns values. If you need to check or compute something, define __post_init__; it runs straight after __init__:
from dataclasses import dataclass, field
@dataclass
class Marks:
subject: str
scored: int
total: int = 100
percent: float = field(init=False)
def __post_init__(self):
if not 0 <= self.scored <= self.total:
raise ValueError(f"scored must be between 0 and {self.total}, got {self.scored}")
self.percent = round(self.scored / self.total * 100, 1)
print(Marks("Maths", 87, 90))
# Marks(subject='Maths', scored=87, total=90, percent=96.7)
try:
Marks("Physics", 120)
except ValueError as e:
print(e) # scored must be between 0 and 100, got 120
field(init=False) keeps percent out of the constructor, since it is calculated rather than passed in.
field(repr=False) and field(compare=False)
field() also controls how each field behaves in the generated methods. repr=False hides a field when printing, which is useful for passwords and tokens. compare=False leaves a field out of == and ordering:
from dataclasses import dataclass, field
@dataclass
class User:
username: str
password: str = field(repr=False)
last_login: str = field(default="", compare=False)
u1 = User("asha", "s3cret", "2026-09-24")
u2 = User("asha", "s3cret", "2026-09-25")
print(u1) # User(username='asha', last_login='2026-09-24')
print(u1 == u2) # True
The two users are equal because the only difference is last_login, which is excluded from comparison.
slots=True (Python 3.10+)
slots=True generates __slots__ for the class. Instances then have no __dict__, which uses less memory and makes attribute access slightly faster. It also catches typos, because you cannot add an attribute that is not a field:
from dataclasses import dataclass
@dataclass(slots=True)
class Pixel:
x: int
y: int
p = Pixel(1, 2)
print(hasattr(p, "__dict__")) # False
p.z = 3
# AttributeError: 'Pixel' object has no attribute 'z'
This is worth using when you create many small objects, such as rows or points.
kw_only=True (Python 3.10+)
kw_only=True makes every field keyword-only in __init__. Calls become self-documenting, and the default-ordering rule from earlier no longer applies:
from dataclasses import dataclass
@dataclass(kw_only=True)
class Order:
item: str
qty: int = 1
price: float
o = Order(item="pen", price=10.0)
print(o) # Order(item='pen', qty=1, price=10.0)
Order("pen", 1, 10.0)
# TypeError: Order.__init__() takes 1 positional argument but 4 were given
The "1 positional argument" is self. You can also make individual fields keyword-only with field(kw_only=True).
asdict, astuple and replace
The dataclasses module has helpers for converting and copying. asdict() turns an instance into a dictionary, recursing into nested dataclasses, which is handy before json.dumps. astuple() does the same as a tuple. replace() returns a new object with some fields changed, which is how you "update" a frozen dataclass:
import json
from dataclasses import dataclass, asdict, astuple, replace
@dataclass(frozen=True)
class Address:
city: str
pin: str
@dataclass(frozen=True)
class Employee:
name: str
salary: int
address: Address
e = Employee("Meera", 900000, Address("Pune", "411001"))
print(asdict(e))
# {'name': 'Meera', 'salary': 900000, 'address': {'city': 'Pune', 'pin': '411001'}}
print(astuple(e))
# ('Meera', 900000, ('Pune', '411001'))
print(json.dumps(asdict(e)))
# {"name": "Meera", "salary": 900000, "address": {"city": "Pune", "pin": "411001"}}
raised = replace(e, salary=1100000)
print(raised.salary, e.salary) # 1100000 900000
replace() goes through __init__, so __post_init__ validation runs on the new object too.
Dataclass vs namedtuple vs plain class vs Pydantic
| Option | Mutable | Runtime type checks | Where it comes from | Use it when |
|---|---|---|---|---|
@dataclass | Yes (or frozen=True) | No | Standard library | You want a class that mainly holds data |
typing.NamedTuple / namedtuple | No | No | Standard library | You want a lightweight immutable record that is also a tuple (indexing, unpacking) |
| Plain class | Yes | Only what you write | Built in | You need full control over __init__ and behaviour |
Pydantic BaseModel | Yes by default | Yes, validates and converts | Third-party (pip install pydantic) | Data arrives from outside: JSON, API requests, config files |
A namedtuple is a tuple, so point[0] and x, y = point work, and it compares equal to a plain tuple with the same values. A dataclass does neither, which is usually what you want for a domain object. Pydantic is the choice when input is untrusted: in its default mode it converts "42" to 42 for an int field and raises a validation error for "forty-two", which a dataclass will not do. For everything in between, a dataclass is the standard-library default.
Practise it
Open the practice editor and write a Book dataclass with a title, an author and a list of tags using default_factory. Then make it frozen and put two books in a set, add a __post_init__ that rejects an empty title, and sort a list of books with order=True. The dataclasses lesson has graded exercises on the same ideas, and everything runs real Python in your browser with no install.