skip to content

Dataclasses and Value Types

The standard-library answer to boilerplate value classes: @dataclass writes __init__, __repr__ and __eq__ from your annotations, with flags for immutability, ordering and slots. Interviewers ask when one fits.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

16

In a typing.NamedTuple class, how do you give fields defaults and add methods?

level: juniorimportance: must knowfreq 65%

answer

  1. Two kinds of name in the body
  2. Only annotated names become fields
  3. It compiles down to a signature
  4. Defaults live on __new__.__defaults__
  5. Defaulted fields must be last

basics

~20 s

Write the record as a class: an annotated name with an assigned value becomes that field's default, and ordinary def statements become real methods on the generated tuple subclass. Defaulted fields must come last, exactly as with function parameters.

solid answer

~50 s

`typing.NamedTuple` supports a class body, and that body is split by kind. Annotated names become the fields, in declaration order; an annotated name that is also assigned a value supplies a default for the generated `__new__`; and anything else — `def`s, `property` objects, unannotated constants — is copied straight onto the generated `tuple` subclass. So defaults and behaviour live in one readable block. The ordering rule is not tuple-specific: the constructor is a normal signature like `__new__(_cls, url, attempts=0)`, and a required parameter cannot follow a defaulted one, so declaring a plain field after a defaulted one raises `TypeError` at class-creation time. Defaults are evaluated once, at class creation, and land in `__new__.__defaults__` and `_field_defaults` — which means a mutable default such as `[]` is shared by every instance. `collections.namedtuple` takes a `defaults=` sequence but has no body, so methods there require subclassing the generated class.

code

python · 22 lines
python
from typing import NamedTuple


class Delivery(NamedTuple):
    url: str
    attempts: int = 0
    delivered: bool = False

    @property
    def exhausted(self) -> bool:
        return self.attempts >= 3

    def retried(self) -> "Delivery":
        return self._replace(attempts=self.attempts + 1)


d = Delivery("https://example.test/hook")
print(d)
print(Delivery._fields, Delivery._field_defaults)
print(Delivery.__new__.__defaults__)
print(d.retried().retried().retried().exhausted)
print(isinstance(d, tuple), d[0], d.url)

go deeper

for a junior

Be ready to write the class form from memory: annotated names are the fields, an assigned value is that field's default, defaulted fields go last, and a def in the body is just a method. Knowing that instances are still ordinary tuples is expected.

for a middle

Explain the mechanics: the body is split into fields, defaults and everything else; the defaults become the generated new's parameter defaults, evaluated once at class creation and visible in _field_defaults; and the ordering error is raised at import time, not at construction.

for a senior

Show the production judgement: a shared mutable default silently coupling instances, field order being a public contract for both positional construction and unpacking, and why a namedtuple subclass that adds behaviour needs slots = () to keep the memory win.

for a principal

Own the tradeoff of standardising on tuple-backed records at all: positional fields make ordering a breaking change across services, there is no keyword-only escape, and defaults invite silent field drift — decide where that beats a heavier record type and write the convention down.

