total=False TypedDict vs Optional Values

TL;DR — total=False on a TypedDict means every key may be absent from the dict. That is a different guarantee from a key that is always present but whose value may be None (str | None). Absence forces you to check with in or read with .get() before use; a nullable value lets you index directly and then narrow away None. Confusing the two is the most common TypedDict mistake.

Presence and nullability are two independent axes, and TypedDict lets you set them separately. total=False moves the presence axis for the whole class: keys become optional. An Optional value type moves the nullability axis for one key: the key is there, but might hold None. This page pulls them apart, because the access patterns and the error codes differ.

Presence versus nullability total controls whether a key is present in the dict, while the value type controls whether a present key may hold None. They are independent. total=False → key may be ABSENT "nickname" not in payload read with .get() or check in payload["nickname"] → [typeddict-item] key might not exist at all value: str | None → key PRESENT "nickname" in payload always index directly, then narrow None payload["nickname"] → str | None value may be null
total=False changes whether the key is there; a nullable value changes what a present key holds.

total=False makes every key optional

total= is a class keyword argument on the TypedDict definition, introduced with TypedDict itself in PEP 589. The default is total=True, meaning every declared key is required; flipping it to False makes every key optional at once — none is guaranteed to exist. A checker then refuses direct subscript access, because the key might be missing:

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypedDict

class UserProfile(TypedDict, total=False):
    display_name: str
    nickname: str
    bio: str

def greet(profile: UserProfile) -> str:
    return "Hi " + profile["nickname"]   # mypy: Key "nickname" of TypedDict "UserProfile"
                                         # may be absent  [typeddict-item]
Every key optional under total=False With total=False the three keys display_name, nickname and bio each sit on a rung marked may be absent, so none is guaranteed present. class UserProfile(TypedDict, total=False) display_name: str may be ABSENT nickname: str may be ABSENT bio: str may be ABSENT __required_keys__ = frozenset() → {} is a valid UserProfile
Under total=False every key drops onto the optional stack; the empty dict satisfies the type.

Every construction below is valid, precisely because absence is allowed — including the empty dict:

# Python 3.11+, mypy 1.10 / pyright 1.1.370
a: UserProfile = {}                                  # OK — all keys optional
b: UserProfile = {"display_name": "Dana"}            # OK
c: UserProfile = {"nickname": "dee", "bio": "hi"}    # OK

The switch is visible at runtime through the class’s introspection attributes. TypedDict records which keys are required and which are optional on __required_keys__ and __optional_keys__ — both frozensets — so total=False moves every name into the optional set:

>>> UserProfile.__required_keys__
frozenset()
>>> UserProfile.__optional_keys__
frozenset({'display_name', 'nickname', 'bio'})

total= accepts only a literal True or False; you cannot pass a variable, and a checker rejects anything else. For finer control you don’t flip the whole class — mark individual keys with Required[...] or NotRequired[...] from PEP 655, which the Required and NotRequired guide covers. Inside a total=False class a single Required[str] key becomes mandatory again, and inside a default class a single NotRequired[str] key becomes optional — so total= sets the baseline and the per-key markers override it point by point.

A version note: TypedDict moved into the standard-library typing module in Python 3.11. On 3.8–3.10 import it from typing_extensions (the typing.TypedDict back-port there lacks Required/NotRequired support until 3.11). The functional form takes the same keyword — TypedDict('UserProfile', {'display_name': str}, total=False) — which is handy when a key name is not a valid identifier such as "content-type". Inheritance keeps each parent’s own total: a total=True base combined with a total=False subclass yields a class whose inherited keys stay required and whose newly declared keys are optional.

Reading an optional key safely

To read an optional key you must prove it is present, or supply a fallback. The two idioms are an in check (which narrows) and .get() (which returns None or a default):

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
def label(profile: UserProfile) -> str:
    if "nickname" in profile:
        return profile["nickname"]        # narrowed: key is present → str
    return profile.get("display_name", "guest")   # returns str, with default

After if "nickname" in profile: the checker narrows the subscript to the value type and the earlier [typeddict-item] error disappears. .get("display_name") without a default is typed str | None, so you then narrow away the None before using it as a str.

Safe read paths for an optional key Reading an optional key branches into an in check that narrows to the value type, or a get call that returns the value type or None or a default. read profile["nickname"]? if "nickname" in profile presence proven profile.get("nickname") supply a fallback narrowed to str index directly, no error str | None (or default) narrow None, then use
Two safe paths: an in check narrows the key to its value type; .get() hands back the value, None, or a default.

