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.

Per-key presence overrides total The total setting sets the default presence for every key, while Required and NotRequired override that default for individual keys. total=True (default) keys required unless overridden order_id: int → required customer: str → required coupon: NotRequired[str] → optional total=False keys optional unless overridden theme: str → optional locale: str → optional user_id: Required[int] → required
total sets each key's default presence; Required and NotRequired flip it for one key at a time.

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:

Required and optional keys in a total=True dict OrderPayload requires order_id and customer and marks coupon optional; a payload omitting coupon is accepted while one missing customer is rejected. OrderPayload keys order_id: int — required customer: str — required coupon: NotRequired[str] — optional sample payloads {order_id, customer} coupon omitted → valid {order_id} missing customer → [typeddict-item] total=True makes every key required until NotRequired overrides it
Two keys are required by the total=True default; only coupon is opted out with 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:

Per-key presence in a total=False dict In a total=False TypedDict each key is optional unless marked Required, in which case it must always be present. total=False: optional unless Required a key in the dict marked Required? yes no user_id: Required[int] must always be present theme, locale: str may be omitted
Under total=False the default flips: keys are optional, and only a Required qualifier forces presence.
# 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 NotRequiredtyping 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:

Unknown key versus missing key A key not declared in the schema triggers a typeddict-unknown-key error, while a declared required key that is omitted triggers a typeddict-item error. Two different key errors key in the literal declared in schema? no — "themme" yes unknown key [typeddict-unknown-key] present → OK omitted & required → [typeddict-item]
An undeclared key is a spelling or membership error; a declared-but-omitted required key is a missing-item error.
# 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.

TypedDict diagnostics by checker Each TypedDict presence issue mapped to the mypy error code and the pyright report it produces. issue mypy code pyright missing required / wrong value [typeddict-item] reportGeneralTypeIssues undeclared (typo) key [typeddict-unknown-key] reportGeneralTypeIssues read NotRequired unguarded (no error) reportTypedDictNotRequiredAccess
The last row is the sole real divergence: only pyright flags an unguarded read of a NotRequired key.

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.

Runtime vs static analysis 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.

Four common Required and NotRequired mistakes A vertical stack of four common per-key presence mistakes, each paired with the diagnostic the checker produces. Four per-key presence mistakes read a NotRequired key without narrowing pyright report (mypy: silent) wrap a value in both Required and NotRequired invalid definition typo in a literal dict key [typeddict-unknown-key] assume total=False makes a value nullable type mismatch
The top row is the one mypy misses entirely — pyright is the checker that catches an unguarded optional read.
  • Reading a NotRequired key without narrowing. Accessing payload["coupon"] directly assumes it is present; pyright flags this with reportTypedDictNotRequiredAccess, while mypy stays silent. Guard with if "coupon" in payload: or use payload.get("coupon"), which is typed str | None and forces you to handle the missing case.
  • Wrapping a value in both Required and NotRequired. 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], pyright reportGeneralTypeIssues. This is a feature: it turns a silent runtime KeyError far 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=False makes a key nullable. total=False controls presence, not value; a key that is present still must match its declared type, so {"theme": None} is a type error unless the annotation is str | None. Use Required[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.

Back to Literal and TypedDict