Unpack for Typed Kwargs (PEP 692)

TL;DR — PEP 692 lets you give **kwargs a precise, per-key type by unpacking a TypedDict: def request(**kwargs: Unpack[RequestOptions]). Each key in the TypedDict becomes a typed keyword argument, Required/NotRequired control which are mandatory, and mypy reports [typeddict-item] or [call-arg] when a caller passes the wrong key or type. Available on Python 3.12+, or 3.11 via typing_extensions.

Before PEP 692, annotating **kwargs: str forced every keyword value to the same type — useless for the common case where a function forwards a bag of differently-typed options. Unpacking a TypedDict solves this: you describe the keyword surface once as a structured type and the checker validates each call site against it. This page, part of Advanced Typing Patterns & Generics, shows the pattern, the required/optional distinction, and how the two major checkers report violations.

Unpack maps a TypedDict onto keyword arguments The RequestOptions TypedDict lists timeout, retries, and verify keys with their types; Unpack projects each one onto a matching keyword parameter that the checker validates per call. RequestOptions timeout: float (Required) retries: int (NotRequired) verify: bool (NotRequired) one TypedDict of options Unpack request(**kwargs) timeout=2.0, retries=3 each keyword type-checked
Unpack projects each TypedDict key onto a keyword parameter the checker validates independently.

The canonical pattern

Define a TypedDict whose keys are your keyword names, then annotate **kwargs with Unpack[...]. On Python 3.12 both Unpack and TypedDict live in typing; on 3.11 import them from typing_extensions. The star-import symmetry matters: Unpack[SomeTypedDict] in the **kwargs position is a special form the checker recognises, not an ordinary generic alias, so importing it from the wrong module on an older interpreter silently disables PEP 692 rather than raising.

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

class RequestOptions(TypedDict):
    timeout: float
    retries: NotRequired[int]
    verify: NotRequired[bool]

def request(url: str, **kwargs: Unpack[RequestOptions]) -> bytes:
    timeout = kwargs["timeout"]          # float
    retries = kwargs.get("retries", 0)   # int
    ...
    return b""

request("https://api.example.com", timeout=2.0, retries=3)   # ok
request("https://api.example.com", timeout=2.0, verify=True) # ok

Inside the body, kwargs is a RequestOptions, so kwargs["timeout"] is a float and the missing-key rules of the TypedDict apply. At the call site, each keyword is checked against its declared type independently — exactly what a flat **kwargs: object could never express.

The three rungs of the Unpack kwargs pattern Define a TypedDict, annotate the kwargs parameter with Unpack of that TypedDict, and the checker validates each keyword argument at every call site. 1. Define TypedDict class RequestOptions(TypedDict): timeout: float ... 2. Annotate **kwargs def request(url, **kwargs: Unpack[RequestOptions]) 3. Each keyword checked timeout=2.0 → float, retries=3 → int
Structure travels up the ladder: the TypedDict's shape becomes the function's keyword contract, enforced per call.

Two properties are easy to miss. First, reveal_type(kwargs) inside the body prints RequestOptions, not dict[str, object] — the parameter genuinely has the TypedDict type, so kwargs.get("retries", 0) narrows to int and kwargs["missing"] is a static [typeddict-item] key error, not a runtime KeyError the checker ignores. Second, PEP 692 requires that the unpacked TypedDict be the last parameter and that no plain **kwargs-style catch-all follow it; positional and positional-or-keyword parameters (url above) come first and are unaffected. You do not need from __future__ import annotations for the pattern to work — Unpack is resolved as a form, not evaluated as a runtime expression in a way that would trip on forward references, though quoting the TypedDict name still works if it is defined later in the module. A subtle backport note: typing.Unpack existed from 3.11 for TypeVarTuple unpacking, but applying it to **kwargs (the PEP 692 meaning) is only recognised by the stdlib typing on 3.12+, so on 3.11 you must route both TypedDict and Unpack through typing_extensions for the checker to treat the annotation as a typed-keyword surface rather than a variadic-tuple splat.

Required vs NotRequired keys

Which keywords a caller must pass is governed by the TypedDict itself. A plain key is required; wrapping it in NotRequired makes it optional. The mirror image, Required, pins a specific key as mandatory inside a total=False TypedDict. See Required and NotRequired keys for the full matrix.

# Python 3.12+, checked with mypy 1.10
from typing import TypedDict, Unpack, Required

class RetryPolicy(TypedDict, total=False):
    backoff: float
    max_attempts: Required[int]   # mandatory despite total=False

def send(**kwargs: Unpack[RetryPolicy]) -> None: ...

send(max_attempts=5)              # ok — backoff is optional
send(backoff=1.5)                 # mypy error: [call-arg] — missing max_attempts

