Python Dataclasses Explained: Concepts, Code, and Real-World Use

Introduction

When a Python class exists primarily to represent data, we often write repetitive code. We need to define __init__(), assign attributes, implement __repr__() for debugging, and often implement __eq__() to compare objects.

Dataclasses reduce this boilerplate and make the intent of the class more explicit.


What are Dataclasses?

A Dataclass is a regular class processed by the @dataclass decorator. The decorator examines type annotations and adds generated methods such as __init__(), __repr__(), and __eq__().

PYTHON
from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int
Click to expand and view more

We can then write:

PYTHON
user = User("Alison", 38)

print(user)
# User(name='Alison', age=38)
Click to expand and view more

And:

PYTHON
user1 = User("Alison", 38)
user2 = User("Alison", 38)

print(user1 == user2)
# True
Click to expand and view more

The official documentation explains that the decorator does not create a new class: it processes the existing class, adds the generated methods, and returns the same class.


When were they introduced?

Dataclasses were proposed in PEP 557 and added to Python 3.7. The proposal aimed to provide a standard-library solution for classes that primarily represent data while reducing repetitive code.

Before them, developers commonly wrote classes manually, used namedtuple, typing.NamedTuple, or libraries such as attrs.


What happens when a Dataclass is created?

This is one of the most important parts to understand.

Consider:

PYTHON
@dataclass
class User:
    name: str
    age: int
Click to expand and view more

When the decorator runs, it examines User.__annotations__ and identifies name and age as fields.

With the default parameters (init=True, repr=True, eq=True), it generates methods equivalent to:

PYTHON
def __init__(self, name: str, age: int):
    self.name = name
    self.age = age

def __repr__(self):
    return f"User(name={self.name!r}, age={self.age!r})"

def __eq__(self, other):
    if other.__class__ is self.__class__:
        return (
            self.name == other.name
            and self.age == other.age
        )
    return NotImplemented
Click to expand and view more

This is a conceptual representation of the result; it does not mean this exact source code is literally inserted into the class.


Getters and Setters: What they are and how they work in Python

What are they?

In languages like Java or C#, it is common to see code like this:

JAVA
// Java - traditional getters and setters
public class User {
    private String name;
    private int age;

    public String getName() { return this.name; }
    public void setName(String name) { this.name = name; }

    public int getAge() { return this.age; }
    public void setAge(int age) { this.age = age; }
}
Click to expand and view more

In these languages, attribute access always goes through methods:

JAVA
User user = new User();
user.setName("Alison");
System.out.println(user.getName());
Click to expand and view more

How does it work in Python?

In Python, attribute access is direct - we don’t need get_ and set_ methods:

PYTHON
user = User("Alison", 38)

# Direct access (no get_name() needed)
print(user.name)   # Alison

# Direct assignment (no set_name() needed)
user.age = 39
Click to expand and view more

Does a Dataclass create getters and setters?

No. It is common to see the claim that a Dataclass “creates getters and setters for each attribute.” This is not true in Python.

Dataclasses do not generate methods like:

PYTHON
get_name()
set_name()
get_age()
set_age()
Click to expand and view more

What Dataclasses generate are the special methods (__init__, __repr__, __eq__), which run “under the hood” and are not called directly by the programmer.

What if I need logic when accessing or changing a value?

If you need validation or logic when reading or writing an attribute, use @property:

PYTHON
from dataclasses import dataclass

@dataclass
class User:
    name: str
    _age: int

    @property
    def age(self) -> int:
        """Getter: logic when accessing the value."""
        return self._age

    @age.setter
    def age(self, value: int) -> None:
        """Setter: logic when changing the value."""
        if value < 0:
            raise ValueError("Age cannot be negative")
        self._age = value
Click to expand and view more

Now:

PYTHON
user = User("Alison", 38)

print(user.age)      # 38 - calls the getter under the hood
user.age = 39        # calls the setter under the hood
user.age = -5        # ValueError: Age cannot be negative
Click to expand and view more

