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.
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.
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.
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.
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 int→float, 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.
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.
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.
- Using
Unpackon a non-TypedDict:**kwargs: Unpack[dict[str, int]]is invalid — mypy reports[misc]("Unpack" only valid with a TypedDict for **kwargs), pyrightreportGeneralTypeIssues. Only a TypedDict may be unpacked into**kwargs; adict,NamedTuple, dataclass, orTypeVarTupleare all rejected here. - Passing an unknown keyword: any keyword absent from the TypedDict triggers
[call-arg]/reportCallIssue. Add the key (asNotRequiredif 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 (reportArgumentTypein pyright). The value must match the key’s annotated type, not merely be truthy —verify=1fails againstverify: booleven though1is falsy/truthy-compatible at runtime. - Importing from
typingon 3.11:Unpackfor**kwargsneeds Python 3.12 in the stdlibtyping; on 3.11 import it fromtyping_extensionsor 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.