Skip to content
← All articles

Choosing a record type: dataclass, NamedTuple, TypedDict, attrs, pydantic, msgspec

Two symmetric mistakes — BaseModel everywhere and dict[str, Any] everywhere — with measured construction, equality and memory numbers for each option, and a decision procedure that starts from the trust boundary.

There are two expensive mistakes here and they are mirror images of each other.

The first is BaseModel everywhere. Someone reasonably decides that pydantic should validate the API request, and then — equally reasonably, and wrongly — decides that the validated thing should be the domain type too. Now every internal object construction runs a validator over data that was already validated, and your business logic imports a serialization library, inherits its field semantics, and moves at its upgrade cadence.

The second is dict[str, Any] everywhere. No schema, no cost, nothing to maintain. You pay for it in incidents instead: a renamed key that nobody notices until the dashboard goes flat, a None that reaches a .strip() three services downstream, a field that is a string on Tuesday and an int on Wednesday.

Both mistakes come from treating “which record type” as a style question. It is a cost placement question: where do you want to pay for the schema, and how many times.

The numbers

CPython 3.14.6, macOS arm64, a three-int record, min-of-3 timings, tracemalloc for memory:

shape construct == bytes/instance
@dataclass 43.5 ns 45.8 ns 96
@dataclass(slots=True) 35.1 ns 46.3 ns 56
@dataclass(frozen=True) 150.7 ns 96
typing.NamedTuple 95.2 ns 9.9 ns 80
attrs.define 36.4 ns 43.8 ns 56
attrs.define + validators 284.8 ns 43.1 ns 56
pydantic.dataclasses.dataclass 248.5 ns 99.0 ns
pydantic.BaseModel 354.8 ns 224.9 ns
plain dict 184

Read the extremes. A BaseModel costs a plain dataclass to construct and to compare. A frozen=True dataclass costs 3.4× a mutable one, because the generated __init__ has to call object.__setattr__ per field. A NamedTuple is slow to build and 4.6× faster to compare than anything else, because equality is one C-level tuple comparison. And a plain dict is the worst memory option on the board at 184 bytes — nearly 3.3× a slotted dataclass — which is the opposite of what most people assume when they reach for one to “keep it light”.

💡A service parses 50,000 JSON records per request and then does one lookup per record. Using the table, where does the time actually go — and what does that imply about which record type to use? click to reveal

Construction dominates, overwhelmingly, and it is not close.

50,000 × 354.8 ns (BaseModel) ≈ 17.7 ms of pure object construction, per request, before any of your code runs. The same 50,000 as slotted dataclasses is 50,000 × 35.1 ns ≈ 1.8 ms. That is a ~16 ms difference on every request, and it is invisible in a profile that only shows your own function names.

Equality barely registers: one lookup per record is 50,000 comparisons, which is 11 ms for BaseModel and 0.5 ms for a NamedTuple — but if you are doing a lookup you are hashing, not comparing linearly, so in practice it is a handful of comparisons total.

The implication is the whole thesis of this article: you are paying validation cost 50,000 times for data that arrives in one shape from one producer. Validate the payload once at the boundary — one TypeAdapter call over the whole list — and construct plain dataclasses inside. You keep the schema and the error messages, and you delete 16 ms.

What each one is actually for

@dataclass — the default. It is in the stdlib, it has no dependency, mypy understands it perfectly, and frozen=True, kw_only=True gives you a value object with a good API. Reach for something else only when you can name the reason.

typing.NamedTuple — when the value genuinely is a tuple. A coordinate, an RGB triple, a database row, the result of divmod. You get unpacking, slicing, tuple compatibility and very fast equality. You also get Point(1, 2) == Date(1, 2) == (1, 2), which is a real bug source for anything that is a domain entity rather than a pair. Use typing.NamedTuple, never collections.namedtuple: the latter types every attribute as Any and --strict says nothing about it.

TypedDict — when the thing genuinely is a dict that you immediately serialize or hand to an API that wants a dict. It is a description of a dict, not a class: no methods, no construction cost, no runtime existence at all. It shines for keyword-argument bundles and JSON payloads that never become objects.

attrs — when you need converters (normalising at the boundary, on assignment as well as construction), cached_property on slotted classes, Factory(takes_self=True), field aliases, or field transformers. @attrs.define gives you slots=True and weakref_slot=True by default, at 36.4 ns. It is the answer to specific stdlib gaps, not a general upgrade.

pydantic — at trust boundaries. HTTP request bodies, message-queue payloads, config files, anything a human or another team can get wrong. It buys you validation, coercion, error paths and JSON Schema. Use TypeAdapter over a plain dataclass when you want all that without making BaseModel your domain type.

