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.

TL;DR

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 versus Enum trade-offs Literal restricts which plain string or int values are allowed with no runtime object; Enum creates singleton member objects supporting identity, iteration and methods. Literal["sync", "async"] stays a plain str at runtime zero runtime cost excellent narrowing no iteration of members no methods / no identity class Mode(Enum) singleton member objects identity: Mode.SYNC is … iterate: for m in Mode methods & attributes real runtime objects
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. 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.

Literal as a value-membership gate The two members sync and async pass through the Literal boundary as valid str values; blocking and other strings are rejected with an arg-type error. Mode = Literal["sync", "async"] allowed set "sync" ✓ "async" ✓ "sync" "blocking" [arg-type] / reportArgumentType
A Literal acts as a compile-time membership gate over plain strings — members pass, everything else is an [arg-type] error.

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
Anatomy of an Enum member object The Mode class holds two singleton member objects, and each member exposes a value, a name, identity comparison, iteration and attached methods. class Mode(Enum) Mode.SYNC Mode.ASYNC .value → "sync" .name → "SYNC" is Mode.SYNC → identity is_blocking() → method for m in Mode: iterate all members
An Enum member is a real object carrying .value, .name, identity, iteration, and any methods you attach.

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
Exhaustiveness narrows the set down to Never Starting from the full Literal set, each handled case subtracts one member, and the final else reaches the empty Never type where assert_never is valid. mode: "sync" | "async" if "sync" → peel "sync" remaining: "async" elif "async" → peel "async" remaining: (empty) else: Never assert_never(mode) ✓
Each handled branch subtracts a member; a complete chain leaves Never, which is the only type assert_never accepts.

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.

Runtime vs static analysis `assert_never` is a real runtime function: if control actually reaches it (because data violated the type, e.g. a value deserialized from JSON), it raises `AssertionError`. The static guarantee that "this is unreachable" only holds while the data matches the annotation. For `Literal`, nothing validates the incoming string at runtime — pair it with a parse step or a `TypedDict`-backed loader if untrusted input can reach it.

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.

Literal versus Enum across three edge cases Rows are JSON round-trip, StrEnum bridge, and mixed-type sets; columns compare how Literal and Enum behave for each. Literal Enum JSON round-trip already a str — no convert .value out, Mode() in StrEnum bridge n/a — no member object == "sync" is True (3.11+) mixed types [200, 404, "default"] ✓ one type per class
Where the two diverge: JSON handling, the StrEnum bridge, and support for heterogeneous value sets.
  • Serialization boundaries: Literal["sync", "async"] round-trips through JSON with no conversion because the values are already strings, so json.dumps({"mode": mode}) just works. An Enum needs mode.value on the way out and Mode(value) on the way in — and Mode("blocking") raises ValueError: 'blocking' is not a valid Mode at runtime. Wrap the inbound Mode(value) in a try/except ValueError if the payload is untrusted.
  • StrEnum and IntEnum blend both: Python 3.11’s enum.StrEnum makes members be str subclasses, so Mode.SYNC == "sync" is True and the member drops straight into str slots and json.dumps while keeping iteration and identity. IntEnum does the same for integers (an HTTPStatus.OK == 200). Before 3.11 you emulate StrEnum with class Mode(str, Enum). These are the middle ground when you want Enum machinery but raw-value compatibility at the edges.
  • Mixed-type literals: Literal can mix str, int, bool, and None in one set, which an Enum cannot do cleanly. Literal[200, 404, "default"] is valid and narrows per value; an Enum would force each into a separately-named member of one value type. Note that Literal[True] and Literal[1] stay distinct — Literal does not collapse bool into int.
  • Recovering the members: with no runtime object, you introspect a Literal via typing.get_args(Mode), which returns ("sync", "async"). That is the closest analogue to list(Mode) on an Enum, 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.

Mistake to fix mapping for Literal and Enum Comparing an Enum member to a string is replaced by an identity check, and widening a Literal parameter to str is replaced by keeping the Literal alias. mistake fix mode == "sync" [comparison-overlap] — always False mode is Mode.SYNC identity — narrows correctly def f(mode: str) discards narrowing + exhaustiveness def f(mode: Mode) keep the Literal alias end to end
Both mistakes have a one-line fix: compare members by identity, and never widen a Literal to str.
  • Using == against an Enum member’s value by accident. mode == "sync" is always False when mode: Mode is a plain Enum — they are different types. mypy reports [comparison-overlap]: “Non-overlapping equality check”, and pyright flags reportGeneralTypeIssues. Compare mode is Mode.SYNC, or switch the class to StrEnum if you genuinely need value equality.
  • Widening a Literal to str too early. Annotating a helper’s parameter as str instead of the Literal alias discards narrowing and exhaustiveness; later [arg-type] errors vanish and the assert_never guard silently stops working because str never narrows to Never. Keep the Literal type all the way down the call chain.
  • Confusing Literal with Final. Final (PEP 591) forbids reassignment of a name; Literal constrains the set of values a slot may hold. x: Final = "sync" still has type str unless you write x: Final[Literal["sync"]]. They are orthogonal and do not substitute for one another.
  • Assuming a Literal is validated at runtime. It is erased at execution time — a function annotated Literal["sync", "async"] will happily accept "blocking" at runtime. Guard untrusted input with a Mode(value) Enum lookup, a value 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.

Back to Literal and TypedDict