Required and NotRequired Keys in TypedDict
TL;DR — Required[...] and NotRequired[...] (PEP 655) set whether a single key must be present, independent of the TypedDict’s total= setting. In a default total=True dict, mark the one optional key NotRequired[...]; in a total=False dict, mark the one mandatory key Required[...]. That per-key control replaces the old all-or-nothing choice and the awkward split-into-two-classes inheritance trick.
Before PEP 655, a TypedDict had exactly one presence knob: the class-wide total flag. If most keys were required but one was optional, you either split the schema into a required base and a total=False subclass, or gave up precision. Required and NotRequired remove that constraint — presence is now a property of each key. They live in typing from Python 3.11, and in typing_extensions for older runtimes.
A mostly-required dict with one optional key
Start with a total=True TypedDict — the default — where every key is mandatory except one. Mark that single key NotRequired:
# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import NotRequired, TypedDict
class OrderPayload(TypedDict):
order_id: int
customer: str
coupon: NotRequired[str] # may be absent
good: OrderPayload = {"order_id": 7, "customer": "acme"} # coupon omitted → OK
also: OrderPayload = {"order_id": 7, "customer": "acme", "coupon": "SAVE10"}
bad: OrderPayload = {"order_id": 7} # mypy: Missing key "customer" [typeddict-item]
# pyright: reportGeneralTypeIssues
Because the dict is total=True, order_id and customer are required by default and only coupon is optional. This is the shape you reach for constantly: an API record where nearly everything is mandatory and a field or two is optional.
NotRequired lives in typing from Python 3.11 (PEP 655); on 3.8–3.10 import it from typing_extensions, where both checkers treat it identically. It is meaningful only inside a TypedDict body — applying it to a normal variable or parameter is an error. Crucially, NotRequired[str] says the key may be absent; it does not make the value nullable. If a caller may send coupon explicitly set to None, that is NotRequired[str | None], a distinct and stricter contract than plain absence.
Reading the optional key reflects its optionality. payload.get("coupon") is typed str | None, because the key may be missing, whereas subscripting payload["coupon"] is typed str but only type-checks after you have narrowed presence with if "coupon" in payload. On construction, mypy reports a missing required key as [typeddict-item] while pyright reports it under reportGeneralTypeIssues; neither needs strict mode, since presence checking is on by default.
You can mix as many Required and NotRequired keys as a schema needs; the class-wide total only sets the default that each qualifier overrides, and the checker resolves every key independently at definition time. One caveat: TypedDict presence is checked at construction and assignment, not on dict methods that mutate in place. payload.pop("customer") on a required key is not flagged, because pop is typed generically against the underlying dict. If a required key can be removed after the fact, the checker cannot protect you — treat the TypedDict as an input contract at the boundary, avoid destructive mutations on it, and re-validate after any. That boundary discipline is what keeps the static shape and the runtime object from drifting apart.
A mostly-optional dict with one required key
Invert it. A settings patch where callers send whatever they want to change is naturally total=False, but you still need one anchoring key present. Mark that key Required:
# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import Required, TypedDict
class SettingsPatch(TypedDict, total=False):
theme: str
locale: str
user_id: Required[int] # must always be present
ok: SettingsPatch = {"user_id": 42} # everything else omitted → OK
ok2: SettingsPatch = {"user_id": 42, "theme": "dark"}
nope: SettingsPatch = {"theme": "dark"} # mypy: Missing key "user_id" [typeddict-item]
Required and NotRequired never nest — a key is exactly one or the other — and they cannot wrap each other. What they can wrap is any value type, including a Required[str | None] when the key must be present but its value may be null. That is a genuinely different constraint from the key being absent, which is the subject of the total=False vs Optional guide.
Required imports from the same places as NotRequired — typing on 3.11+, typing_extensions earlier. The choice of the total default is purely ergonomic: set it to whichever presence most keys share, then override the minority. A PATCH-style settings body is the canonical total=False case, because callers send only the fields they are changing; the single Required key — here user_id — is the routing anchor that must always accompany the patch so the server knows what to update.
The Required[str | None] shape deserves emphasis in this inverted direction too. It means the key must be present but its value may be null, a stronger guarantee than a bare optional key and a common pattern for JSON APIs that distinguish “field cleared” (an explicit null) from “field untouched” (absent entirely). Both qualifiers are legal only inside a TypedDict body; you cannot annotate a function parameter Required[int], and doing so is a [valid-type] error in mypy.
Before PEP 655, expressing “mostly optional with one required key” meant the inheritance trick: a required base class plus a total=False subclass, or the reverse. That pattern still works and reads well for large schemas, but it forces the shape across two class definitions and makes a single odd-one-out key look like a structural decision rather than a small exception. Per-key Required/NotRequired collapses the common case back into one class that is easier to scan and to keep in sync with the wire format it mirrors. Reach for the two-class split only when the base is genuinely reused on its own — for instance a shared Identifiable base carrying id: int that several unrelated payloads extend.
Unknown keys and typo detection
Setting per-key presence also tightens what counts as a valid key. A key that is not declared at all is a different error from a declared-but-missing one:
# Python 3.11+, mypy 1.10 / pyright 1.1.370
patch: SettingsPatch = {"user_id": 1, "themme": "dark"}
# mypy: Extra key "themme" for TypedDict "SettingsPatch" [typeddict-unknown-key]
# pyright: reportGeneralTypeIssues
The [typeddict-unknown-key] code catches typos in literal dict displays. It fires only for keys that are entirely unknown to the schema; a known key that is simply omitted (and required) is [typeddict-item] instead. Keeping the two straight speeds up debugging: unknown-key means “you spelled it wrong or it does not belong,” missing-item means “you left out something mandatory.”
The unknown-key check fires specifically for literal dict displays and direct key assignments — the places where the checker can see the complete set of keys. Building a dict dynamically erases that information: d = {}; d["themme"] = "x"; f(d) slips past the typo check that a literal f({"themme": "x"}) would catch. That is a strong reason to construct TypedDict values as literals at the trust boundary rather than mutating an empty dict into shape.
This strictness exists because a TypedDict is closed: only its declared keys are valid, unlike a plain dict[str, str] that accepts any string key. mypy introduced the dedicated [typeddict-unknown-key] code precisely so this typo class stops being conflated with the missing-key [typeddict-item]; pyright surfaces both through reportGeneralTypeIssues. At runtime none of this applies — the stray key is stored like any other dict entry, which is exactly why catching it at type-check time is worthwhile: it converts a silent, far-away KeyError into an error at the line where the typo was written.
In practice teams pair this with a thin deserialization boundary: parse the raw JSON, construct the TypedDict as a literal (or validate it with a runtime library), and keep the rest of the codebase working against the typed shape. The unknown-key and missing-key checks then fire exactly where external data enters the program, and every downstream access is already known-good to the checker — which is the whole point of layering a static contract over a dynamic dict.
Analyzer behavior
mypy reports a missing required key and a wrong-typed value under [typeddict-item], and an undeclared key under [typeddict-unknown-key]. pyright folds both into reportGeneralTypeIssues, and additionally offers reportTypedDictNotRequiredAccess when you read a NotRequired key without first narrowing its presence. Both checkers understand Required/NotRequired natively on 3.11+; on older interpreters import them from typing_extensions and the same diagnostics apply. None of this needs strict mode, though pyright’s not-required-access report is stricter about reads than mypy’s default. See pyright vs mypy for where the two diverge on optional-key access.
The one axis where the checkers genuinely diverge is reading a NotRequired key. pyright’s reportTypedDictNotRequiredAccess flags payload["coupon"] unless you have narrowed presence first; mypy has no equivalent and lets the read through, trusting your guard. That single difference means a module can be clean under mypy and fail under pyright, so a team gating on both should either narrow every optional read or lower pyright’s report to a warning during migration. Everything else — missing required keys, wrong value types, undeclared keys — is reported by both checkers, in the codes above, without strict mode.
Required and NotRequired are pure type-checker constructs. At runtime a TypedDict is a plain dict, so nothing stops you building {"order_id": 7} without the required customer key — no exception is raised until some later code does payload["customer"] and hits a genuine KeyError. If you need presence enforced when data arrives, validate with Pydantic or an explicit key check at the boundary.
Common mistakes
The recurring Required/NotRequired mistakes cluster around two confusions: presence versus value, and literal versus dynamic construction. The stack below lists them with the diagnostic each produces.
- Reading a
NotRequiredkey without narrowing. Accessingpayload["coupon"]directly assumes it is present; pyright flags this withreportTypedDictNotRequiredAccess, while mypy stays silent. Guard withif "coupon" in payload:or usepayload.get("coupon"), which is typedstr | Noneand forces you to handle the missing case. - Wrapping a value in both
RequiredandNotRequired. A key is exactly one or the other; nesting them (Required[NotRequired[str]]) is rejected — mypy reports an invalid TypedDict definition. Pick the single correct qualifier for the key’s presence. - Typos in a literal dict.
{"themme": ...}is an undeclared key — mypy[typeddict-unknown-key], pyrightreportGeneralTypeIssues. This is a feature: it turns a silent runtimeKeyErrorfar downstream into a type-check-time failure on the exact line, but only when the dict is written as a literal rather than mutated into shape. - Assuming
total=Falsemakes a key nullable.total=Falsecontrols presence, not value; a key that is present still must match its declared type, so{"theme": None}is a type error unless the annotation isstr | None. UseRequired[str | None]when the key must exist but may carry a null value — a stronger, more precise contract than a plain optional key.
FAQ
Can I mix Required and NotRequired in the same TypedDict?
Yes. Set the class-wide total to whichever default fits most keys, then override the exceptions individually. A total=True dict can carry several NotRequired keys, and a total=False dict can carry several Required ones.
Do I still need the inheritance trick of a base plus a total=False subclass?
No. Per-key Required/NotRequired was designed to retire that pattern. It remains valid and readable for large schemas, but for a handful of exceptions the per-key qualifiers are clearer and keep the whole shape in one class.