Home Knowledge Base The decorator writes Python source text and then executes it.

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 viewBox="0 0 760 470" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,Segoe UI,Roboto,sans-serif">
  <rect x="0" y="0" width="760" height="470" fill="#0d1117"/>
  <text x="380" y="32" fill="#e6edf3" font-size="21" font-weight="700" text-anchor="middle">The Decorator Is a Compiler, and It Runs Once</text>
  <text x="380" y="54" fill="#8b98a5" font-size="13" text-anchor="middle">a two-field class, built by hand and built by @dataclass, on CPython 3.12.3</text>

  <rect x="20" y="76" width="350" height="196" fill="#161b22" stroke="#30363d" stroke-width="1"/>
  <text x="34" y="98" fill="#8b98a5" font-size="12" font-weight="700">RUNTIME: BUILD ONE INSTANCE</text>
  <text x="34" y="126" fill="#8b98a5" font-size="11">@dataclass</text>
  <rect x="34" y="132" width="310" height="30" fill="#34d399"/>
  <text x="189" y="152" fill="#0d1117" font-size="14" font-weight="700" text-anchor="middle">88.01 ns</text>
  <text x="34" y="188" fill="#8b98a5" font-size="11">hand-written __init__</text>
  <rect x="34" y="194" width="310" height="30" fill="#58a6ff"/>
  <text x="189" y="214" fill="#0d1117" font-size="14" font-weight="700" text-anchor="middle">88.01 ns</text>
  <text x="34" y="243" fill="#8b98a5" font-size="10.5">the bars are the same length because the generated</text>
  <text x="34" y="259" fill="#8b98a5" font-size="10.5">code is the code you would have written yourself</text>

  <rect x="390" y="76" width="350" height="196" fill="#161b22" stroke="#30363d" stroke-width="1"/>
  <text x="404" y="98" fill="#8b98a5" font-size="12" font-weight="700">IMPORT: BUILD THE CLASS ITSELF</text>
  <text x="404" y="126" fill="#8b98a5" font-size="11">@dataclass</text>
  <rect x="404" y="132" width="310" height="30" fill="#f85149"/>
  <text x="559" y="152" fill="#0d1117" font-size="14" font-weight="700" text-anchor="middle">136.77 µs</text>
  <text x="404" y="188" fill="#8b98a5" font-size="11">hand-written class</text>
  <rect x="404" y="194" width="8" height="30" fill="#34d399"/>
  <text x="422" y="214" fill="#6ee7b7" font-size="14" font-weight="700">3.60 µs</text>
  <text x="404" y="243" fill="#8b98a5" font-size="10.5">38.0x, and it is the string formatting, the compile</text>
  <text x="404" y="259" fill="#8b98a5" font-size="10.5">and the exec — paid once per class, never again</text>

  <rect x="20" y="288" width="720" height="98" fill="#161b22" stroke="#30363d" stroke-width="1"/>
  <text x="380" y="314" fill="#e6edf3" font-size="14" font-weight="700" text-anchor="middle">One option changes the object rather than the class: slots=True</text>
  <text x="380" y="340" fill="#8b98a5" font-size="12.5" text-anchor="middle">344 bytes per instance becomes 48 bytes — an 86.0% cut, because the 296-byte __dict__ disappears.</text>
  <text x="380" y="364" fill="#8b98a5" font-size="12.5" text-anchor="middle">Across a million records that is 296 MB of memory that is simply never allocated.</text>

  <text x="380" y="412" fill="#8b98a5" font-size="12" text-anchor="middle">frozen=True is the costly one: 198.77 ns to build, 2.26x, because every field routes through object.__setattr__.</text>
  <text x="380" y="444" fill="#8b98a5" font-size="11" text-anchor="middle">CPython 3.12.3, x86-64 Linux, minimum of seven runs of one million operations</text>
</svg>

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.

ConstructionInstantiateAttribute readPer instanceWhat it actually buys
Plain class88.01 ns7.28 ns344 bytesnothing generated, you maintain every method
@dataclass88.01 ns7.27 ns344 bytesfour methods, and 0 ns of runtime overhead
@dataclass(slots=True)81.49 ns7.72 ns48 bytes296 MB saved per million, no new attributes
@dataclass(frozen=True)198.77 ns7.28 ns344 bytesa real __hash__, at 2.26x construction cost
NamedTuple134.09 ns16.18 nstuple-backedindexing and immutability, 2.23x slower reads
Plain tuple5.88 nssmallest possiblespeed, at the price of every name and method
{ "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 <string>", "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.

python dataclassdataclassdataclassespython dataclassesfrozen dataclassdataclass slotspython data classdataclass fieldnamedtuplepython namedtupleattrs pythonslots python

Explore 500+ Semiconductor & AI Topics

From EUV lithography to CUDA optimization — search the full knowledge base or chat with our AI assistant.