← Essays
§ Essay

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
Samples
Output
// 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

  1. Is the data crossing a trust boundary — network, user input, file on disk, LLM output? Pydantic.
  2. Do you already have the dict, and you only need the type checker to know its shape (with key-presence preserved)? TypedDict.
  3. 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.