AlgoMaster Logo

Data Classes

Medium Priority14 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

This section is about two small standard-library tools that make data-shaped classes pleasant to write: dataclasses and enum. The first one is @dataclass, a decorator that takes a class with type-annotated attributes and writes the boring methods for you: __init__, __repr__, and __eq__. This chapter covers the decorator at its plainest, the kind of class you'd reach for to model a Product, a Customer, or an Order line.

Why dataclasses exist

A lot of classes in real code are essentially containers: a name, a price, a stock count, maybe an is_active flag. The behavior is "hold those values, print nicely, compare by content." Writing that by hand is repetitive. Here's the plain-class version of a Product:

Three attributes, fifteen lines, and three of those methods are pure boilerplate. The constructor just copies parameters onto self. The repr restates every field. The equality method compares every field. None of that code says anything interesting about a product; it's the same shape every class with three fields would have.

The @dataclass decorator, added in Python 3.7, looks at a class with type-annotated attributes and writes those methods for you. Same class:

That's the whole class. The decorator inspects the class body, finds three annotated attributes, and generates __init__, __repr__, and __eq__ with the same behavior as the hand-written version. The fields you list in the class body become the parameters to __init__, in order.

The class is still a normal Python class. You can add methods, inherit from it, and use it anywhere a regular class fits. The decorator just removes the typing you'd otherwise repeat.

Your first dataclass

Let's build one and use it:

A few things to notice in that output. Creating an instance with Product("Running Shoes", 79.99, 12) worked because the decorator built an __init__ that takes the three fields in the order they appear in the class body. Printing the object gave a useful, multi-field representation instead of <__main__.Product object at 0x10a3b4c10>, because the decorator also generated __repr__. The attributes are accessible on the instance the same way they would be on any other class.

Keyword arguments work too, and they tend to read better when the order isn't obvious:

The fields are positional by default, so positional arguments work in declaration order, and keyword arguments work by name. That's the same rule any normal function follows, because __init__ is just a normal function the decorator wrote for you.

What @dataclass generates

It's worth knowing exactly what the decorator gives you, because every dataclass in the rest of this section builds on these three methods.

Method generatedWhat it does
__init__(self, ...)Takes one parameter per field, in declaration order, and assigns each to self
__repr__(self)Returns ClassName(field1=value1, field2=value2, ...) for printing and debugging
__eq__(self, other)Returns True when other is the same class and every field compares equal

Equality is the part people are most surprised by. Two instances of a plain class are unequal by default, even when every field has the same value. With @dataclass, equality compares by content:

a and b are different objects in memory (so a is b is False), but they have identical field values, so the generated __eq__ returns True. Change one field and equality breaks. This is what most code wants for value-shaped types: two orders with identical contents should compare equal.

The generated __repr__ is the other quality-of-life win. Without it, printing an object gives you a useless memory-address line. With it, you see the actual contents:

Same data, very different debugging experience. The plain class would need a hand-written __repr__ to get parity, which is exactly the boilerplate @dataclass removes.

Type hints are required

Here's the rule that trips people up first: @dataclass only treats an attribute as a field when it has a type annotation. The annotation can be anything (str, int, float, list, even object), but it has to be there.

This works:

This one doesn't behave the way you'd expect:

No order_id, no total, and Order() takes no arguments. The decorator ignored both lines because neither had a type annotation, so it found zero fields and generated an __init__ with no parameters. The attributes are still on the class as class-level constants, but they're not part of the instance contract.

Try to pass arguments and you get a clear error:

The fix is to add annotations:

The annotations don't enforce types at runtime. You can still pass Order(123, "free") and Python won't object; the decorator uses the annotations as a list of fields, not as a runtime type check. Static type checkers like mypy and pyright use them, and editors use them for autocomplete, but @dataclass itself treats them as field declarations.

Default values

A field can have a default value the same way a function parameter can: write it after the type annotation with =.

Defaults follow the same rule as normal Python function parameters: every field with a default has to come after every field without one. The decorator builds __init__ in declaration order, and Python doesn't allow a non-default parameter after a default. If you put stock: int = 0 before price: float, the decorator raises an error at class-definition time:

That error fires when Python tries to define the class, not when you create an instance. Move every defaulted field to the bottom and the class compiles cleanly.

Defaults only work this way for simple immutable values like numbers, strings, booleans, and None. Mutable defaults like [], {}, or set() are a separate topic with their own gotcha, because writing items: list = [] shares one list across every instance. The fix uses field(default_factory=list). For this chapter, stick to simple immutable defaults.

Equality and comparison

The generated __eq__ compares two instances field by field, in declaration order. Two instances are equal when they're the same class and every field is equal.

That's enough to support a lot of useful patterns. Checking whether two carts contain the same line items, deduplicating a list of orders with set won't work yet (we need hashability for that, and the default dataclass isn't hashable), but plain == comparison and in checks for lists already work:

The in check uses __eq__ under the hood. Because the dataclass equality compares contents, you can ask "is this exact item already in the cart?" without writing a loop. Without @dataclass, that check would fail because plain-class == falls back to identity comparison.

Equality is also class-aware. Two dataclasses with the same field names and values but different classes are not equal:

The generated __eq__ checks the type first. A cart item and a wishlist item with the same contents are still different things, so equality returns False. That's the behavior almost every value class wants.

What @dataclass does not generate by default is ordering (<, <=, >, >=). Trying to sort or compare order is a TypeError:

Ordering is opt-in via @dataclass(order=True), which is one of the decorator options alongside frozen=True and slots=True. For now, the rule is: == and != work out of the box, ordering does not.

Quiz

Data Classes Quiz

10 quizzes