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 changes whether the key is there; a nullable value changes what a present key holds.total=False makes every key optional
Set total=False and none of the keys are 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
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 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.
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.
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
- 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.
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.