Skip to content
← All articles

StrEnum: the migration path out of a module of string constants

Every existing comparison and JSON serialisation keeps working, and you gain iteration, membership, a namespace and a parse error. Plus a verified checker disagreement about Literal.

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.