### The class form is a factory, not an ordinary base class `typing.NamedTuple` looks like inheritance, but you are not inheriting behaviour from it. When the class body finishes executing, the machinery behind `NamedTuple` reads that body's namespace and splits it into three piles: 1. **Annotated names** — `url: str`, `attempts: int` — become the record's *fields*, in the exact order they were written. That order is both the tuple's index order and the parameter order of the generated `__new__`. 2. **Annotated names that were also assigned a value** — `attempts: int = 0` — additionally contribute a *default* for that parameter. 3. **Everything else** — `def` statements, `property` objects, `classmethod` and `staticmethod` objects, docstrings, unannotated assignments — is copied onto the generated class untouched. What comes out is a genuine subclass of `tuple` carrying `_fields`, `_field_defaults`, `_replace`, `_asdict` and `_make`, a `__new__` whose signature you can print with `inspect.signature`, and whatever methods you wrote. The methods are real methods: they are looked up on the class like any other, they are not stored per instance, and they do not occupy tuple slots. ### Why the defaulted fields must come last There is nothing tuple-specific about this rule; it is Python's own parameter grammar. The generated constructor for a record with `url`, `attempts = 0` and `delivered = False` is effectively `def __new__(_cls, url, attempts=0, delivered=False)`, and a required parameter cannot follow a defaulted one in any Python signature. So the class-creation machinery checks the declaration order itself and raises `TypeError: Non-default namedtuple field b cannot follow default field a` at *import* time, not when you first construct an instance. A dataclass hits the same wall and offers a keyword-only escape hatch; a NamedTuple has none, because its fields are also positional tuple slots and the positions are the record's identity. The practical consequence for design: field order is part of your public contract twice over — once for positional construction, once for indexing and unpacking — so a field that gains a default later must already be at the end, and reordering fields to make room is a breaking change for every caller that unpacks or indexes. ### Where the defaults live, and when they are evaluated `Delivery.__new__.__defaults__` holds the default values as a tuple, and `Delivery._field_defaults` exposes them as a `{name: value}` mapping. Both are built **once**, at class-creation time, from expressions evaluated in the class body — exactly like ordinary function parameter defaults. That produces the same classic trap: a mutable default is shared. Writing `tags: list = []` gives *every* default-constructed instance the same list object, and mutating it through one instance is visible through all of them. The `@dataclass` decorator rejects a mutable default outright; `NamedTuple` does not, and the tuple's own immutability does not save you, because a tuple is only shallowly immutable — the slot cannot be rebound, but the list inside it can be mutated. If you want a per-instance empty collection, default to `None` (or to an immutable empty tuple) and build the collection in a small factory `classmethod`. ### Methods with `collections.namedtuple`, and why the class form is easier `collections.namedtuple` is a string-driven factory: you pass a class name, the field names, and — since Python 3.7 — a `defaults` sequence that is applied to the *rightmost* fields. It returns a finished class, and there is no class body in which to put a `def`. To attach behaviour you subclass the class it returned and set `__slots__ = ()` on the subclass so instances do not acquire a per-instance `__dict__`, which would quietly undo the memory advantage the record type exists for. The class form does all of that for you in one block, which is the main day-to-day reason to reach for `typing.NamedTuple`: annotations, defaults and behaviour in one readable place, with the annotations doubling as documentation and as input to a static type checker. ### The escape hatch that is also a trap An assignment **without** an annotation is not a field. `SCALE = 10` in the body stays an ordinary class attribute: it does not appear in `_fields`, it does not become a `__new__` parameter, and it costs nothing per instance. That is the intended way to hang a constant off the record. It is also the failure mode to watch for in review — forget the annotation on a name you meant as a field and it silently vanishes from the record, with no error until something downstream unpacks the wrong number of values. ### Version notes The class form with per-field defaults has behaved this way since Python 3.6.1; `collections.namedtuple`'s `defaults=` argument arrived in 3.7. Nothing here changed through 3.14. In particular, 3.14's deferred evaluation of annotations (PEP 649) does not disturb it: the field list is derived from the annotated *names* in the class body and their declaration order, not from evaluating the annotation expressions, so a record whose annotations reference a type defined later in the module still builds its fields, its order and its defaults correctly.

  • Where do a typing.NamedTuple's defaults actually live, and when are they evaluated?
    They are evaluated once, while the class is being created, and stored on the generated constructor: `Delivery.__new__.__defaults__` is the tuple of values, and `Delivery._field_defaults` is the same thing as a name-to-value mapping. Because evaluation happens once, a mutable default like `[]` is one shared object across every default-constructed instance — default to `None` and build the collection in a factory classmethod instead.
  • How would you attach the same helper method to a class made by collections.namedtuple?
    Subclass it. Call `namedtuple(...)` to get the base class, then define your own class inheriting from it with the method on it, and set `__slots__ = ()` in that subclass so instances do not gain a per-instance `__dict__`. The typing.NamedTuple class form collapses those two steps into one block, which is why it is the usual choice when a record needs behaviour.
  • Does an assignment without an annotation in a NamedTuple class body become a field?
    No. Only annotated names become fields. `SCALE = 10` with no annotation stays an ordinary class attribute: it is absent from `_fields`, it is not a parameter of the generated `__new__`, and it adds nothing to each instance. That makes it the right way to hang a constant on the record — and a real review hazard, since forgetting an annotation on an intended field silently drops it from the record.

The class body is a sorting hatch, not a blueprint: annotated names go into the record's slots, an annotated name with a value goes in carrying a default tag, and everything else is handed through unchanged to the finished class.

saying these in an interview costs you the question

  • Thinks a def in the class body becomes another tuple field
  • Declares a required field after a defaulted one and expects it to work
  • Believes the defaults are re-evaluated on every construction
  • Uses [] as a field default assuming each instance gets its own list
  • Claims collections.namedtuple cannot have default values at all
  • Thinks adding a method makes instances mutable or no longer tuples

context

open as a page

What does the @dataclass decorator generate, and which class attributes become fields?

level: juniorimportance: must knowfreq 72%

basics

~10 s

@dataclass writes init, repr and eq from the class's annotated attributes, in declaration order. Only annotated names become fields; a plain assignment with no annotation stays an ordinary class attribute.

open as a page