Omitting a required key raises [call-arg] in mypy (pyright: reportCallIssue), the same error you would get from missing a normal positional parameter — the TypedDict’s totality rules become the function’s arity rules.

Totality and per-key overrides decide which keywords are mandatory A grid maps a plain key, a NotRequired key, and a Required key against total equals True and total equals False, showing whether each is a mandatory keyword. total=True (default) total=False plain key NotRequired[T] Required[T] mandatory optional optional optional mandatory mandatory
Totality sets the default; Required and NotRequired override individual keys either way.

The interaction is orthogonal, which the matrix above makes concrete: total=True (the default) makes every plain key mandatory and NotRequired[T] opts a single key out; total=False flips the default to optional and Required[T] opts a single key back in. This lets one TypedDict express a mixed contract without splitting it into two — the common shape for an options bag where one identifier is compulsory and the rest are tunable. There is one caller-side subtlety that trips people up: an empty call like send() against a total=False TypedDict with no Required keys is legal, because every key is optional, so PEP 692 lets **kwargs be passed nothing at all. Conversely, a total=True TypedDict makes the function behave like it has that many required keyword-only parameters. You can also inherit: a TypedDict that subclasses another accumulates the parent’s keys with their required-ness intact, and unpacking the child accepts the union of both key sets. On the runtime side none of these markers do anything — Required, NotRequired, and total are pure static metadata (see Required and NotRequired keys), so a caller that constructs a raw dict and splats it dodges the check entirely.

How analyzers report violations

Two failure modes dominate, and the checkers label them slightly differently. Passing a key that the TypedDict does not declare is an unexpected keyword; passing a declared key with the wrong value type is a type error.

# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
request("https://api.example.com", timeout="2.0")
# mypy: [typeddict-item]  — "2.0" is str, expected float
# pyright: reportArgumentType

request("https://api.example.com", timeout=2.0, follow=True)
# mypy: [call-arg]  — unexpected keyword "follow"
# pyright: reportCallIssue

mypy uses [typeddict-item] for a wrong value type and [call-arg] for an unknown or missing keyword; pyright folds both into reportArgumentType / reportCallIssue. Both agree on the outcome — the mismatch is caught — so this pattern is portable across a mixed pyright and mypy setup.

Which diagnostic a bad Unpack call produces A bad call branches on whether the keyword is unknown, missing, or the wrong value type, each leading to a distinct mypy error code and pyright rule. bad call site unknown keyword follow=True missing required timeout omitted wrong value type timeout="2.0" mypy [call-arg] pyright reportCallIssue mypy [call-arg] pyright reportCallIssue mypy [typeddict-item] pyright reportArgumentType
The kind of mismatch — unknown, missing, or wrong-typed — selects the exact diagnostic each checker emits.

A few finer points help when reading these diagnostics in CI. mypy attaches the [call-arg] code to both a missing required key and an unexpected keyword, so grepping the code alone will not distinguish them — read the message text, which says either Missing named argument "timeout" or Unexpected keyword argument "follow". For the wrong-type case, mypy’s [typeddict-item] message names the offending key and both the expected and received types, e.g. Argument "timeout" ... has incompatible type "str"; expected "float". pyright is coarser at the code level but often more precise in prose: reportArgumentType for a bad value type and reportCallIssue for arity or unknown-key problems, each with the parameter name inlined. One edge case worth knowing: passing a float where an int is declared is rejected under strict settings but tolerated by mypy’s default numeric-tower leniency only for intfloat, never the reverse, so timeout (a float key) happily accepts 2 but retries (an int key) rejects 3.0 with [typeddict-item]. Because both tools converge on catching the mismatch, a repository can run pyright in the editor and mypy in CI without the PEP 692 surface drifting between them. A last diagnostic to anticipate is the duplicate keyword case: if a positional-or-keyword parameter and a TypedDict key share a name — say the function already has a real timeout: float parameter and the TypedDict also declares timeout — mypy raises [misc] at definition time because the same name cannot be both an explicit parameter and an unpacked key. Rename one side. Similarly, marking the function itself with @overload and unpacking a TypedDict in one overload is legal, but each overload’s keyword surface is validated independently, so a caller that matches no overload gets the ordinary [call-overload] error rather than a per-key [typeddict-item], which can make the root cause harder to read; prefer a single signature with one TypedDict when the keyword surface is uniform.

Runtime vs static analysis Unpack[RequestOptions] changes nothing at runtime — kwargs is still a plain dict, and Python will happily accept an undeclared keyword or a wrong-typed value. The TypedDict is never instantiated and its Required/NotRequired markers are not enforced. Only the static checker rejects a bad call; if you need runtime validation, add explicit key checks or a library like pydantic.

Forwarding kwargs between functions

The pattern shines when one function forwards options to another. Annotate both ends with the same TypedDict and the checker verifies that the forwarded bag still satisfies the callee.

