When to Use TypedDict vs Dataclasses in Python: A Type-Safe Decision Guide
Use TypedDict for external JSON payloads and API boundaries — zero runtime overhead, works directly with json.loads(). Use dataclasses for internal domain models that need constructors, default values, and method attachment. Both are fully supported by mypy and pyright; the choice is about runtime semantics, not static analysis quality.
Choosing between structural dictionary typing and nominal class-based typing dictates how your codebase handles runtime behavior and static analysis. Core Type Hints Fundamentals establishes the baseline for static versus runtime typing concepts.
TypedDict enforces structural typing for external payloads without instantiation overhead. dataclasses provide nominal typing, default values, and runtime validation at the cost of object creation.
Static analyzers like mypy and pyright handle missing keys differently across both constructs. Review Literal and TypedDict for structural dictionary syntax and strict mode configuration.
Structural vs Nominal Typing Boundaries
TypedDict (PEP 589, in typing since Python 3.8; typing_extensions on 3.7) relies on structural typing: any dict whose keys and value types line up satisfies the type, no matter how the dict was built. A literal you write inline, a value returned by json.loads(), or a dict assembled key-by-key in a loop all match UserPayload as long as their shape agrees. dataclasses (PEP 557, Python 3.7+) use nominal typing: only an object produced by calling the class — UserModel(...) — or an instance of a subclass is accepted. Two dataclasses with byte-identical fields remain unrelated, incompatible types, because identity is by name, not by shape.
mypy strict mode flags structural mismatches in TypedDict and constructor-signature mismatches in dataclasses. pyright defaults to strict inference for missing keys under reportGeneralTypeIssues. Ruff handles syntax linting only and defers structural validation to mypy or pyright. A subtlety that trips people up: a TypedDict literal is closed — extra keys are rejected (mypy [typeddict-unknown-key], pyright reportGeneralTypeIssues) — but a variable typed as a wider TypedDict is assignable to a narrower one only when every required key is present with a compatible type. Because dict values are mutable, per-key compatibility is invariant, so TypedDict subtyping is stricter than plain read-only structural matching.
# Run: mypy --strict example.py
from typing import TypedDict, NotRequired
from dataclasses import dataclass, field
class UserPayload(TypedDict):
id: int
email: str
role: NotRequired[str]
@dataclass
class UserModel:
id: int
email: str
role: str = field(default="viewer")
payload: UserPayload = {"id": 1, "email": "a@b.com"} # Passes
model = UserModel(id=1, email="a@b.com") # Passes
payload2: UserPayload = {"id": 1} # mypy error: Missing key "email" [typeddict-item]
model2 = UserModel(id=1) # mypy error: Missing positional argument "email" [call-arg]
payload3: UserPayload = {"id": 1, "email": "a@b", "extra": 9} # [typeddict-unknown-key]
Because a dataclass is nominal, you cannot pass a plain dict where a UserModel is expected — mypy reports [arg-type], pyright reportArgumentType. That nominal boundary is exactly what makes dataclasses good domain models: the type name means the invariants held by its __post_init__ were checked, which a bare dict can never promise.
TypedDict still supports composition, just structurally. Inheritance merges fields — class AdminPayload(UserPayload): scopes: list[str] extends the shape — and multiple inheritance unions several TypedDicts. Since Python 3.11 a TypedDict can be generic (class Page(TypedDict, Generic[T]): items: list[T]), and PEP 705’s ReadOnly[...] (Python 3.13, typing_extensions earlier) marks individual keys immutable to the checker, which relaxes the invariance rule enough to let a wider TypedDict be assignable where a narrower one is expected. Dataclasses compose through ordinary class inheritance and can be made generic the same way; the practical difference is that a subclass dataclass is a subtype usable wherever the parent is (Liskov substitution), whereas TypedDict “sub-shapes” only match when every field lines up structurally. That is the crux of the choice: pick nominal dataclasses when you want an inheritance hierarchy with substitutability, and structural TypedDicts when you want any conforming dict to slot in regardless of lineage.
Runtime Overhead & Instantiation Costs
TypedDict adds zero runtime overhead. It functions purely as compile-time metadata for static analyzers — at runtime, a TypedDict value is simply a plain dict, so building one is a single BUILD_MAP bytecode with no attribute assignment. dataclasses synthesize __init__, __repr__, and __eq__ (with eq=True, the default) at class-definition time; order=True adds the four comparison dunders, and frozen=True makes instances immutable and hashable by generating __hash__ plus a __setattr__ that raises FrozenInstanceError. Each instantiation then runs that generated __init__, assigning every field on a real object.
High-throughput pipelines feel this difference. Async workers processing thousands of events per second avoid object allocation by routing raw dicts through TypedDict annotations. To claw back the memory cost on the dataclass side, add slots=True (Python 3.10+), which generates __slots__, removes the per-instance __dict__, and both shrinks each object and speeds attribute access. There is also a hard runtime distinction worth remembering: because a TypedDict value is a dict, type(payload) is dict is True, and you cannot use the class in an isinstance check — isinstance(payload, UserPayload) raises TypeError: TypedDict does not support instance and class checks. A dataclass, by contrast, is a normal class, so isinstance(model, UserModel) works exactly as expected. Convert to dataclasses only when business logic requires method attachment, identity, or the validation described below.
The generated methods also unlock ergonomics that a dict cannot offer. A dataclass gets a __match_args__ tuple, so it participates in structural pattern matching by position — case UserModel(id, email): binds fields directly, whereas matching a dict requires the more verbose case {"id": id, "email": email}: mapping pattern. If you want the tuple-like immutability and near-dict memory profile of a plain record, typing.NamedTuple is a third option: it is nominal like a dataclass, iterable and hashable like a tuple, and lighter than a full @dataclass, but it cannot carry mutable state or a custom __init__. And when you need runtime validation and generated methods without hand-writing __post_init__, pydantic.BaseModel or the attrs library sit one step beyond dataclasses, coercing and validating every field at construction time at the cost of more overhead than any of the standard-library forms.
Handling Optional Keys & Default Values
Legacy codebases often reach for total=False to mark every key optional at once, which erases the presence guarantees on keys that are genuinely required. PEP 655 introduced Required[...] and NotRequired[...] (Python 3.11; typing_extensions>=4.0 on 3.10 and below) for per-key control. A NotRequired key may simply be absent from the dict, so the checker forces you to prove presence with in or read it through .get() — a direct subscript is mypy [typeddict-item] / pyright reportTypedDictNotRequiredAccess. dataclasses express “optional” differently: a field with field(default=...) is always present on the instance, just pre-filled when the caller omits it.
dataclasses handle defaults via field(default=...) for immutable values and field(default_factory=...) for mutable ones. Using a mutable literal directly — tags: list[str] = [] — is a ValueError at class-definition time, precisely to prevent the shared-mutable-default bug; use field(default_factory=list) instead. __post_init__ runs after the generated __init__ for cross-field validation or derived fields, and kw_only=True (Python 3.10+, on the decorator or per-field) forces keyword arguments so you can add required fields after ones with defaults without a TypeError.
from typing import TypedDict, NotRequired
from dataclasses import dataclass, field
class UserPayload(TypedDict):
id: int
email: str
role: NotRequired[str] # PEP 655 — key may be absent entirely
@dataclass(kw_only=True) # 3.10+: safe to mix defaults and required
class UserModel:
id: int
email: str
role: str = "viewer" # always present on the instance
tags: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
if "@" not in self.email:
raise ValueError("invalid email")
def role_of(p: UserPayload) -> str:
return p.get("role", "viewer") # subscript p["role"] would be [typeddict-item]
The Self and NotRequired types guide covers per-key optionality in depth, and the difference between total=False and NotRequired matters for new code: prefer NotRequired when only some keys are optional.
API Serialization & External Payload Mapping
TypedDict aligns directly with json.loads() output — no transformation layer is needed, since json.loads already returns a plain dict. You annotate the parsed result and keep working with it as data. dataclasses require an explicit mapping step or a third-party adapter like pydantic or marshmallow, because the constructor is nominal and will not accept a bare dict. The catch on the TypedDict side is that cast() performs no runtime check — typing.cast(UserPayload, parsed) returns its second argument untouched and only tells the checker to trust the shape.
import json
from typing import cast, TypedDict, NotRequired
from dataclasses import dataclass, field
class UserPayload(TypedDict):
id: int
email: str
role: NotRequired[str]
raw = '{"id": 1, "email": "test@dev.com"}'
parsed = json.loads(raw) # type is Any / dict[Any, Any]
user_dict: UserPayload = cast(UserPayload, parsed) # trusted, NOT validated
@dataclass
class UserModel:
id: int
email: str
role: str = field(default="viewer")
# Unpacking untrusted JSON directly is unsafe:
# UserModel(**parsed) raises TypeError on an unexpected key at runtime.
user_obj = UserModel(**{k: parsed[k] for k in ("id", "email", "role") if k in parsed})
Because cast cannot catch a malformed payload, treat TypedDict as a shape assertion over data you already trust or have validated upstream. When the payload is untrusted, validate first — pydantic v2 accepts a TypedDict and enforces it at runtime, or a dataclass with a __post_init__ check gives you a validated domain object. On the way out, dataclasses.asdict(model) turns an instance back into a plain dict ready for json.dumps, closing the loop between your nominal model and the structural wire format. See Understanding typing.Literal for Strict Validation for constraining individual field values inside either structure.
Migration Path: Converting Legacy Dicts to Type-Safe Structures
Migrating an untyped, dict-heavy codebase is a staged process, not a rewrite. Identify dict-heavy modules first — an AST traversal or a targeted grep for dict(, {-literals, and ["..."] access patterns surfaces the boundaries where raw dictionaries flow through your code. Apply TypedDict to the read-only external interfaces first, because it is a pure annotation: it changes no runtime behavior, so it cannot break a passing test. Only after the shapes are pinned down do you tighten the checker and, selectively, promote the interior models to dataclasses.
Tune incremental mypy/pyright configuration to avoid a wall of false positives on day one. Start permissive with ignore_missing_imports = true, get the module type-clean, then ratchet up disallow_untyped_defs and strict one package at a time. pyright’s # pyright: strict file-level comment lets you opt individual modules into strict mode ahead of the whole project. A pragmatic CI-ready pyproject.toml:
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
[tool.pyright]
pythonVersion = "3.10"
typeCheckingMode = "strict"
The decision to promote a TypedDict to a dataclass should be driven by need, not habit: reach for a dataclass when the structure carries invariants that must hold before use (validated in __post_init__), needs methods or identity, or benefits from immutability via frozen=True. Keep the TypedDict at the wire boundary and convert once, at the seam, so the untyped-dict blast radius stays small. A tidy conversion helper is UserModel(**payload) when the TypedDict’s keys exactly match the dataclass fields — and if you want that mapping itself type-checked, PEP 692’s Unpack[UserPayload] lets you annotate **kwargs: Unpack[UserPayload] so the checker verifies the unpacked keys against the TypedDict at the call site. For the strictness knobs referenced above, see mypy Configuration & Strictness — or if you have not yet, start from Core Type Hints Fundamentals.
Common Mistakes
- Using dataclasses for raw JSON payloads: External payloads may contain unexpected keys. Constructors raise
TypeErroron mismatched kwargs.TypedDictwithNotRequiredsafely models partial data without any transformation. - Applying TypedDict to internal domain models:
TypedDictprovides zero runtime validation. Internal business logic that needs invariants enforced at construction time is better served bydataclassesorpydanticmodels. - Ignoring the difference between
total=FalseandNotRequired:total=Falsemakes all fields optional at once.NotRequiredgives per-field control. PreferNotRequiredfor new code where only some fields are optional.
FAQ
Can I use TypedDict and dataclasses together in the same codebase?
Yes. Use TypedDict for external API boundaries and dataclasses for internal domain models. Convert between them at the serialization layer using explicit mapping functions.
Does TypedDict work with Python 3.8+ static checkers?
Yes. TypedDict was added to typing in Python 3.8. NotRequired requires Python 3.11 or typing_extensions>=3.10.0.2.
Which performs better in high-throughput async workers?
TypedDict has near-zero overhead since it uses plain dicts. dataclasses incur object instantiation costs. TypedDict is preferable for raw data routing where throughput matters.