The property provides the user.age interface, while the getter and setter contain the actual logic. This logic is not generated automatically by the Dataclass.

Visual summary

PLAINTEXT
┌─────────────────────────────────────────────────────────────┐
│  OTHER LANGUAGES (Java, C#)                                 │
│  user.getName()  →  explicit getter                         │
│  user.setName(x) →  explicit setter                         │
├─────────────────────────────────────────────────────────────┤
│  PYTHON (default)                                           │
│  user.name       →  direct attribute access                 │
│  user.name = x   →  direct assignment                       │
├─────────────────────────────────────────────────────────────┤
│  PYTHON (with @property)                                    │
│  user.name       →  calls getter under the hood             │
│  user.name = x   →  calls setter under the hood             │
│  (same interface, but with embedded logic)                  │
├─────────────────────────────────────────────────────────────┤
│  DATACLASS                                                  │
│  DOES NOT create get_name() / set_name()                    │
│  Creates __init__, __repr__, __eq__ (special methods)       │
└─────────────────────────────────────────────────────────────┘
Click to expand and view more

__init__(), __repr__(), and __eq__()

__init__()

Initializes the instance after it is created.

PYTHON
@dataclass
class User:
    name: str
    age: int
Click to expand and view more

The Dataclass conceptually generates:

PYTHON
def __init__(self, name: str, age: int):
    self.name = name
    self.age = age
Click to expand and view more

So:

PYTHON
user = User("Alison", 38)
Click to expand and view more

works without us having to write the method.

__repr__()

Provides a useful representation of the object:

PYTHON
print(User("Alison", 38))
# User(name='Alison', age=38)
Click to expand and view more

This is especially useful for debugging, logs, and collections.

We can prevent a field from appearing:

PYTHON
from dataclasses import dataclass, field

@dataclass
class User:
    name: str
    password: str = field(repr=False)
Click to expand and view more

__eq__()

Allows value-based comparison:

PYTHON
user1 = User("Alison", 38)
user2 = User("Alison", 38)

user1 == user2
# True
Click to expand and view more

The default eq=True causes the Dataclass to generate this method.

Version note: in Python 3.13, the generated __eq__() implementation changed to compare fields individually instead of constructing tuples for comparison.


@dataclass - Decorator parameters

The current signature is:

PYTHON
@dataclass(
    *,
    init=True,
    repr=True,
    eq=True,
    order=False,
    unsafe_hash=False,
    frozen=False,
    match_args=True,
    kw_only=False,
    slots=False,
    weakref_slot=False,
)
Click to expand and view more

The most important parameters are:

ParameterWhat it doesDocumentation
initGenerates __init__()dataclasses
reprGenerates __repr__()dataclasses
eqGenerates __eq__()dataclasses
orderGenerates __lt__, __le__, __gt__, __ge__dataclasses
unsafe_hashForces generation of __hash__()dataclasses
frozenPrevents normal field assignmentsFrozen instances
match_argsControls __match_args__ for pattern matchingdataclasses
kw_onlyMakes fields keyword-onlydataclasses
slotsGenerates __slots__dataclasses
weakref_slotAdds __weakref__, requires slots=Truedataclasses

Default value vs. field(default=...) - What’s the difference?

These two declarations:

PYTHON
age: int = 18
Click to expand and view more

and:

PYTHON
age: int = field(default=18)
Click to expand and view more

produce, for this simple case, the same default value and the same __init__() parameter:

PYTHON
def __init__(self, age: int = 18):
    self.age = age
Click to expand and view more

The difference is purpose and flexibility.

When to use age: int = 18?

Use when you simply want a default value, with no extra configuration:

PYTHON
@dataclass
class User:
    name: str
    age: int = 18
Click to expand and view more

Advantage: shorter, more readable, sufficient for 90% of cases.

When to use field(default=18)?

Use when you also need to configure the behavior of that field:

PYTHON
from dataclasses import field

@dataclass
class User:
    name: str
    age: int = field(
        default=18,
        repr=False,      # doesn't appear in print()
        compare=False,   # doesn't participate in == comparison
    )
Click to expand and view more

In this example, age still defaults to 18, but:

What else does field() allow?

PYTHON
field(
    default=18,                    # simple default value
    default_factory=list,          # mutable default (function)
    init=False,                   # doesn't appear in __init__
    repr=False,                   # doesn't appear in __repr__
    compare=False,                # doesn't participate in ==, <, >
    hash=False,                   # doesn't participate in __hash__
    metadata={"description": "User age"},  # extra metadata
)
Click to expand and view more

Decision flowchart

PLAINTEXT
                    Do you want a default value?
                           │
           ┌───────────────┴───────────────┐
           ▼                               ▼
    Is it just a simple value?      Do you need extra configuration?
           │                               │
           ▼                               ▼
    age: int = 18                  age: int = field(
    (more readable)                     default=18,
                                        repr=False,
    ✅ Recommended                      compare=False,
    for beginners                       ...)

                                        ✅ Recommended
                                        when control is needed
Click to expand and view more

Fields with default values

PYTHON
@dataclass
class User:
    name: str
    age: int
    active: bool = True
Click to expand and view more

Then:

PYTHON
user = User("Alison", 38)
Click to expand and view more

uses True for active.

⚠️ Important rule: fields without defaults must appear before fields with defaults.


Mutable values and default_factory

❌ The problem

Avoid:

PYTHON
@dataclass
class ShoppingCart:
    items: list[str] = []   # DANGER! All objects share the same list
Click to expand and view more

This creates a single list at class definition time. All instances share that same list.

✅ The solution

For mutable values, use:

PYTHON
from dataclasses import field

@dataclass
class ShoppingCart:
    items: list[str] = field(default_factory=list)
Click to expand and view more

default_factory receives a zero-argument callable. It is called when a new instance needs the default value, creating a new and independent list for each object.

PYTHON
cart1 = ShoppingCart()
cart2 = ShoppingCart()

cart1.items.append("Book")

print(cart1.items)  # ['Book']
print(cart2.items)  # []  ← separate list!
Click to expand and view more

Why use __post_init__()?

__post_init__() is the extension point for a Dataclass’s automatic initialization. It is called automatically after the generated __init__() assigns all fields.

When to use it?

SituationExample
Calculate derived attributesarea = width * height
Validate combinations of fieldsif price < 0: raise ValueError
Normalize data after assignmentconvert string to uppercase
Initialization depending on multiple attributesfull_name = first + " " + last
Work with InitVarpass temporary values to init
Call a base class __init__()inheritance with dataclasses

Example 1: Derived attribute

PYTHON
from dataclasses import dataclass, field

@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)  # doesn't go into __init__()

    def __post_init__(self):
        self.area = self.width * self.height
