TypedDict vs dataclass vs Pydantic: a three-way trade-off
Python gives you three different ways to model an incoming JSON shape,
and they are not interchangeable. TypedDict is a type-checker
fiction layered over a plain dict: nothing exists at runtime
beyond what the dict already is. A @dataclass is a real class
with an auto-generated __init__ — structure, no validation.
A Pydantic BaseModel is a real class that runs real bytes of
validation code on every model_validate(). Most codebases pick
one of the three and use it everywhere. That is the wrong question. The
right question is which one earns its keep at this point in the
program.
Take a single Stripe customer payload. Each row below is the same JSON, modeled three ways:
from typing import TypedDict, NotRequired
from dataclasses import dataclass
from pydantic import BaseModel
class CustomerTD(TypedDict):
id: str
email: str | None
address: NotRequired[str]
@dataclass
class CustomerDC:
id: str
email: str | None
address: str | None = None
class CustomerPD(BaseModel):
id: str
email: str | None
address: str | None = None
They look almost identical. They behave almost nothing alike. The
TypedDict instance is the dict that came off the wire —
no constructor ran, no copy was made, the type annotation is invisible at
runtime and only mypy or pyright cares. The dataclass took the
raw values, called __init__, and stored them on attributes;
if a string came in where an int was annotated, nobody noticed.
The Pydantic model parsed the input through pydantic-core,
coerced or rejected each field, and produced a fully validated instance or
raised ValidationError.
One is a label on a dict. One is a constructor. One is a validator. Picking the right one is a code-path question, not a project-wide one.
— the house style// type the conversion will appear here… Tab between TypedDict, dataclass, and Pydantic to see the same shape with three different runtime contracts. Edit either sample, or click + to paste your own.
The cost map
The three differ on four axes that actually show up in profilers and bug reports:
TypedDict dataclass Pydantic
runtime cost none __init__ only validate + coerce
memory per inst. dict __slots__-able schema + state
type-check structural nominal nominal
catches bad data no no yes (at boundary) TypedDict is invisible at runtime. The instance is a
plain dict; isinstance(x, CustomerTD) raises
TypeError at the call site because there is no class to
check against. Type errors live entirely in the type checker. This is
also why "address" in customer means exactly what it looks
like — key presence on the underlying dict.
Dataclass generates __init__,
__repr__, and __eq__ at class-creation time.
Calling the constructor allocates an instance, sets attributes, and
returns. Nothing inspects the values. CustomerDC(id=42, email=None)
succeeds with id set to the integer 42, in contradiction to
the annotation. The annotation is documentation that mypy reads.
Pydantic runs an actual validator on every input field,
in Rust, and produces a model whose attributes are guaranteed to match
the declared types. CustomerPD.model_validate({"id": 42, ...})
coerces the integer to "42" if you let it, or raises if you
used StrictStr. Single-digit microseconds for simple models,
tens to hundreds for nested ones with lists. Free on a request handler,
measurable on a million-record stream.
Where each one fits
The trust-boundary picture from the
Pydantic vs dataclasses
essay extends naturally to three. TypedDict slots in beside dataclass
on the “no validation” side, but with a different
super-power: it preserves the original dict.
TypedDict — when key presence matters
The killer use case is partial-update payloads, where the difference
between “the user sent null” and “the
user did not mention this field” is the entire point. Pydantic
BaseModel collapses both into the same attribute value unless you reach
for model_fields_set; TypedDict with NotRequired
preserves the distinction at zero runtime cost.
class UpdateProfile(TypedDict):
email: NotRequired[str | None]
phone: NotRequired[str | None]
def apply_update(patch: UpdateProfile):
if "email" in patch:
user.email = patch["email"] # may be None — explicit clear
# key absent → don't touch user.email
The other case is “I already have the dict, I just want the type
checker to know its shape.” A response from requests.json(),
a row from asyncpg, anything where wrapping the value in a
new class would mean copying the data for nothing. The
Optional[T] or T | None
essay covers the related decision about how to spell nullability inside
the NotRequired[...].
Dataclass — when you build the instance yourself
Internal value objects, return types from your own functions, intermediate structures inside business logic. The shape is something your code constructs; mypy already proved every call site passes the right types; no wild data is involved. A dataclass gives you nominal typing, equality, repr, and pattern-match support, none of which a TypedDict offers.
@dataclass(frozen=True, slots=True)
class PriceBreakdown:
subtotal: Decimal
tax: Decimal
total: Decimal
def compute(order: Order) -> PriceBreakdown:
... frozen=True makes equality and hashing well-defined.
slots=True drops the __dict__, which both
saves memory and prevents accidental attribute typos at runtime.
Pydantic can do all this too, but you would be paying for a validator
on a value you constructed five lines above.
Pydantic — at the network and user-input boundaries
Where data crosses the trust boundary into your program. Webhooks, HTTP request bodies, environment variables, config files, LLM outputs, third-party SDK responses with weak typing. Pydantic earns its microseconds when there is a real risk that the shape is not what your annotations claim.
class WebhookEvent(BaseModel):
type: Literal["customer.created", "customer.updated"]
data: Customer
created: datetime
@app.post("/webhook")
def handle(event: WebhookEvent):
# Inside this function, the type is real. mypy and the runtime agree.
process(event.data)
The Literal on type is the kind of guarantee
only a validator can give you at runtime. A TypedDict
annotation says “the shape should look like this”; Pydantic
says “the shape is this, or you get a 422.”
The TypedDict gotchas
TypedDict has the smallest API of the three, which makes its sharp edges easy to miss.
It is structural, not nominal. Two unrelated TypedDicts with the same keys and value types are interchangeable to the type checker. That is intentional — you are typing the dict, not branding it — but it surprises people coming from dataclass land.
Inheritance composes required-ness, not optionality.
A TypedDict subclass that adds fields adds them as required by default.
Use NotRequired per field, or set total=False on
the class to flip the default. Mixing the two is the readable choice.
class Base(TypedDict):
id: str
name: str
class Customer(Base):
email: str
address: NotRequired[str] cast() is not validation. Wrapping a
response.json() result in
cast(Customer, data) tells the type checker what you
believe but proves nothing about what arrived. If the data crossed a
network boundary, the right answer is Pydantic; if it did not, you
probably did not need the cast.
Mixing all three in one file
The most underrated arrangement in a Python codebase that talks to external systems: TypedDict for the wire contract, Pydantic for the parser, dataclass for the domain object the rest of the code holds.
# the contract — what we expect from the server, no runtime presence
class CustomerWire(TypedDict):
id: str
email: str | None
address: NotRequired[str]
# the boundary — runs once on each inbound payload
class CustomerIn(BaseModel):
id: str
email: str | None
address: str | None = None
# the domain — what the rest of the program holds and passes around
@dataclass(frozen=True, slots=True)
class Customer:
id: str
email: str | None
address: str | None
def parse(raw: CustomerWire) -> Customer:
m = CustomerIn.model_validate(raw)
return Customer(id=m.id, email=m.email, address=m.address)
This looks like ceremony until you ship the second feature. Mocks in
tests build Customer directly — no Pydantic, no
schema construction, no validation budget burned per assertion.
Internal functions take Customer, never the wire shape,
so a refactor on the API side stops at parse(). The
TypedDict is the type-checked promise about what the network sends,
decoupled from how you choose to validate it. Three layers, three
jobs, none of them fighting for the same role.
The shortcut is to skip the dataclass and pass CustomerIn
around. It works, and on a small project it is fine. The cost shows
up when you decide to swap Pydantic for msgspec, or when you start
constructing customers in tests and find yourself re-running validation
on values your test code just spelled out by hand.
Picking, in order
- Is the data crossing a trust boundary — network, user input, file on disk, LLM output? Pydantic.
- Do you already have the dict, and you only need the type checker to know its shape (with key-presence preserved)? TypedDict.
- Are you constructing the instance from values your own code already typed? Dataclass.
The short version: TypedDict for the wire, Pydantic for the parse, dataclass for the domain. One codebase can run all three; the sharp codebases do.