Chapter 17 of 24 · Python for Beginners — Complete Free Tutorial

Dataclasses and Special Methods

The Course class in the previous chapter needed a dozen lines to store two pieces of data. Multiply that by every record in a program and the boilerplate becomes unbearable. Chapter 17 of the Python for Beginners 24-chapter series shows two answers: @dataclass, which generates the boring methods for you, and special methods (dunders), which let your objects work with Python’s built-in syntax.

Why plain classes get verbose

Ask a plain class for good manners and you write the same methods by hand every time: __init__ to store the fields, __repr__ so the interactive prompt shows something useful, __eq__ so two records with the same fields compare equal, plus a matching __hash__ if the object will ever be a dictionary key. None of it is interesting, and all of it is easy to get subtly wrong — usually the __eq__ that forgets a field. Dataclasses exist to delete that code.

@dataclass in one decorator

Declare the fields as annotations and let the decorator generate __init__, __repr__ and __eq__ from them:

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

p1 = Point(2, 3)
p2 = Point(2, 3)
print(p1)
print(p1 == p2)
print(p1 is p2)

Expected output:

Point(x=2, y=3)
True
False

Two objects, equal but not identical — six lines of behaviour for two lines of declaration. The annotations are declarations, not conversions: Point("2", "3") happily stores strings, and nothing here is checked at runtime.

Defaults, frozen and equality

Defaults work as you would expect, and a few flags change what gets generated. eq=True (the default) generates __eq__, which in turn sets __hash__ to None so mutable instances cannot be dictionary keys. frozen=True makes instances immutable and therefore hashable — a value object you can safely share. And mutable defaults need field(default_factory=...), because a plain tags: list = [] is rejected outright.

from dataclasses import dataclass, field

@dataclass(frozen=True)
class Config:
    debug: bool = False
    retries: int = 3
    tags: tuple = field(default_factory=tuple)

cfg = Config()
print(cfg)
cfg.debug = True

Expected output:

Config(debug=False, retries=3, tags=())
FrozenInstanceError: cannot assign to field 'debug'

Should you try the list default anyway, the definition itself fails with ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory — caught at import time rather than three in the morning.

__post_init__: validating in a dataclass

Generation removes boilerplate, not rules. __post_init__ runs immediately after the generated __init__, the natural home for validation and normalisation — it may reassign the fields it is given, which is why cleanup belongs here rather than in an __init__ the dataclass wrote for you.

from dataclasses import dataclass

@dataclass
class Player:
    name: str
    score: int = 0

    def __post_init__(self):
        if self.score < 0:
            raise ValueError("score cannot be negative")
        self.name = self.name.strip().title()

print(Player("  ada lovelace ", 120))
Player("bad", -1)

Expected output:

Player(name='Ada Lovelace', score=120)
ValueError: score cannot be negative

The special-method catalogue

Special methods are the hooks Python calls on your behalf when ordinary syntax shows up. Knowing the names is enough to recognise what other people’s classes can do:

MethodTriggered by
__repr__ / __str__repr(obj), the prompt and debugging tools; str(obj) and print(obj)
__eq__ / __hash__==, and hash(obj) for sets and dictionary keys
__len__ / __getitem__len(obj) and obj[key]
__iter__for item in obj — the next chapter in depth
__call__obj(...) — instances that behave like functions

A worked example implementing six of them, so you can see repr and str diverge:

class Playlist:
    def __init__(self, name, tracks):
        self.name = name
        self._tracks = list(tracks)

    def __repr__(self):
        return f"Playlist({self.name!r}, tracks={len(self._tracks)})"

    def __str__(self):
        return f"{self.name} - {len(self._tracks)} tracks"

    def __len__(self):
        return len(self._tracks)

    def __getitem__(self, index):
        return self._tracks[index]

    def __iter__(self):
        return iter(self._tracks)

    def __call__(self, greeting="hello"):
        return f"{greeting}, {self.name}!"

mix = Playlist("Focus", ["a", "b", "c"])
print(repr(mix))
print(str(mix))
print(mix)
print(len(mix), mix[1], list(mix))
print(mix())

Expected output:

Playlist('Focus', tracks=3)
Focus - 3 tracks
Focus - 3 tracks
3 b ['a', 'b', 'c']
hello, Focus!

repr is written for developers — unambiguous, ideally copy-pasteable into code. str is written for people. When a class defines only one, Python uses __repr__ as the fallback; here both exist, so print(mix) shows the friendly version while the prompt would show the precise one.

Operator dunders and named tuples

The same mechanism powers operators: defining __add__ and __lt__ makes + and < work, as a frozen Money(amount=10) + Money(2.5) returning Money(amount=12.5, currency='GBP') proves. An older, narrower alternative is collections.namedtuple — a tuple subclass with named fields:

