Python TypedDict: Required vs NotRequired Keys

Python's typing.TypedDict allows developers to define types for dictionary keys and values, but early versions struggled with mixing mandatory and optional keys without cumbersome inheritance patterns. Introduced in Python 3.11 via PEP 655 (and available in earlier versions via typing_extensions), typing.Required and typing.NotRequired solve this issue. This article explains how these type qualifiers define key optionality on a per-field basis, how they interact with the total parameter, and how they differ from typing.Optional.

The Limitation of the total Parameter

Before Required and NotRequired, optionality in a TypedDict was controlled exclusively at the class level through the total boolean flag:

To create a dictionary where some keys were required and others were optional, developers had to create two separate TypedDict classes and inherit one from the other:

from typing import TypedDict

class UserBase(TypedDict):
    name: str

class User(UserBase, total=False):
    email: str

This approach led to boilerplate code, reduced readability, and scaled poorly for complex schemas.

Explicit Optionality with Required and NotRequired

PEP 655 introduced Required and NotRequired as type qualifiers to declare optionality directly on individual keys within a single TypedDict definition.

Using NotRequired in a Standard TypedDict (total=True)

Because total=True is the default behavior, every field is required unless explicitly marked with NotRequired.

from typing import TypedDict, NotRequired

class UserProfile(TypedDict):
    user_id: int              # Mandatory
    username: str             # Mandatory
    bio: NotRequired[str]     # Optional key
    avatar_url: NotRequired[str]  # Optional key

# Valid: bio and avatar_url are omitted
user_1: UserProfile = {
    "user_id": 1,
    "username": "jdoe"
}

# Valid: bio is included
user_2: UserProfile = {
    "user_id": 2,
    "username": "asmith",
    "bio": "Software engineer"
}

Using Required in a Partial TypedDict (total=False)

When total=False is specified, all fields are optional by default. You can use Required to specify which individual fields must always be present.

from typing import TypedDict, Required

class APIRequest(TypedDict, total=False):
    endpoint: Required[str]   # Mandatory key
    payload: dict             # Optional key
    timeout: int              # Optional key

# Valid: endpoint is provided, others are omitted
request: APIRequest = {
    "endpoint": "/api/v1/users"
}

NotRequired[T] vs. Optional[T]

A common point of confusion is the distinction between NotRequired[T] and Optional[T] (or T | None). They address different concepts in type checking:

from typing import TypedDict, Optional, NotRequired

class Settings(TypedDict):
    theme: Optional[str]        # Key MUST exist, value can be str or None
    notifications: NotRequired[bool]  # Key MAY be absent, value must be bool

# Valid
s1: Settings = {"theme": None}

# Valid
s2: Settings = {"theme": "dark", "notifications": True}

# Type Error: "theme" key is missing
s3: Settings = {"notifications": False}

If you want a key that can be omitted and accept a None value when present, you combine both: NotRequired[Optional[str]].

Inspecting Optionality at Runtime

Python tracks Required and NotRequired keys in special class attributes: __required_keys__ and __optional_keys__. These frozensets allow libraries, serializers, and runtime validation tools to inspect which fields must be present.

print(UserProfile.__required_keys__)  # frozenset({'user_id', 'username'})
print(UserProfile.__optional_keys__)  # frozenset({'bio', 'avatar_url'})

Using typing.Required and typing.NotRequired eliminates the need for multi-class inheritance workarounds and provides precise, readable control over dictionary schemas in Python type systems.