A Python dataclass writes the boring parts of a class for you: the __init__, the __repr__, and the __eq__. You declare the fields once with type annotations, add @dataclass on top, and Python fills in the rest. This post is a recipe book of dataclass patterns: simple defaults, mutable default factories, frozen immutability, computed fields with __post_init__, and the memory savings from slots.
“The best code is the code you never had to write.”
Jeff Atwood, Coding Horror
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 17 minutes
Prerequisites: You should be comfortable with Classes and Objects, Type Hints, and Decorators. A dataclass is really just those three ideas stacked together: class syntax, type annotations, and one decorator doing the busywork.
Here is the problem. You have written this class a hundred times. A constructor that assigns self.name = name, self.age = age, line after line. A __repr__ so the object prints nicely instead of showing a useless memory address. An __eq__ so two objects with the same data count as equal. It is the same plumbing every single time, and every line is a chance to make a typo.
Think of a dataclass like ordering a meal combo instead of asking for the veggie burger, the fries, and the drink one by one. You name what you want, and the kitchen assembles the standard parts for you. You declare the fields, and Python hands back a finished class with the constructor and comparison methods already built.
And it goes past just saving keystrokes. Dataclasses give you frozen=True for objects that cannot be changed, slots=True for instances that use less memory, kw_only=True for keyword-only constructors, and field() for fine control over defaults and comparison. They have been in the standard library since Python 3.7, so there is nothing to install. Just from dataclasses import dataclass and go. And when a field should only ever hold one of a few fixed values, pair the dataclass with a Python enum instead of loose strings.
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
The diagram takes a dataclass apart. The @dataclass decorator reads your field definitions and auto-generates __init__, __repr__, and __eq__. Turn on the right options and it also writes __hash__ (with frozen=True) and comparison methods like __lt__ (with order=True). Each field can carry a type, a default value, and metadata, and the field() function gives you fine control over factory defaults and comparison. All the plumbing that makes plain classes tedious for data objects gets handled for you.
Table of Contents
The Solution: From 20 Lines to 5
First, feel the pain. Here is the plain class you would write by hand for a simple employee record. Notice how little of it is about employees and how much is just machinery.
❌ Without a dataclass: lots of boilerplate
class Employee:
def __init__(self, name: str, department: str, salary: float):
self.name = name
self.department = department
self.salary = salary
def __repr__(self):
return f"Employee(name={self.name!r}, department={self.department!r}, salary={self.salary})"
def __eq__(self, other):
if not isinstance(other, Employee):
return NotImplemented
return (self.name, self.department, self.salary) == (other.name, other.department, other.salary)
Now the same thing as a python dataclass. You list the three fields with their types, and that is the whole class. To try it out, we will create records for two engineers on the same team, Rahul and Niranjan.
✅ With a dataclass: same behaviour, a fraction of the code
from dataclasses import dataclass
@dataclass
class Employee:
name: str
department: str
salary: float
# __init__, __repr__, and __eq__ are auto-generated
rahul = Employee("Rahul", "Engineering", 85000.0)
niranjan = Employee("Niranjan", "Engineering", 85000.0)
print(rahul)
print(rahul == niranjan)
▶ Output
Employee(name='Rahul', department='Engineering', salary=85000.0) False
What happened here: The @dataclass decorator read the three annotated fields (name, department, salary) and wrote the constructor for you, so Employee("Rahul", "Engineering", 85000.0) just works. The auto-generated __repr__ is why print(rahul) shows the field values instead of a memory address like <__main__.Employee object at 0x...>. The comparison printed False because Rahul and Niranjan have different names, and the generated __eq__ compares every field. Two employees count as equal only when all their fields match.
Variation 1: Defaults and Default Factories
Most records have fields that are usually the same. A new project is probably written in Python, probably at version 1.0.0, and starts with no tags. You give a field a default just by writing = value after its type, the same way function arguments get defaults.
But there is a catch with lists, dicts, and sets. You cannot write tags: list[str] = [], and Python will refuse to even build the class if you try. The reason is the classic mutable default trap: a single empty list would be shared by every instance, so one project appending a tag would change every other project too. The fix is field(default_factory=list), which tells Python to call list() fresh for each new instance. Think of it like a tear-off notepad. Each new project tears off its own blank page instead of everyone scribbling on one shared sheet.
📄 defaults.py: simple defaults and mutable default factories
from dataclasses import dataclass, field
@dataclass
class Project:
name: str
language: str = "Python"
version: str = "1.0.0"
tags: list[str] = field(default_factory=list) # Fresh list per instance
contributors: dict[str, str] = field(default_factory=dict)
p1 = Project("ML Pipeline")
p1.tags.append("machine-learning")
p2 = Project("Web App")
p2.tags.append("web")
# Each instance has its own list, no shared mutable default bug
print(p1.tags)
print(p2.tags)
▶ Output
['machine-learning'] ['web']
What happened here: p1 and p2 each got their own empty list because default_factory=list ran once per instance. Appending to p1.tags left p2.tags untouched, which is exactly what you want. Had we written tags: list[str] = [] instead, Python would have raised ValueError: mutable default ... is not allowed: use default_factory before the program even started. The same rule applies to any mutable default: use default_factory=dict for dicts, default_factory=set for sets, or pass your own zero-argument function (even a lambda) to build the starting value.
Variation 2: Frozen Dataclasses for Immutability
Some data should never change after you create it. A GPS coordinate, a colour value, a configuration record. For those, add frozen=True to the decorator. Once an instance is built, trying to reassign any field raises an error. It is like writing on a coin versus writing on a whiteboard. The whiteboard (a normal object) can be wiped and rewritten; the coin (a frozen object) is stamped once and stays that way.
Freezing buys you a second perk. A frozen python dataclass also gets a __hash__ method, so you can use instances as dictionary keys or drop them into a set. A plain (non-frozen) dataclass is unhashable, because Python will not let you hash something that can change underneath you.
📄 frozen.py: immutable dataclasses you can use as dict keys
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
mumbai = Coordinate(19.076, 72.877)
print(mumbai)
# Frozen instances are hashable, so they work as dict keys and set members
locations = {mumbai: "Mumbai", Coordinate(28.613, 77.209): "Delhi"}
print(locations[mumbai])
▶ Output
Coordinate(lat=19.076, lon=72.877) Mumbai
Now try to move Mumbai. The moment you assign to a field, Python stops you cold.
🚫 Python REPL: a frozen field refuses to change
>>> mumbai.lat = 0.0 Traceback (most recent call last): ... dataclasses.FrozenInstanceError: cannot assign to field 'lat'
What happened here: Assigning to mumbai.lat tripped the __setattr__ that frozen=True installed, which raises FrozenInstanceError: cannot assign to field 'lat'. That guarantee is exactly why the dict above works. Because the coordinate cannot change, its hash never changes, so Python can safely use it as a key. If you need a tweaked copy of a frozen object, do not fight the freeze. Build a fresh one with dataclasses.replace(mumbai, lat=0.0), which returns a brand new instance and leaves the original alone.
Variation 3: Computed Fields with __post_init__
Sometimes a field is not given, it is figured out. A rectangle is created with a width and a height, but its area and perimeter follow from those two numbers. You do not want the caller to pass them in. You want Python to compute them.
That is what __post_init__ is for. It is a method the dataclass calls automatically right after the auto-generated __init__ finishes. Pair it with field(init=False), which keeps a field out of the constructor’s argument list so you can fill it in yourself. This is the same idea as a receipt at a shop. You hand over the items and quantities, and the till works out the total. You never type the total in by hand.
📄 post_init.py: derive fields after the constructor runs
from dataclasses import dataclass, field
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False) # Not a constructor argument
perimeter: float = field(init=False) # Filled in by __post_init__
def __post_init__(self):
self.area = self.width * self.height
self.perimeter = 2 * (self.width + self.height)
rect = Rectangle(10, 5)
print(f"Area: {rect.area}, Perimeter: {rect.perimeter}")
▶ Output
Area: 50, Perimeter: 30
What happened here: You called Rectangle(10, 5) with just two arguments. The area and perimeter fields were marked init=False, so they are not part of the constructor signature and you cannot pass them in. After the constructor stored width and height, Python called __post_init__, which computed area as 10 * 5 and perimeter as 2 * (10 + 5). The output reads Area: 50, Perimeter: 30, and both values stay in sync with the inputs without the caller doing any arithmetic. This is also the natural spot to validate data: raise a ValueError in __post_init__ if, say, a width comes in negative.
Variation 4: slots=True for Memory and Speed
By default, every Python object stores its attributes in a hidden dictionary called __dict__. That dictionary is flexible (you can bolt on new attributes at runtime), but it is not free. It costs memory, and a dictionary lookup is a touch slower than a direct slot. Add slots=True (available since Python 3.10) and the dataclass uses __slots__ instead: a fixed set of named storage spots with no per-instance dictionary.
Picture a coat check. A regular object is like a big open cloakroom where you can keep adding coats anywhere; finding a coat means scanning the room. A slotted object is a row of numbered pegs, one per field. Fewer moving parts, less space, faster to grab. The trade-off: you cannot hang a coat on a peg that does not exist, meaning you cannot add new attributes that were not declared as fields.
📄 slots_demo.py: measure the real memory difference
import sys
from dataclasses import dataclass
@dataclass
class Regular:
x: float
y: float
@dataclass(slots=True)
class Slotted:
x: float
y: float
r = Regular(1.0, 2.0)
s = Slotted(1.0, 2.0)
# A regular instance keeps its data in a separate __dict__.
# sys.getsizeof(r) does NOT count that dict, so add it for a fair total.
regular_total = sys.getsizeof(r) + sys.getsizeof(r.__dict__)
slotted_total = sys.getsizeof(s) # no __dict__ to add
print(f"Regular: object {sys.getsizeof(r)} + __dict__ {sys.getsizeof(r.__dict__)} = {regular_total} bytes")
print(f"Slotted: {slotted_total} bytes (no __dict__)")
print(f"Slotted has __dict__? {hasattr(s, '__dict__')}")
▶ Output
Regular: object 48 + __dict__ 296 = 344 bytes Slotted: 48 bytes (no __dict__) Slotted has __dict__? False
What happened here: This is the trap most slots demos fall into. If you only print sys.getsizeof(r) and sys.getsizeof(s), both come back as 48 bytes and slots looks pointless. That is because sys.getsizeof measures only the object header, not the separate __dict__ hanging off a regular instance. Add the dict in and the regular object needs 344 bytes against the slotted object’s 48 on this machine. The slotted version has no __dict__ at all, which is why hasattr(s, '__dict__') is False.
The exact numbers shift with how many fields you have and your Python build, so do not memorise “344” or a fixed percentage. The point is real and measurable: drop the per-instance dictionary and you save memory, which adds up fast when you are holding millions of small records.
Inheritance Between Dataclasses
Dataclasses inherit like normal classes. Think of a printed form template: the head office form asks for name and age, and each department staples its own extra boxes underneath. A dataclass subclass works the same way. It adds its own fields, and the generated __init__ simply lines up the parent’s fields first, then the child’s. An Employee is a Person with a department and a salary tacked on. Say a new engineer named Viraj joins the company:
📄 inheritance.py: a subclass extends the parent’s fields
from dataclasses import dataclass
@dataclass
class Person:
name: str
age: int
@dataclass
class Employee(Person):
department: str
salary: float = 50000.0
viraj = Employee("Viraj", 27, "Engineering", 90000.0)
print(viraj)
print(viraj.name, viraj.department)
▶ Output
Employee(name='Viraj', age=27, department='Engineering', salary=90000.0) Viraj Engineering
What happened here: The constructor accepts the parent’s fields in order (name, age) and then the child’s (department, salary), so Employee("Viraj", 27, "Engineering", 90000.0) fills all four. The generated __repr__ shows every field, parent and child together. There is one sharp edge to know about, covered next.
The catch is field ordering. Once any field has a default, every field after it must have one too, and inheritance flattens parent and child fields into a single list. So if the parent gives age a default but the child’s department has none, Python complains.
🚫 Python REPL: a non-default field cannot follow a default one
>>> @dataclass ... class Person: ... name: str ... age: int = 0 ... >>> @dataclass ... class Employee(Person): ... department: str # no default, but follows age=0 ... salary: float = 50000.0 ... Traceback (most recent call last): ... TypeError: non-default argument 'department' follows default argument 'age'
What happened here: Because age has a default, the flattened field list becomes name, age=0, department, salary=50000.0, and department sits after a defaulted field with no default of its own. That is the same rule Python enforces for function arguments. The clean fix is @dataclass(kw_only=True) on the relevant class, which makes the fields keyword-only so ordering no longer matters and calls read clearly: Employee(name="Viraj", age=27, department="Engineering").
When Not to Reach for a Dataclass
A pattern is only useful if you also know where it stops helping. A python dataclass is made for objects that mostly hold data. Here is where they are the wrong tool.
- You need real validation and parsing. A dataclass does not check types at runtime. Pass a string where you declared an
intand it stores the string without a word of warning. When you are taking in untrusted data (an Application Programming Interface (API) request body, a config file, a form), reach for Pydantic instead, which validates and coerces for you. - The class is mostly behaviour. If your class is a pile of methods with one or two attributes, a dataclass buys you little. The generated
__init__and__repr__are not the interesting part. Write a plain class and keep the focus on the methods. - Construction is genuinely complex. If building an instance means opening connections, branching on arguments, or pulling from several sources, a hand-written
__init__(or a classmethod factory likefrom_config) is clearer than bending__post_init__around the logic.
The short version: dataclasses for data, plain classes for behaviour, Pydantic for data that arrives from the outside world and must be trusted.
Common Mistakes
Mistake 1: A mutable default without field(default_factory=…)
🚫 Wrong
@dataclass
class Bad:
items: list[str] = [] # Python refuses to build this class
▶ Output
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory
✅ Correct
@dataclass
class Good:
items: list[str] = field(default_factory=list)
Why: A bare [] would be created once and shared across every instance, so the error stops you before that bug can bite. default_factory=list builds a fresh list per instance.
Mistake 2: Expecting a dataclass to validate types
Say a user named Aditi fills in a signup form and her age arrives as text. It feels like the age: int annotation should catch that. It does not.
🚫 Wrong assumption
@dataclass
class User:
name: str
age: int
u = User("Aditi", "twenty-six") # age should be an int...
print(u.age) # ...but Python stores the string anyway
▶ Output
twenty-six
Why: Type annotations on a dataclass are hints, not runtime checks. age: int documents your intent and helps tools like mypy, but Python itself never enforces it. If you need the value rejected or converted, validate it in __post_init__ or use Pydantic.
Try It Yourself
Build a BankAccount dataclass. Give it an owner (str) and a balance (float, default 0.0). Add an account_id that callers cannot pass in: mark it field(init=False) and generate it inside __post_init__, and in the same method raise a ValueError if the starting balance is negative. Then make a tweaked copy with a new balance using dataclasses.replace, and confirm the original is unchanged.
Conclusion
You now have the full python dataclass toolkit: @dataclass to erase the __init__, __repr__, and __eq__ boilerplate, field(default_factory=...) for safe mutable defaults, frozen=True when data must never change (and needs to work as a dict key), __post_init__ for computed and validated fields, and slots=True when you are holding millions of small records and memory matters. The rule of thumb stays simple: dataclasses for data, plain classes for behaviour, Pydantic for untrusted input.
Up next is Pydantic, which picks up exactly where dataclasses stop: validating and converting data that arrives from the outside world. And if you want to jump to any other topic, browse the full Python + AI/ML tutorial series home.
Frequently Asked Questions
What does a Python dataclass do?
A Python dataclass reads your class’s field annotations and auto-generates __init__, __repr__, and __eq__ methods. You declare the fields with type hints and add the @dataclass decorator; Python writes the boilerplate. It has been part of the standard library since Python 3.7, so there is nothing to install.
What is frozen=True in dataclasses?
@dataclass(frozen=True) makes instances immutable. Any attempt to set an attribute raises FrozenInstanceError. Frozen dataclasses also get a __hash__ method, so they can be used as dictionary keys and set members. To get a changed copy, use dataclasses.replace() instead of editing in place.
When should I use a dataclass vs a regular class?
Use a dataclass when the class is mostly a data container, such as records, configs, or data transfer objects. Use a regular class when behaviour (methods) is the main point, or when construction is complex. For data coming from outside (an API or config file) that needs validation, use Pydantic instead.
What is __post_init__ in dataclasses?
A method called automatically right after the generated __init__ finishes. Use it to compute derived fields, validate inputs, or transform values. Fields marked with field(init=False) are kept out of the constructor and typically set inside __post_init__.
What does slots=True do in a dataclass?
@dataclass(slots=True) (Python 3.10+) generates __slots__ instead of using a per-instance __dict__ for attribute storage. That saves memory and slightly speeds up attribute access. The trade-off is that you cannot add new attributes that were not declared as fields.
Does a dataclass validate types at runtime?
No. Type annotations like age: int are hints for readers and tools such as mypy, not runtime checks. A dataclass will happily store a string in an int field. For real validation and type coercion, use Pydantic or validate manually in __post_init__.
Interview Questions on Python Dataclasses
How interviewers actually probe this topic: real scenarios, with answers you can say out loud.
Q: You add slots=True to a dataclass, but instances of a subclass are still carrying a __dict__ and the memory savings vanish. Why?
Slots only remove the per-instance __dict__ when every class in the inheritance chain uses them. If a subclass does not also declare slots=True (or its own __slots__), Python quietly gives its instances a __dict__ again, and the saving is gone. The fix is to add slots=True to the subclass’s @dataclass decorator too. It is worth knowing that @dataclass(slots=True) actually builds and returns a brand new class object, since __slots__ cannot be added to a class after creation.
Q: A teammate named Anvay makes a dataclass frozen=True, and now __post_init__ crashes with FrozenInstanceError when it sets a derived field. How does he fix it without unfreezing the class?
frozen=True installs a __setattr__ that blocks all assignment, including assignments made inside __post_init__. The standard workaround is to bypass it with object.__setattr__(self, "area", value) inside __post_init__. That sets the field directly on the instance without going through the frozen guard, and the object is still immutable to all normal code afterwards.
Q: When do you use field(default=…) versus field(default_factory=…), and can a field have both?
Use default for immutable values like numbers, strings, and tuples; a plain = value after the type is shorthand for it. Use default_factory for anything mutable (lists, dicts, sets, other dataclasses), because the factory is a zero-argument callable that runs fresh for every instance, so nothing is shared. A field cannot have both: passing default and default_factory together raises ValueError.
Q: What does order=True generate, and what happens when you compare two different dataclass types with it?
@dataclass(order=True) generates __lt__, __le__, __gt__, and __ge__, which compare instances as if they were tuples of their fields in declaration order. Comparing against a different class returns NotImplemented, which surfaces as a TypeError. A common trick is to exclude noisy fields from ordering with field(compare=False) so sorting only looks at the fields you care about.
Q: Your config dataclass has grown to 12 fields, and callers keep passing values in the wrong positional order, so wrong values land in the wrong fields silently. What is the cleanest fix?
Add kw_only=True to the decorator (Python 3.10+). Every field then becomes keyword-only, so callers must write Config(host="db1", port=5432, ...) and a swapped pair of arguments becomes impossible instead of silent. As a bonus, kw_only=True also dissolves the “non-default argument follows default argument” error you hit when a subclass adds required fields after a parent’s defaulted ones.
Q: How does dataclasses.asdict() handle nested dataclasses, and what is the catch?
asdict() converts recursively: nested dataclasses become nested dicts, and it also descends into lists, tuples, and dicts, deep-copying values along the way. The catch is exactly that deep copy: on large object graphs it is slow, and mutating the result never touches the originals. If you just need a shallow, non-recursive view of one instance, {f.name: getattr(obj, f.name) for f in dataclasses.fields(obj)} is much cheaper.
Go deeper: when you outgrow this post, Python dataclasses documentation is the next stop.
Related Posts
Previous: Python: Type Hints, Annotations, Union Types (X | None), Generics
Next: Python: Pydantic, Data Validation, Settings, Serialization
Series Home: Python + AI/ML Tutorial Series

No comment