Featurenamedtuple@dataclass
ImmutableAlwaysOnly with frozen=True
Validation hookNo__post_init__
Methods and propertiesAwkwardOrdinary class behaviour
Also a tupleYes — unpacking and len()No

Reach for namedtuple when you want a lightweight immutable record that behaves like a tuple, and for @dataclass everything else. Coming from TypeScript, compare declaring shapes in interfaces and type aliases: an interface is erased at run time, whereas a dataclass generates real methods that still exist when the program runs.

Common mistakes

  • Assigning to a field of a frozen=True instance — it raises FrozenInstanceError by design; build a new instance instead of mutating the old one.
  • Declaring tags: list = [] — dataclasses reject shared mutable defaults at definition time; use field(default_factory=list).
  • Assuming annotations are checked — score: int does not stop a string arriving; add __post_init__ validation when the invariant matters.
  • Putting an unhashed dataclass in a set — eq=True without frozen=True sets __hash__ to None; freeze the object or opt out of equality.
  • Hand-writing what a decorator generates — __repr__ without __eq__ is the boilerplate dataclasses exist to remove; drop to dunders only for behaviour they cannot know.

Practice checklist

  1. Write a hand-rolled Point with __init__, __repr__ and __eq__, replace it with a two-line dataclass, and list which methods disappeared.
  2. Make it frozen=True, put three instances in a set, and confirm duplicates collapse to one.
  3. Add __post_init__ that rejects a negative coordinate, and read the ValueError it raises.
  4. Build a Playlist with __len__, __getitem__, __iter__ and __call__, then run every line of the worked example against it.
  5. Model the same record as both a namedtuple and a dataclass; note which accepts a default list and which unpacks with x, y = point.

What's next?

Your objects can now be created, compared and printed by Python itself. Next, take control of how they are looped over — from the raw iteration protocol to lazy pipelines in Iterators and Generators.

Quiz: Dataclasses and Special Methods

5 questions · 70% to pass

Frequently Asked Questions

Python is a general-purpose language first released in 1991, designed around one idea: code is read far more often than it is written. English keywords and indentation instead of braces make it readable, the standard library covers files, JSON, dates and testing, and pip adds thousands more packages. The same language handles scripts, web APIs, data work and machine learning.

None at all. Python is widely considered the friendliest first language: no declaration keywords, no braces, no build step, and error messages that name the problem. This series begins at your first print() call. If you already read JavaScript or TypeScript you will adjust quickly, but no previous language is assumed anywhere in these 24 chapters.

Python 3 only, any release from 3.12 onwards. Python 2 reached end of life in 2021 and its print "hi" statements and xrange() no longer run. One historical quirk remains: installers ship the interpreter as python3 on macOS and Linux, while Windows usually offers python or the py launcher, and every command in this series works with that one-word swap.

More than you might expect. Everyday automation such as renaming files, cleaning spreadsheets or scraping pages; web backends with Django, Flask or FastAPI; data analysis and machine learning with pandas, NumPy and PyTorch; DevOps tooling, desktop apps and games. Python is also the classic glue language, calling C, Rust or Fortran libraries from short readable scripts.

Pure Python can run 10 to 100 times slower than C, yet that rarely blocks you: heavy numeric work lives in C extensions such as NumPy, which Python coordinates. Dynamic typing does push errors to runtime, surfacing as a TypeError on the line that finally uses the bad value. Type hints checked by mypy move those mistakes back into your editor.

Only the interpreter. Install CPython from python.org, version 3.12 or newer, confirm it with python3 --version, and open a plain text editor; VS Code with the Python extension is the recommendation here. The early chapters use no third-party packages, so there is nothing else to set up until virtual environments and pip arrive.

A dict is a real runtime mapping: keys may be any hashable value rather than only strings, insertion order has been guaranteed since Python 3.7, and a missing key raises KeyError unless you ask with get(). JavaScript objects coerce every key to a string and carry a prototype chain. A TypeScript interface is erased at compile time and describes shape only; TypedDict is Python's checked equivalent.

Reach for a list when items will be added, removed or reordered: it is ordered, mutable and rich with methods. Pick a tuple for fixed records and function returns, because it is immutable and hashable, so it can be a dictionary key. Choose a set for unique values and fast membership checks; it has no order and de-duplicates on creation.

Yes, gradually. Annotate public function signatures first; the interpreter ignores hints at runtime, so a checker such as mypy is what gives them teeth. Add tests the same way: pytest collects any test_*.py file and plain assert statements, and a suite that finishes in a tenth of a second is one you will run after every change. Both habits cost little now and much more later.

JavaScript and TypeScript keep the browser, so anything rendered to the user stays theirs. Python takes the server: Django for full-featured applications, Flask for small ones, FastAPI for typed async APIs where your annotations become automatic documentation. Your TypeScript habits transfer directly, and asyncio mirrors the event loop you already know, just with different keywords.