Click to expand and view more

What happens under the hood when we create Rectangle(10, 5):

PLAINTEXT
1. Generated __init__() runs:
   self.width = 10
   self.height = 5

2. Automatically calls:
   self.__post_init__()

3. __post_init__() runs:
   self.area = 10 * 5  →  50.0
Click to expand and view more
PYTHON
rectangle = Rectangle(10, 5)
print(rectangle.area)  # 50.0
Click to expand and view more

Example 2: Validation

PYTHON
@dataclass
class Product:
    name: str
    price: float

    def __post_init__(self):
        if self.price < 0:
            raise ValueError("Price cannot be negative")
        if not self.name.strip():
            raise ValueError("Name cannot be empty")
Click to expand and view more
PYTHON
# Works normally
product = Product("Laptop", 1500.00)

# Raises error at creation time
product = Product("Laptop", -100)
# ValueError: Price cannot be negative
Click to expand and view more

Example 3: Data normalization

PYTHON
@dataclass
class User:
    name: str
    email: str

    def __post_init__(self):
        self.name = self.name.strip().title()
        self.email = self.email.strip().lower()
Click to expand and view more
PYTHON
user = User("  alison  ", "  Alison@Email.COM  ")
print(user.name)   # Alison
print(user.email)  # alison@email.com
Click to expand and view more

