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.
Literal is a typing-only restriction; Enum trades runtime weight for identity, iteration and behavior.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. The analyzer rejects any value outside the set.
# Python 3.11+, mypy 1.x
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
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 and no
import-time object is built.
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 objects.
# Python 3.11+, mypy 1.x
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.value)
open_connection(Mode.SYNC) # OK
open_connection("sync") # mypy error: [arg-type] — expected "Mode"
Note the cost: callers must import Mode and pass Mode.SYNC, not the bare string. That coupling
is worth it when behavior or iteration matters, and a liability when the value just crosses a JSON
boundary.
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 typing.assert_never (Python 3.11+; typing_extensions before that).
# Python 3.11+, mypy 1.x
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
If you later extend the alias to Literal["sync", "async", "batch"], the else no longer narrows
to Never, and mypy reports it:
# Python 3.11+, mypy 1.x — after adding "batch"
assert_never(mode)
# mypy error: [arg-type] — Argument 1 to "assert_never" has incompatible type
# "Literal['batch']"; expected "Never"
# pyright: reportArgumentType
The identical pattern works for Enum with a match statement, where each case Mode.SYNC: narrows
the subject and the fallthrough case _: calls assert_never.
Edge cases
- Serialization boundaries:
Literal["sync", "async"]round-trips through JSON with no conversion because the values are already strings. AnEnumneedsmode.valueon the way out andMode(value)on the way in — andMode("blocking")raisesValueErrorat runtime. StrEnumblends both: Python 3.11’senum.StrEnummakes members be strings, soMode.SYNC == "sync"isTruewhile keeping iteration and identity. It is a middle ground when you want Enum ergonomics but string compatibility at the edges.- Mixed-type literals:
Literalcan mixstr,int,bool, andNonein one set, which anEnumcannot do cleanly.Literal[200, 404, "default"]is valid; anEnumwould force each into a member.
Common mistakes
- 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.” Comparemode is Mode.SYNCinstead. - Widening a
Literaltostrtoo early. Annotating a helper’s parameter asstrinstead of theLiteralalias discards narrowing and exhaustiveness; later[arg-type]errors vanish and bugs slip through. Keep theLiteraltype all the way down the call chain.
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.