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.
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 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.
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 literal — if 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.
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.
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.
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:
- Subscripting a
total=Falsekey directly.profile["nickname"]assumes presence; mypy[typeddict-item], pyrightreportTypedDictNotRequiredAccess. Guard withinor use.get(). - Treating
total=Falseas “value can be None.” It does not touch the value type — a present key must still match its declared type. Usestr | None(orNotRequired[str | None]) for nullability. - Forgetting to narrow a
.get()result.profile.get("bio")isstr | None; calling.strip()on it directly is mypy[union-attr]/ pyrightreportOptionalMemberAccess. Narrow theNonefirst. - Mixing the two axes by accident. A
total=Falsekey with astr | Nonevalue can be absent or null; make sure your access handles both, or tighten the schema so only one axis varies. - Reaching for
total=Falsewhen 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 fieldNotRequired[str]instead. - Assuming
innarrows a variable key. Only a string literal narrows;if key in profilewithkey: strleaves the subscript at[typeddict-item]. Branch on the literal or funnel through.get(key), which is typedstr | Noneregardless.
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.