The narrowing from in is real flow-sensitive analysis, but it is fragile in ways worth knowing. Both mypy and pyright only narrow when the key is a string literalif key in profile: with a key: str variable does not narrow the subscript, because the checker cannot tie the runtime name back to a declared field. Narrowing also survives only inside the guarded block; assigning profile to another name, mutating it, or calling a function that could delete the key can invalidate the narrowing. Pull the value into a local instead:

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
def label(profile: UserProfile) -> str:
    nickname = profile.get("nickname")     # str | None, computed once
    if nickname is not None:
        return nickname                    # narrowed to str, decoupled from the dict
    return profile.get("display_name", "guest")

.get() is the more robust idiom precisely because it snapshots the value. Its overloads are worth reading: profile.get("nickname") is str | None, while profile.get("nickname", "") is str because the default rules out None — the second overload’s return type is str | T where T is the default’s type. Note that .get("nickname", 0) type-checks (returning str | int), which is rarely what you want; keep the default the same type as the value. Two more accessors round out the set: profile.pop("nickname") is rejected on a total=False TypedDict without a default because the key may be absent, and setdefault is typed but mutates the dict, so reserve it for genuinely building the payload rather than reading it.

An EAFP alternative — try: name = profile["nickname"] except KeyError: — is correct at runtime, but the checker does not treat the try body as narrowing: the subscript inside still reports [typeddict-item], because static analysis cannot know the except will catch it. That is a concrete reason to prefer the LBYL in/.get() idioms with TypedDict even where you would normally reach for try/except on a plain dict. Nested optional keys compound the problem: reaching payload["address"]["zip"] when both address and zip are optional needs a guard at each level, so a .get("address", {}).get("zip") chain (or an early return after one in check) keeps the access flat and the checker happy.

The contrast: a present key with an Optional value

Now the other axis. Keep the dict total=True (the default) so the key is always present, but let its value be null with str | None. The access pattern is the opposite — you index freely, then narrow the value:

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypedDict

class Account(TypedDict):
    user_id: int
    nickname: str | None        # key ALWAYS present; value may be None

def render(acc: Account) -> str:
    name = acc["nickname"]      # OK — key is guaranteed present, type is str | None
    if name is None:
        return f"user-{acc['user_id']}"
    return name                 # narrowed to str

No [typeddict-item] here: acc["nickname"] is always safe because the key exists. What you must handle is the None value, exactly like any other Optional type. If you want both — the key optional and its value nullable — combine them with NotRequired[str | None], which the Required and NotRequired guide covers in depth.

Access pipeline for a present nullable key A present key with value str or None is indexed directly, then narrowed by an is None check into a definite str for use. acc["nickname"] key always present str | None no [typeddict-item] if name is None narrow the value str safe to use total=True key, value str | None
With the key guaranteed present, the work moves entirely to narrowing the None out of the value.

The distinction is not academic — it changes which annotation faithfully models your data. A field that is sometimes omitted from the wire format entirely (a partial PATCH body, a sparse config) is a presence question: model it with total=False or NotRequired. A field that is always sent but can be explicitly null (a JSON null, a cleared database column) is a nullability question: model it with T | None. Getting this backwards produces annotations that pass the type checker yet lie about the runtime shape.

A partial-update endpoint is the canonical place this bites. A PATCH body carries only the fields the client wants to change, so the presence of a key is itself meaningful — “email is in the dict” means “set the email”, and “email is absent” means “leave it untouched”. That is fundamentally different from “email is present and None”, which in a PATCH usually means “clear the email”. A total=False TypedDict (or per-key NotRequired) captures the first distinction, and adding NotRequired[str | None] captures both at once, letting the handler tell absent (skip), null (clear), and value (assign) apart by combining an in/.get() guard with an is None check:

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import TypedDict, NotRequired

class UserPatch(TypedDict, total=False):
    email: NotRequired[str | None]     # absent = skip, None = clear, str = set

def apply_patch(current: str | None, patch: UserPatch) -> str | None:
    if "email" not in patch:
        return current                 # field omitted → leave unchanged
    return patch["email"]              # present: str (set) or None (clear)