⚠️ Important

__post_init__() is called automatically only when the Dataclass generates __init__(). If you use init=False or write your own __init__(), the automatic call does not happen.


Custom methods

Dataclasses remain normal Python classes:

PYTHON
@dataclass
class Product:
    name: str
    price: float

    def apply_discount(self, percentage: float) -> None:
        self.price *= 1 - percentage / 100

    @property
    def price_with_tax(self) -> float:
        return self.price * 1.10
Click to expand and view more

We can add properties, methods, validation, and other functionality normally.


frozen=True - Immutable instances

PYTHON
from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float
Click to expand and view more

After creation:

PYTHON
point = Point(10, 20)
point.x = 30
# FrozenInstanceError: cannot assign to field 'x'
Click to expand and view more

This provides a way to emulate read-only instances.

⚠️ Attention: frozen=True does not make nested objects deeply immutable. A list stored in a field remains a mutable list.


order=True - Object ordering

PYTHON
@dataclass(order=True)
class Product:
    price: float
Click to expand and view more

Generates:

PYTHON
__lt__   # less than (<)
__le__   # less than or equal (<=)
__gt__   # greater than (>)
__ge__   # greater than or equal (>=)
Click to expand and view more

Allows:

PYTHON
Product(10) < Product(20)
# True
Click to expand and view more

⚠️ Only use order=True when ordering genuinely makes sense for the domain. Not every entity has a useful concept of “less than” and “greater than”.


slots=True - Memory optimization

PYTHON
@dataclass(slots=True)
class User:
    name: str
    age: int
Click to expand and view more

slots=True creates a Dataclass based on __slots__, which can:


weakref_slot=True

This is a more advanced feature:

PYTHON
@dataclass(slots=True, weakref_slot=True)
class User:
    name: str
Click to expand and view more

It adds __weakref__ and requires slots=True.


kw_only=True - Keyword-only arguments

PYTHON
@dataclass(kw_only=True)
class User:
    name: str
    age: int
Click to expand and view more

Now:

PYTHON
User(name="Alison", age=38)   # ✅ valid
User("Alison", 38)             # ❌ TypeError
Click to expand and view more

Useful to avoid confusion with argument order.


Helper functions from the dataclasses module

The module provides useful helper functions:

PYTHON
from dataclasses import (
    asdict,
    astuple,
    fields,
    replace,
    is_dataclass,
)
Click to expand and view more

asdict() - Convert to dictionary

PYTHON
user = User(name="Alison", age=38)

asdict(user)
# {"name": "Alison", "age": 38}
Click to expand and view more

astuple() - Convert to tuple

PYTHON
astuple(user)
# ("Alison", 38)
Click to expand and view more

fields() - Inspect fields

PYTHON
for item in fields(User):
    print(item.name, item.type, item.default)

# name <class 'str'> <dataclasses._MISSING_TYPE object>
# age <class 'int'> 18
Click to expand and view more

replace() - Create a modified copy

PYTHON
updated = replace(user, age=39)
# User(name='Alison', age=39)  - original user is unchanged!
Click to expand and view more

replace() creates a new instance and goes through __init__() and, consequently, __post_init__().

is_dataclass() - Check if it is a dataclass

PYTHON
is_dataclass(User)     # True
is_dataclass(user)     # True (instance also works)
is_dataclass(str)      # False
Click to expand and view more

Dataclass vs. regular class vs. NamedTuple