# Python 3.12+, checked with mypy 1.10
from typing import TypedDict, Unpack, NotRequired

class ConnectOptions(TypedDict):
    host: str
    port: NotRequired[int]

def open_socket(**opts: Unpack[ConnectOptions]) -> None: ...

def connect(retries: int, **opts: Unpack[ConnectOptions]) -> None:
    for _ in range(retries):
        open_socket(**opts)   # ok — opts already matches ConnectOptions

Because opts is typed as ConnectOptions, splatting it into open_socket(**opts) type-checks with no cast — the two signatures share one source of truth.

One TypedDict forwarded intact between two functions A caller passes keywords into connect, whose opts parameter typed as ConnectOptions is splatted into open_socket, which shares the same TypedDict contract. caller host="db", port=5432 connect(retries, **opts) opts: ConnectOptions **opts open_socket(**opts) same ConnectOptions one contract, no cast
The typed bag flows through untouched because both signatures reference the identical TypedDict.

The safety here is real, not cosmetic: if open_socket later gains a Required key that ConnectOptions lacks, or connect is annotated with a wider TypedDict than open_socket accepts, the open_socket(**opts) line fails with [call-arg] (pyright reportCallIssue) at the forwarding site — the mismatch surfaces where the bug is, not three frames deeper at runtime. A common variation is to make the inner contract a superset via subclassing: class TLSOptions(ConnectOptions): verify: NotRequired[bool]. Forwarding a ConnectOptions bag into a function that expects TLSOptions is rejected (the bag might be missing nothing required, but the checker cannot prove verify obeys any constraint it does not know), whereas forwarding a TLSOptions into an open_socket that wants ConnectOptions is accepted because every ConnectOptions key is present. This is ordinary TypedDict assignability — width subtyping on the required keys — applied to the keyword surface. Note one limitation both checkers share: you cannot forward a partially built plain dict and expect narrowing; open_socket(**{"host": h}) degrades to dict[str, str] and mypy will complain it is not a ConnectOptions. Keep the value typed as the TypedDict end to end, or build it with a ConnectOptions(host=h) constructor call so the structure is preserved.

Common mistakes

Most PEP 692 bugs are a wrong-hand-side annotation or an import that quietly disables the semantics rather than erroring. The before-and-after below pairs each pitfall with its fix; the detailed list follows.

Four Unpack kwargs pitfalls and their fixes Each row shows an unsound annotation on the left and the corrected TypedDict-based form on the right. mistake fix Unpack[dict[str, int]] Unpack[Options] (a TypedDict) **kwargs: object (untyped bag) add key as NotRequired[T] timeout="2.0" (str for float) timeout=2.0 (match the type) from typing import Unpack (3.11) from typing_extensions import Unpack
Left column silently disables or breaks PEP 692; right column restores per-keyword checking.
  • Using Unpack on a non-TypedDict: **kwargs: Unpack[dict[str, int]] is invalid — mypy reports [misc] ("Unpack" only valid with a TypedDict for **kwargs), pyright reportGeneralTypeIssues. Only a TypedDict may be unpacked into **kwargs; a dict, NamedTuple, dataclass, or TypeVarTuple are all rejected here.
  • Passing an unknown keyword: any keyword absent from the TypedDict triggers [call-arg] / reportCallIssue. Add the key (as NotRequired if optional) rather than falling back to **kwargs: object, which discards every per-key guarantee and lets any keyword through.
  • Wrong value type for a declared key: yields [typeddict-item] in mypy (reportArgumentType in pyright). The value must match the key’s annotated type, not merely be truthy — verify=1 fails against verify: bool even though 1 is falsy/truthy-compatible at runtime.
  • Importing from typing on 3.11: Unpack for **kwargs needs Python 3.12 in the stdlib typing; on 3.11 import it from typing_extensions or the checker will not apply PEP 692 semantics — the annotation is accepted but the call site is not validated, the worst outcome because it looks safe.
  • Following the unpacked TypedDict with more parameters: def f(**kwargs: Unpack[Opts], extra: int) is a syntax-level impossibility, and putting a second ** bag after it is rejected as [misc]. The unpacked kwargs must be the final parameter, mirroring an ordinary **kwargs.

FAQ

Can I combine Unpack kwargs with regular positional parameters? Yes. Positional and positional-or-keyword parameters come first, then **kwargs: Unpack[SomeTypedDict] last — for example def connect(retries: int, **opts: Unpack[ConnectOptions]). The TypedDict only governs the keyword surface.

Does PEP 692 replace TypedDict for return values or storage? No. PEP 692 is purely about the call surface of **kwargs. Use a normal TypedDict value where you actually build and pass a dict; Unpack is the bridge that turns that structure into individually-checked keyword arguments.

Back to Variadic Generics with TypeVarTuple