What does @dataclass(frozen=True) actually prevent, and what does it not?

level: juniorimportance: must knowfreq 62%

basics

~20 s

Setting or deleting an attribute on a frozen dataclass instance raises dataclasses.FrozenInstanceError, because the decorator generates blocking setattr and delattr methods. It does not freeze what the fields point at: a list field can still be mutated in place.

open as a page

When does __post_init__ run in a Python dataclass, and what is it for?

level: juniorimportance: must knowfreq 55%

basics

~10 s

@dataclass generates an init that assigns every field, and if the class defines post_init that generated constructor calls it as its final statement. It is the hook for validating arguments and computing derived values.

open as a page

Why does @dataclass raise ValueError for `tags: list = []`, and what is the fix?

level: middleimportance: must knowfreq 66%

basics

~10 s

A default in the class body is created once and would be shared by every instance, so @dataclass rejects it at class-creation time. Use dataclasses.field(default_factory=list), which calls the factory per instance.

open as a page

Which @dataclass flags decide whether its instances are hashable?

level: middleimportance: must knowfreq 55%

basics

~10 s

With the defaults (eq=True, frozen=False) the decorator sets hash to None, so instances are unhashable. eq=True together with frozen=True generates a hash; eq=False leaves the inherited identity-based hash alone; unsafe_hash=True forces one.

open as a page

How do you update a field on a collections.namedtuple instance?

level: middleimportance: should knowfreq 45%

basics

~10 s

You do not update it in place — the record is immutable and attribute assignment raises AttributeError. Call _replace(field=value), which returns a new instance with that field changed and the rest copied across.

open as a page

How does typing.NamedTuple differ from collections.namedtuple?

level: middleimportance: should knowfreq 50%

basics

~20 s

typing.NamedTuple is a class-statement spelling of the same runtime object: annotations declare the fields and their order, assignments supply defaults, and the body may hold methods and a docstring. Both produce a tuple subclass; neither checks the annotations at runtime.

open as a page

Why does a dataclass with a defaulted field before a non-defaulted one raise TypeError?

level: middleimportance: should knowfreq 48%

basics

~10 s

The generated init takes fields in annotation order, so a defaulted field followed by a non-defaulted one would produce an invalid signature. The decorator raises TypeError at class-creation time: non-default argument follows default argument.

open as a page

What does annotating a dataclass attribute with dataclasses.InitVar do?

level: middleimportance: should knowfreq 38%

basics

~20 s

dataclasses.InitVar marks a construction-only pseudo-field. It becomes a parameter of the generated init and is handed to post_init, but it is never stored on the instance and never appears in fields(), the repr, equality or asdict().

open as a page

Where does validation belong in a dataclass, and what does __post_init__ not cover?

level: middleimportance: should knowfreq 45%

basics

~20 s

Put invariant checks in post_init and raise ValueError there; it is the one hook the generated constructor gives you. It runs once, at construction, so a plain dataclass stays mutable and later attribute assignment bypasses every check.

open as a page

When does a typing.NamedTuple stop being the right record type for a webhook receiver's cached payload?

level: seniorimportance: should knowfreq 45%

basics

~20 s

When being a tuple costs more than the names are worth: structural equality that ignores the record's class, positional construction past a handful of fields, sequence-shaped serialisation, or a need to mutate or validate. Use a dataclass instead.

open as a page

In a dataclass, when would you set field(repr=False) or field(compare=False)?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Each dataclasses.field() flag removes the field from one generated method: repr=False keeps a large or sensitive value out of the printed repr, compare=False keeps a volatile value out of eq, and init=False drops it from the constructor signature.

open as a page

A metrics scraper sorts @dataclass(order=True) records; why can inserting a field silently change the sort?

level: seniorimportance: should knowfreq 40%

basics

~20 s

order=True generates lt, le, gt and ge that compare instances as tuples of their fields in declaration order. Inserting a field high in the class body silently rewrites the sort key, so sorted() output changes with no call-site edit.

open as a page

In a frozen dataclass, how does __post_init__ set a derived or normalized field?

level: seniorimportance: should knowfreq 32%

basics

~10 s

Assign through object.setattr(self, "name", value). A frozen dataclass installs a setattr that raises FrozenInstanceError, and post_init is not exempt, so a plain self.name = value fails even during construction.

open as a page

Why does @dataclass(slots=True) replace the class instead of modifying it?

level: seniorimportance: nice to knowfreq 26%

basics

~20 s

slots has to be present when the type object is created, so a decorator cannot add it afterwards. The decorator builds a brand-new class from the same body plus slots and returns that, leaving anything that already captured the original class pointing at a discarded object.

open as a page