typing.Literal vs Enum: Choosing a Fixed Set of Constants
For a closed set of constant values like "sync" and "async", you can model it with
typing.Literal
or with enum.Enum. Literal["sync", "async"] adds zero runtime cost, narrows beautifully, and
gives compile-time exhaustiveness; Enum creates real runtime objects with identity, iteration,
and methods. This guide shows when each is the right call and how to get exhaustiveness checking
out of both with assert_never.
Use Literal["sync", "async"] when the constants are just string/int values flowing across an API
boundary — it is zero-runtime-cost and narrows perfectly. Use enum.Enum when you need runtime
identity, iteration over members, or methods/attributes on each constant. Both support exhaustive
match/if checks via typing.assert_never.
Literal is a typing-only construct (PEP 586, Python 3.8+): the values stay as plain str or
int at runtime, and the checker simply restricts which of those values a variable may hold. An
Enum is a runtime class whose members are singleton instances — Mode.SYNC is Mode.SYNC is
True, the members are iterable, and you can attach behavior. That runtime presence is the entire
trade-off.
Step 1: Model the set with Literal
When the constant is just a value passed across a function or API boundary, Literal is the
lightest annotation. Literal came from PEP 586 and lives in typing on Python 3.8+; on 3.7 you
import it from typing_extensions. It takes one or more exact values — str, int, bool,
bytes, None, or Enum members — and the analyzer rejects anything outside that set.
# Python 3.11+, mypy 1.10 / pyright 1.1.370
from typing import Literal
Mode = Literal["sync", "async"]
def open_connection(mode: Mode) -> None:
...
open_connection("sync") # OK
open_connection("blocking") # mypy error: [arg-type] — Argument 1 has incompatible type
# "Literal['blocking']"; expected "Literal['sync', 'async']"
# pyright: reportArgumentType — "blocking" is not assignable to "sync" | "async"
Defining Mode once as a type alias keeps the
literal set in one place. At runtime mode is an ordinary str, so mode == "sync" works, string
methods are available, and no import-time object is built. This is the whole appeal for API
boundaries: a value read from JSON is already a str, so a Literal annotation adds a
compile-time constraint over data you never have to convert.
On Python 3.12+ you can spell the alias with the PEP 695 type
statement — type Mode = Literal["sync", "async"] — which creates a lazily-evaluated TypeAliasType
and reads more clearly than the bare assignment; both forms are equivalent to the checker. A Literal
also composes inside larger types, which is where the single alias pays off: dict[str, Mode],
list[Mode], and Mode | None all stay in sync when you edit the one definition, and reveal_type
on a narrowed variable prints Literal['sync'] rather than str, confirming the checker is tracking
the exact value rather than widening back to the base type. Because the constraint is purely static,
a Literal parameter is also completely free to a subclass or a Protocol implementer — there is no
member object to instantiate, no metaclass, and nothing to import beyond the alias name.
Step 2: Model the set with Enum when you need runtime behavior
If you need to iterate the members, compare by identity, or hang methods off each constant, reach
for Enum. The members become real singleton objects: Mode.SYNC is Mode.SYNC is always True,
list(Mode) yields every member in definition order, and Mode.SYNC.value recovers the underlying
"sync" string. Each member also carries a .name ("SYNC") distinct from its .value.
# Python 3.11+, mypy 1.10 / pyright 1.1.370
from enum import Enum
class Mode(Enum):
SYNC = "sync"
ASYNC = "async"
def is_blocking(self) -> bool: # behavior attached to the constant
return self is Mode.SYNC
def open_connection(mode: Mode) -> None:
if mode.is_blocking():
...
for member in Mode: # iteration is free with Enum
print(member.name, member.value) # SYNC sync / ASYNC async
open_connection(Mode.SYNC) # OK
open_connection("sync") # mypy error: [arg-type] — Argument 1 has
# incompatible type "str"; expected "Mode"
# pyright: reportArgumentType
Note the cost: callers must import Mode and pass Mode.SYNC, not the bare string. A plain Enum
member is not equal to its value — Mode.SYNC == "sync" is False — so it does not drop into
str-typed slots without an explicit .value. That coupling is worth it when behavior or iteration
matters, and a liability when the value just crosses a JSON boundary. If you want the class to
subclass int or str so members compare equal to raw values, use IntEnum or the 3.11+
StrEnum, covered under Edge cases below.
Enum brings a toolbox that Literal has no equivalent for. enum.auto() assigns sequential values
so you never hand-number members; the @enum.unique decorator rejects duplicate values at class
creation with a ValueError, guarding against accidental aliases (by default a second member with the
same value becomes an alias of the first rather than a distinct member). Overriding the _missing_
classmethod lets you accept lenient input — mapping "SYNC", "sync", and " sync " all onto
Mode.SYNC inside a single lookup — which is impossible with a Literal. For sets of flags meant to
be combined bitwise, enum.Flag/IntFlag give you Mode.A | Mode.B semantics that a Literal cannot
express at all. There is also a functional constructor, Mode = Enum("Mode", ["SYNC", "ASYNC"]), but
type checkers understand the class form far better — mypy only partially infers members from the
functional call — so prefer the class syntax when you care about static analysis. One typing note:
mypy infers Mode.SYNC.value as str here because every value is a string, but for a mixed-value
Enum the inferred .value type widens to Any, so annotate the values explicitly if you rely on
.value downstream.
Step 3: Get exhaustiveness checking with assert_never
Both forms support compile-time exhaustiveness: if you add a third constant and forget a branch, the
analyzer flags it through assert_never. It arrived in typing in Python 3.11 (specified by PEP
484’s NoReturn/Never machinery); on 3.8–3.10 import it from typing_extensions. The trick is
that each handled branch narrows away one member, so a truly exhaustive chain leaves the fall-through
with type Never — and assert_never is typed to accept only Never.
# Python 3.11+, mypy 1.10 / pyright 1.1.370
from typing import Literal, assert_never
Mode = Literal["sync", "async"]
def describe(mode: Mode) -> str:
if mode == "sync":
return "blocking"
elif mode == "async":
return "non-blocking"
else:
assert_never(mode) # mode narrows to Never here → OK
If you later extend the alias to Literal["sync", "async", "batch"], the else no longer narrows to
Never — "batch" is still live — and both checkers report it at the assert_never call:
# Python 3.11+, mypy 1.10 — after adding "batch"
assert_never(mode)
# mypy error: [arg-type] — Argument 1 to "assert_never" has incompatible type
# "Literal['batch']"; expected "Never"
# pyright: reportArgumentType — "Literal['batch']" is not assignable to "Never"
The identical pattern works for Enum with a match statement, where each case Mode.SYNC: narrows
the subject and the fall-through case _: calls assert_never. pyright additionally offers
reportMatchNotExhaustive, which flags a non-exhaustive match even without the assert_never
sentinel; mypy relies on the assert_never idiom. Prefer assert_never over a bare raise AssertionError — only the former gives the analyzer the exhaustiveness signal. See
TypeGuard and type narrowing
for how narrowing composes with these checks.
Two details make this reliable in practice. First, the branches must each be a terminal statement
for the narrowing to compound — an early return, raise, or continue in every handled branch is
what leaves the fall-through with the residual type; a chain that merely assigns and falls through
keeps the full union alive and defeats the check. Second, match on an Enum should use the member
pattern case Mode.SYNC: (a value pattern), not case Mode.SYNC as x: with an unrelated capture,
because only the value pattern narrows the subject; a bare capture pattern case x: matches
everything and silently makes the match exhaustive for the wrong reason. Because assert_never is
statically typed as def assert_never(arg: Never) -> Never, it doubles as documentation: a reader
sees immediately that the branch is meant to be unreachable, and any future member added to Mode
turns that intent into a hard type error at exactly the sites that need updating.
Edge cases
Three edges decide most real choices between the two forms: how each survives a serialization
round-trip, whether StrEnum/IntEnum erases the ergonomic gap, and whether the set is
single-typed or mixed. The matrix below lines them up before the detail.
- Serialization boundaries:
Literal["sync", "async"]round-trips through JSON with no conversion because the values are already strings, sojson.dumps({"mode": mode})just works. AnEnumneedsmode.valueon the way out andMode(value)on the way in — andMode("blocking")raisesValueError: 'blocking' is not a valid Modeat runtime. Wrap the inboundMode(value)in atry/except ValueErrorif the payload is untrusted. StrEnumandIntEnumblend both: Python 3.11’senum.StrEnummakes members bestrsubclasses, soMode.SYNC == "sync"isTrueand the member drops straight intostrslots andjson.dumpswhile keeping iteration and identity.IntEnumdoes the same for integers (anHTTPStatus.OK == 200). Before 3.11 you emulateStrEnumwithclass Mode(str, Enum). These are the middle ground when you want Enum machinery but raw-value compatibility at the edges.- Mixed-type literals:
Literalcan mixstr,int,bool, andNonein one set, which anEnumcannot do cleanly.Literal[200, 404, "default"]is valid and narrows per value; anEnumwould force each into a separately-named member of one value type. Note thatLiteral[True]andLiteral[1]stay distinct —Literaldoes not collapseboolintoint. - Recovering the members: with no runtime object, you introspect a
Literalviatyping.get_args(Mode), which returns("sync", "async"). That is the closest analogue tolist(Mode)on anEnum, and it is handy for building a runtime validator from the same alias.
Common mistakes
Most bugs here come from crossing the two models — comparing an Enum member to a raw value, or
throwing away a Literal’s precision by widening it to str. Each has a mechanical fix that keeps
the type system on your side.
- Using
==against an Enum member’s value by accident.mode == "sync"is alwaysFalsewhenmode: Modeis a plainEnum— they are different types. mypy reports[comparison-overlap]: “Non-overlapping equality check”, and pyright flagsreportGeneralTypeIssues. Comparemode is Mode.SYNC, or switch the class toStrEnumif you genuinely need value equality. - Widening a
Literaltostrtoo early. Annotating a helper’s parameter asstrinstead of theLiteralalias discards narrowing and exhaustiveness; later[arg-type]errors vanish and theassert_neverguard silently stops working becausestrnever narrows toNever. Keep theLiteraltype all the way down the call chain. - Confusing
LiteralwithFinal.Final(PEP 591) forbids reassignment of a name;Literalconstrains the set of values a slot may hold.x: Final = "sync"still has typestrunless you writex: Final[Literal["sync"]]. They are orthogonal and do not substitute for one another. - Assuming a
Literalis validated at runtime. It is erased at execution time — a function annotatedLiteral["sync", "async"]will happily accept"blocking"at runtime. Guard untrusted input with aMode(value)Enumlookup, avalue in get_args(Mode)check, or a Pydantic model.
FAQ
Which is faster?
Literal has no runtime footprint at all, so it is strictly cheaper at import and call time. Enum
member construction happens once per class at import; per-use cost is negligible but non-zero.
Can I iterate over a Literal’s allowed values?
Not directly — there is no runtime object to iterate. Use typing.get_args(Mode) to recover the
tuple of values, or choose Enum if iteration is a core need.