FeatureRegular classDataclassNamedTuple
BoilerplateHigherLowerLower
MutabilityUsually yesYes by defaultNo
Custom methodsExcellentExcellentPossible
Value equalityManualAutomaticYes
Default valuesManualYesYes
Tuple semanticsNoNoYes
Field controlMaximumHighMore limited
Performance (slots)YesYes (slots=True)Yes (inherent)

When to use each one?

PLAINTEXT
┌─────────────────────────────────────────────────────────────────┐
│  Does the class PRIMARILY represent structured data?            │
│                      │                                          │
│         ┌────────────┴────────────┐                           │
│         ▼                         ▼                           │
│        YES                        NO                           │
│         │                         │                           │
│         ▼                         ▼                           │
│  ┌──────────────┐        ┌─────────────────┐                │
│  │  Do you need │        │  Regular class  │                │
│  │  immutability│        │  (full control  │                │
│  │  or tuple    │        │  of behavior)   │                │
│  │  semantics?  │        └─────────────────┘                │
│  │      │        │                                           │
│  │  ┌───┴───┐    │                                           │
│  │  ▼       ▼    │                                           │
│  │ YES     NO    │                                           │
│  │  │      │     │                                           │
│  │  ▼      ▼     │                                           │
│  │ NamedTuple  Dataclass                                      │
│  └───────────────┘                                           │
└─────────────────────────────────────────────────────────────────┘
Click to expand and view more

Practical use cases

DTO (Data Transfer Object)

PYTHON
@dataclass
class UserDTO:
    id: int
    name: str
    email: str
Click to expand and view more

Configuration

PYTHON
@dataclass
class DatabaseConfig:
    host: str
    port: int = 5432
    database: str = "app"
    ssl: bool = True
Click to expand and view more

Processing result

PYTHON
@dataclass
class ProcessingResult:
    success: bool
    processed: int
    errors: int
Click to expand and view more

Internal model with logic

PYTHON
@dataclass
class OrderItem:
    product_id: int
    quantity: int
    unit_price: float

    @property
    def total(self) -> float:
        return self.quantity * self.unit_price

    def __post_init__(self):
        if self.quantity <= 0:
            raise ValueError("Quantity must be positive")
Click to expand and view more

Common mistakes and pitfalls

❌ Do not use mutable lists, dictionaries, or sets directly as defaults

PYTHON
# WRONG
items: list[str] = []

# RIGHT
items: list[str] = field(default_factory=list)
Click to expand and view more

❌ Do not confuse frozen=True with deep immutability

PYTHON
@dataclass(frozen=True)
class Container:
    items: list[str]

container = Container(["a", "b"])
container.items.append("c")  # Works! The internal list is mutable
Click to expand and view more

❌ Do not use unsafe_hash=True without understanding hashing and equality

Hash must be consistent with the object’s equality semantics.

❌ Do not use order=True without a natural ordering

Not every entity has a useful concept of “less than” and “greater than”.

❌ Do not turn every class into a Dataclass

A Dataclass is a tool, not an architectural rule.


A Dataclass is not an ORM

A Dataclass is not automatically a database model.

It can represent a DTO, a domain object, a configuration, or a service result, but it does not replace persistence mechanisms such as Django ORM or SQLAlchemy.

PLAINTEXT
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   Database      │     │   SQLAlchemy/   │     │   Dataclass     │
│                 │◄────│   Django ORM    │────►│   (DTO/Model)   │
│                 │     │                 │     │                 │
└─────────────────┘     └─────────────────┘     └─────────────────┘
       ↑                                               ↓
       └──────────── Persistence ─────────────────────┘

Dataclass = data representation
ORM       = persistence and querying
Click to expand and view more

Conclusion

Dataclasses were introduced in Python 3.7 through PEP 557 to reduce boilerplate and make data-oriented classes more declarative.

The most important concepts are:

The main point is not simply to write less code.

It is about clearly declaring the data structure and letting Python generate mechanical behavior when it makes sense.


References

Start searching

Enter keywords to search articles

↑↓
ESC
⌘K Shortcut