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. 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.

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

  • Serialization boundaries: Literal["sync", "async"] round-trips through JSON with no conversion because the values are already strings. An Enum needs mode.value on the way out and Mode(value) on the way in — and Mode("blocking") raises ValueError at runtime.
  • StrEnum blends both: Python 3.11’s enum.StrEnum makes members be strings, so Mode.SYNC == "sync" is True while keeping iteration and identity. It is a middle ground when you want Enum ergonomics but string 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; an Enum would force each into a member.

Common mistakes

  • 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.” Compare mode is Mode.SYNC instead.
  • 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 bugs slip through. Keep the Literal type 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.

Back to Literal and TypedDict