msgspec — when the boundary itself is the bottleneck: it decodes straight from bytes into typed objects and skips an intermediate dict entirely. Narrower ecosystem, much faster on the specific job.

A plain class — when the thing has behaviour, identity or a lifecycle rather than a value. A Connection is not a record. If two instances with identical fields should not be equal, you do not want a generated __eq__ at all.

💡TypedDict has no runtime existence — no class, no validation, no construction cost. Given that, why use it at all instead of dict[str, Any]? click to reveal

Because Any is contagious and TypedDict is not.

dict[str, Any] means every expression that touches the dict is Any. payload["user"]["email"].strip() type-checks whatever those keys hold, including when "user" was renamed last sprint and the lookup now raises KeyError. Worse, the Any escapes: assign payload["count"] to a variable and that variable is Any too, and so is everything computed from it. One untyped dict at the top of a function can erase the checking for the whole function.

A TypedDict gives every key a type and makes an unknown key an error, with zero runtime cost — it compiles to a plain dict. You get key-name checking, value-type checking, Required/NotRequired for optional keys, and ReadOnly (3.13) for keys a consumer must not mutate.

Two honest caveats. It checks nothing at runtime: a TypedDict annotation on data that came off the wire is a claim, not a guarantee — that is what the parse step at the boundary is for. And mypy does not flag reading a NotRequired key (pyright errors at every strictness level), so a missing optional key still surfaces as a runtime KeyError under mypy alone.

The decision procedure

Work down this list and stop at the first line that matches:

  1. Is this data crossing a trust boundary? → pydantic (or explicit hand-written validators). Parse it once, here, and convert to a domain type immediately. Never let the boundary type reach your business logic.
  2. Is it a dict you are about to serialize, and it never becomes an object?TypedDict.
  3. Is it genuinely a tuple, where unpacking matters and cross-type equality is acceptable?typing.NamedTuple.
  4. Does it have behaviour, identity or a lifecycle rather than a value? → a plain class.
  5. Otherwise@dataclass(frozen=True, kw_only=True). Add slots=True when there are many instances or you want the typo protection. Add attrs only when you hit one of its specific gaps.
💡You are designing an internal event-processing service. Events arrive as JSON on a queue, get enriched, get compared for deduplication, and get written to a database. Walk the decision procedure and say what each stage uses. click to reveal

Ingest. The queue is a trust boundary — another team’s producer, deployed independently. Step 1: parse with pydantic.TypeAdapter (or a hand-written parser) into a domain type, once per message. The signature is def parse_event(raw: object) -> Event, and it raises a domain error, never a pydantic error. Nothing downstream ever sees the raw payload.

Domain. Event is @dataclass(frozen=True, kw_only=True, slots=True). Frozen because it is a value and the pipeline is concurrent; kw_only because the field list will grow and no call site should be positional; slots because there are a lot of them and it makes a typo in an enrichment step a mypy error.

Enrichment. Each step returns a new Event via dataclasses.replace(event, enriched=...) — checked by mypy’s plugin for both field names and types, which copy.replace is not.

Deduplication. The dedup key is a value, and it is small and genuinely tuple-shaped: (tenant_id, event_id). typing.NamedTuple is defensible for its 9.9 ns equality — provided nothing else in the process uses a bare 2-tuple as a key against the same set, because a NamedTuple compares equal to any tuple with the same contents. If that worries you (it should), a frozen slotted dataclass is 46 ns and cannot collide.

Persistence. The row handed to the DB driver is a dict you build and immediately serialize: TypedDict. It never becomes an object, so give it a shape and no runtime cost.

Five stages, four different answers, one rule: the type changes at each boundary, and the conversion is explicit.

An honest correction

It is widely believed that attrs is the safer, more mature library and stdlib dataclasses is the stripped-down version. On one specific axis that is backwards.

stdlib dataclasses raises ValueError on an unhashable mutable default. items: list[int] = [] is rejected at class-creation time.

attrs accepts it and silently shares it. Every instance gets the same list.

attrs’ own documentation is clear about its scope — it “emphatically does not try to be a validation library” — and this is a consequence of that position rather than an oversight. But if you are migrating a codebase from dataclasses to attrs for “safety”, know that you are giving this check up, and that RUF008 is your only remaining backstop.

What to write down

  • The record type is a cost-placement decision. Validation is not free; decide how many times you want to pay for it.
  • @dataclass(frozen=True, kw_only=True) is the default, and being explicit about that in a style guide saves more review time than any individual choice.
  • The boundary type and the domain type are different types, and the function that converts between them is the most important function in the module.
  • A plain dict is not the lightweight option. It is 184 bytes and no schema.