Every codebase has this module:
STATUS_PENDING = "pending"
STATUS_ACTIVE = "active"
STATUS_DONE = "done"
It works. What it does not give you is a way to enumerate the valid values, a
membership test, a namespace, or any error at all when someone writes
"pendign".
enum.StrEnum (3.11) is the migration target that requires changing almost
nothing else:
from enum import StrEnum, unique
@unique
class Status(StrEnum):
PENDING = "pending"
ACTIVE = "active"
DONE = "done"
Verified behaviour, all of which is what makes the migration cheap:
| Expression | Result |
|---|---|
str(Status.PENDING) |
'pending' |
f"{Status.PENDING}" |
'pending' |
Status.PENDING == "pending" |
True |
{"pending": 0}[Status.PENDING] |
works |
json.dumps({"s": Status.ACTIVE}) |
'{"s": "active"}' |
Status("bogus") |
ValueError |
A member is a str, so every existing comparison, dict lookup, log line
and json.dumps keeps working unchanged. What you gain on top is
list(Status), Status("...") as a validating parser, in for membership,
and a name to attach a docstring to.
Contrast a plain Enum with the same members: str(Plain.PENDING) is
'Plain.PENDING', Plain.PENDING == "pending" is False, dict lookup by
member fails, and json.dumps raises TypeError. Migrating to that means
touching every call site.
The 3.11 __str__ change, and where it does not apply
Python 3.11 changed __str__ on StrEnum and IntEnum to return the plain
value. It did not change the pre-3.11 idiom class S(str, Enum), which
still stringifies as 'S.PENDING'. So output genuinely changed at that
boundary: a service that logs f"{status}" and was written with
class S(str, Enum) prints S.PENDING, and the same class rewritten as a
StrEnum prints pending. If you are migrating an old mixin enum, expect log
lines, cache keys and serialised payloads to change.
💡@unique rejects duplicate values. What does an enum *without*
click to reveal
it do when two names share a value? It creates an alias. The second name becomes another way to refer to the first member — not a distinct member.
class Legacy(StrEnum):
OLD = "same"
NEW = "same"
Legacy.NEW is Legacy.OLD # True
Legacy.NEW.name # "OLD"
len(list(Legacy)) # 1 -- iteration skips aliases
That is deliberate and occasionally exactly what you want: a deprecated name
that keeps working. It is far more often a copy-paste bug, and the symptom is
bizarre — Legacy.NEW.name is "OLD", so a log line or a serialiser that
uses .name reports a name nobody wrote at the call site, and iteration
quietly has one fewer member than the class body suggests.
@unique turns it into a ValueError at class-creation time, which is import
time. Use it on every enum whose values are supposed to be distinct — which is
most of them.
The checker disagreement, verified
This is a genuine, current split rather than a bug to work around:
def handle(status: Literal["pending", "active"]) -> None: ...
handle(Status.PENDING)
mypy rejects it. pyright and ty accept it. mypy issue #19243 asking for
the behaviour was closed as not planned.
So “StrEnum or Literal?” is partly a question about which checker your team
runs. If you are on mypy, do not try to mix them at an API boundary — pick
one. Literal gives you exhaustiveness checking in match statements and
zero runtime cost; StrEnum gives you iteration, a validating constructor, a
namespace and a place to hang methods. For a value that arrives from outside
the program and must be validated, StrEnum wins on the constructor alone.
💡Status("bogus") raises ValueError. Why is that a better
click to reveal
boundary than checking if raw in VALID_STATUSES?
Three reasons, in ascending order of importance.
The check and the set cannot drift apart: adding a member updates both the parser and the enumeration at once, because they are the same object.
The failure is a ValueError with a message naming the class and the bad
value, raised at the exact line where the untrusted string entered — rather
than a False that some caller has to remember to turn into an error.
And the return type changes. Status(raw) gives you a Status, so
everything downstream is typed, and a function that takes Status cannot be
passed an unvalidated string by accident. raw in VALID leaves you holding a
str that you merely happen to have checked — the type system does not record
that you did, and three functions later nobody knows.
That last point is the general shape of a validated boundary: parse, do not check, and let the resulting type carry the proof.