The four combinations are worth naming explicitly: x: str (present, non-null), NotRequired[str] (maybe absent, non-null when present), x: str | None (present, maybe null), and NotRequired[str | None] (maybe absent, and null when present). Only the last requires a caller to handle both an in/.get() guard and an is None check, which is why over-using it makes call sites needlessly defensive.

Analyzer behavior

mypy flags direct access to a possibly-absent key with [typeddict-item], the same code it uses for a missing required key or a wrong-typed value. pyright reports possibly-absent access under reportTypedDictNotRequiredAccess, which is more targeted than mypy’s shared code and can be tuned independently. After an in narrowing both checkers agree the key is present. For a nullable value, the diagnostics are the ordinary Optional ones — mypy [union-attr] if you call a method on the un-narrowed value, pyright reportOptionalMemberAccess. See pyright vs mypy for how their optional-access reporting differs in practice.

Which diagnostic each checker emits A three-column matrix mapping four access situations to the mypy error code and the pyright diagnostic rule reported for each. situation mypy code pyright rule index absent key [typeddict-item] reportTypedDict NotRequiredAccess after "k" in d clean clean .method() on str | None [union-attr] reportOptional MemberAccess wrong value type [typeddict-item] reportArgumentType
mypy reuses [typeddict-item] across several failures; pyright splits them into named, individually toggleable rules.

That difference in granularity matters when you tune strictness. Because mypy funnels absent-key access, missing-required-key, and wrong-value-type all through [typeddict-item], a single # type: ignore[typeddict-item] silences more than you might intend — prefer fixing the access. pyright’s split lets you, for example, set reportTypedDictNotRequiredAccess to "warning" while keeping value-type mismatches at "error". Both checkers also honor assert "nickname" in profile and an early if "nickname" not in profile: return as narrowing constructs, not just the positive if form. One asymmetry to remember: neither checker narrows across a function boundary, so a helper like def _has(d: UserProfile, k: str) -> bool that returns a membership result will not narrow at the call site — that requires a TypeGuard or TypeIs return annotation to carry the narrowing back.

Runtime vs static analysis total=False exists only for the checker; at runtime the object is a plain dict. Indexing a missing key raises a real KeyError, while a present-but-None value returns None with no error. That is the practical difference the type system is modeling: absence blows up on access, nullability quietly hands you None. Use .get() for the former and an is None check for the latter.

Common mistakes

Almost every total=False bug is a confusion between the presence axis and the nullability axis, and each has a mechanical fix. The mapping below pairs the tempting wrong pattern with the corrected one:

Common total=False mistakes and their fixes Four rows each pair a mistaken access or annotation on the left with the corrected form on the right. mistake fix profile["nickname"] if "nickname" in profile: ... total=False to mean "nullable" value: str | None profile.get("bio").strip() b = profile.get("bio"); if b: ... flip whole class to total=False one key: NotRequired[str]
Every fix is the same move: separate "is the key here?" from "is the value null?" and address each with the right tool.
  • Subscripting a total=False key directly. profile["nickname"] assumes presence; mypy [typeddict-item], pyright reportTypedDictNotRequiredAccess. Guard with in or use .get().
  • Treating total=False as “value can be None.” It does not touch the value type — a present key must still match its declared type. Use str | None (or NotRequired[str | None]) for nullability.
  • Forgetting to narrow a .get() result. profile.get("bio") is str | None; calling .strip() on it directly is mypy [union-attr] / pyright reportOptionalMemberAccess. Narrow the None first.
  • Mixing the two axes by accident. A total=False key with a str | None value can be absent or null; make sure your access handles both, or tighten the schema so only one axis varies.
  • Reaching for total=False when only one key is optional. Flipping the whole class makes every field require a guard, spreading noise across every call site. Keep the class total and mark the single field NotRequired[str] instead.
  • Assuming in narrows a variable key. Only a string literal narrows; if key in profile with key: str leaves the subscript at [typeddict-item]. Branch on the literal or funnel through .get(key), which is typed str | None regardless.

FAQ

Is total=False the same as making every value Optional? No. total=False says each key may be missing; Optional values say each present key may be None. The first requires presence checks (in/.get()), the second requires None narrowing after a direct index. They can even stack as NotRequired[T | None].

How do I make just one key optional instead of all of them? Leave the class total=True and mark the single key NotRequired[...]. total=False is the whole-class switch; per-key NotRequired is the surgical one, described in the Required and NotRequired guide.

Back to Literal and TypedDict