python dataclass

A Python dataclass is not a base class and not a runtime library — it is a code generator that runs once. The `@dataclass` decorator reads your annotations, assembles the source text of `__init__`, `__repr__`, `__eq__` and `__hash__` as a Python string, compiles that string with `exec`, and attaches the resulting functions to the class you already wrote. The proof is one attribute: `P.__init__.__code__.co_filename` reads ``. Building the class costs 136.77 µs at import, and building an instance afterwards costs 88.01 ns — the identical 88.01 ns of the hand-written class it replaced. ```svg The Decorator Is a Compiler, and It Runs Once a two-field class, built by hand and built by @dataclass, on CPython 3.12.3 RUNTIME: BUILD ONE INSTANCE @dataclass 88.01 ns hand-written __init__ 88.01 ns the bars are the same length because the generated code is the code you would have written yourself IMPORT: BUILD THE CLASS ITSELF @dataclass 136.77 µs hand-written class 3.60 µs 38.0x, and it is the string formatting, the compile and the exec — paid once per class, never again One option changes the object rather than the class: slots=True 344 bytes per instance becomes 48 bytes — an 86.0% cut, because the 296-byte __dict__ disappears. Across a million records that is 296 MB of memory that is simply never allocated. frozen=True is the costly one: 198.77 ns to build, 2.26x, because every field routes through object.__setattr__. CPython 3.12.3, x86-64 Linux, minimum of seven runs of one million operations ``` **The decorator writes Python source text and then executes it.** PEP 557 added `dataclasses` to the standard library in Python 3.7, and the implementation is deliberately unglamorous: the decorator walks `__annotations__` — the class-body annotation syntax PEP 526 introduced in 3.6 — turns each entry into a `Field` object, and then builds the body of each method by string concatenation. `__init__` is assembled as literal text, handed to `exec` with a namespace containing the defaults, and bound to the class. That is why the generated function reports `` as its filename, and why `inspect.signature(P.__init__)` returns a fully typed `(self, x: int, y: int) -> None` rather than an opaque variadic placeholder: the signature is real because the function is real. Nothing is intercepted at call time, nothing is proxied, and no metaclass is involved. PEP 681 later added `dataclass_transform` in Python 3.11 so that `attrs` and Pydantic could tell type checkers they perform the same trick, which is an admission that the trick, not the library, is the interesting part. **At runtime a dataclass costs exactly nothing, because there is nothing left to cost.** Instantiating a two-field dataclass takes 88.01 ns; instantiating an otherwise identical class with a hand-written `__init__` takes 88.01 ns. Reading an attribute costs 7.27 ns against 7.28 ns, and writing one costs 7.30 ns against 7.30 ns. These are not close numbers, they are the same numbers, and the reason is structural rather than lucky — the generated `__init__` is a hand-written `__init__`, produced by a program instead of a person and compiled by the same compiler to the same bytecode. Any performance intuition that treats `@dataclass` as a wrapper, a proxy or a layer is measuring something that does not exist. The comparison worth making is not dataclass against plain class, it is dataclass against the options the decorator exposes, because those genuinely differ. **The entire price is paid once, at import.** Creating the class costs 136.77 µs with the decorator against 3.60 µs for a hand-written equivalent, a factor of 38.0, and that gap is the string building, the `compile` and the `exec`. For a module defining a hundred dataclasses this is roughly 13.7 ms of import time that a hand-written module would not spend, which is invisible in a long-lived service and quite visible in a command-line tool or a cold-start function where total startup budget is tens of milliseconds. The mitigation is not to abandon the decorator but to stop importing modules eagerly, because the cost attaches to class creation and class creation happens at import. It is worth noticing the shape of the trade rather than only its size: 38.0x sounds alarming and 133.17 µs per class does not, and the second framing is the one that tells you whether to care. **Setting slots=True changes the object itself, not merely the class.** A default dataclass instance occupies 344 bytes, which is a 48-byte object plus a 296-byte `__dict__`; the same class declared with `slots=True` occupies 48 bytes and has no `__dict__` at all, an 86.0% reduction. Across a million instances that is 296 MB of memory that is never allocated, which is the difference between a data pipeline that fits in a container and one that does not. Both forms scale linearly, so the option is a constant factor rather than a change of complexity class, $$M_{\text{default}} \;=\; 344n\ \text{bytes} \qquad\text{against}\qquad M_{\text{slots}} \;=\; 48n\ \text{bytes}$$ and the factor is 7.17 for as long as the objects live. Construction gets slightly faster too, at 81.49 ns against 88.01 ns, because there is no dictionary to create — and the hand-rolled equivalent lands at 81.67 ns, confirming again that the decorator adds nothing. The one sharp edge is worth naming: `__slots__` cannot be attached to a class after it exists, so `slots=True` makes the decorator build and return a brand-new class object, and anything that captured a reference to the original — a decorator applied above it, a registry, a previously created subclass — is now pointing at a different class than the one the name refers to. **Immutability is the expensive option, and it costs only at construction.** A frozen dataclass takes 198.77 ns to instantiate against 88.01 ns for a mutable one, a factor of 2.26, because `frozen=True` overrides `__setattr__` to raise, which means the generated `__init__` can no longer assign fields normally and has to route every single one through `object.__setattr__`. Reading is completely unaffected at 7.28 ns, so the tax is per construction rather than per access, and `frozen=True, slots=True` together land at 193.20 ns. What you buy is a generated `__hash__`, and therefore instances that work as dictionary keys and set members, which a mutable dataclass deliberately does not get because Python sets `__hash__` to `None` the moment it generates `__eq__`. If you build a few thousand configuration objects and then read them a billion times, 110.76 ns each at construction is not a cost worth thinking about. **Generated equality compares values, and that is a behaviour change rather than an optimisation.** The default `__eq__` on a plain class is identity, which costs 13.46 ns; the generated one builds a tuple of each side's fields and compares them, which costs 91.80 ns, a factor of 6.82. A raw tuple comparison of the same two fields costs 16.73 ns, so most of the difference is attribute collection rather than comparison. The generated `__repr__` shows the same pattern at 310.68 ns against 112.16 ns for a hand-written f-string, 2.77 times, because it walks the field list rather than interpolating known names. Both of these are usually the right trade — value equality is what you wanted, or you would not have reached for a dataclass — but they belong in the "changed the semantics" column and not the "free" column, and `__eq__` in particular is now on the hot path of every `in` test against a list of your objects. **A dataclass is not a tuple, and the difference runs in both directions.** A `NamedTuple` with the same two fields costs 134.09 ns to build, 1.52 times the dataclass, and 16.18 ns to read an attribute, 2.23 times slower, because each field access goes through a property descriptor rather than a dictionary or slot lookup — so the immutable-and-indexable option is the slower one on the operation you perform most. Below both of them a plain tuple builds in 5.88 ns and a dict literal in 38.16 ns, which is the honest floor: if you truly need ten million records in memory and never call a method on them, 5.88 ns and no per-instance overhead beats every named alternative on every axis except the one that made you write a class in the first place. The decision is therefore about which of four properties you actually need — names, mutability, value equality, or compactness — and the options map onto them cleanly rather than forming a single ranking. | Construction | Instantiate | Attribute read | Per instance | What it actually buys | |---|---|---|---|---| | Plain class | 88.01 ns | 7.28 ns | 344 bytes | nothing generated, you maintain every method | | `@dataclass` | 88.01 ns | 7.27 ns | 344 bytes | four methods, and 0 ns of runtime overhead | | `@dataclass(slots=True)` | 81.49 ns | 7.72 ns | 48 bytes | 296 MB saved per million, no new attributes | | `@dataclass(frozen=True)` | 198.77 ns | 7.28 ns | 344 bytes | a real `__hash__`, at 2.26x construction cost | | `NamedTuple` | 134.09 ns | 16.18 ns | tuple-backed | indexing and immutability, 2.23x slower reads | | Plain tuple | 5.88 ns | — | smallest possible | speed, at the price of every name and method | ```flowchart { "rows": [ { "type": "nodes", "items": [ { "title": "class body with annotations", "sub": "PEP 526 syntax, no assignments needed", "tone": "neutral" }, { "title": "@dataclass is applied", "sub": "once, at import time", "tone": "neutral" } ] }, { "type": "arrow" }, { "type": "group", "title": "136.77 microseconds of code generation, per class", "note": "38.0x a hand-written class, and it never happens again", "items": [ { "title": "read __annotations__", "sub": "each entry becomes a Field object", "tone": "green" }, { "title": "build method text as a string", "sub": "__init__, __repr__, __eq__, __hash__", "tone": "green" }, { "title": "compile and exec that string", "sub": "co_filename is literally ", "tone": "green" }, { "title": "options that change the object", "sub": "slots=True rebuilds the class, frozen=True rewrites __setattr__", "tone": "orange" } ] }, { "type": "arrow" }, { "type": "nodes", "items": [ { "title": "an ordinary class", "sub": "88.01 ns to instantiate, 7.27 ns to read", "tone": "green" }, { "title": "with changed semantics", "sub": "value equality at 91.80 ns, not identity at 13.46 ns", "tone": "orange" } ] } ] } ``` Read a dataclass through a *code generation* lens rather than an *inheritance* lens: you are not subclassing anything and you are not calling into a library at runtime, you are asking a program to write the boilerplate you would otherwise type, at a fixed cost of 136.77 µs per class and zero cost per call. Every question in this space answers itself from that one substitution — the 88.01 ns tie is the generated code being ordinary code, the 38.0x import gap is a compiler running, the 344-byte-to-48-byte collapse under `slots=True` is the only knob that reaches past the class into the instances, the 2.26x on `frozen=True` is a rewritten `__setattr__` being paid at construction, and the jump from 13.46 ns to 91.80 ns on equality is a semantic decision that merely happens to have a price. Decide which of the four properties you need, and the option list stops being a set of flags to memorise.

Go deeper with CFSGPT

Get AI-powered deep-dives, save terms, and run advanced simulations — free account.

Create Free Account