Enum vs StrEnum
StrEnum landed in 3.11 and quietly obsoleted the class Foo(str, Enum)
idiom that everyone had been writing for a decade. The difference is small,
the failure it removes is not.
Why an enum at all
A closed set of values, checked by the type checker and named in tracebacks.
from enum import Enum
class Status(Enum):
ACTIVE = "active"
BANNED = "banned"Against bare string constants you get three things: a typo becomes an
AttributeError instead of a silently valid new value, mypy rejects a
function called with "activ", and iteration and membership are free
(list(Status), "active" in Status).
The problem StrEnum solves
A plain Enum member is not a string, so every boundary needs .value:
Status.ACTIVE == "active" # False
json.dumps({"s": Status.ACTIVE}) # TypeErrorFor years the workaround was mixing in str. StrEnum makes it official:
from enum import StrEnum
class Status(StrEnum):
ACTIVE = "active"
BANNED = "banned"
Status.ACTIVE == "active" # True
json.dumps({"s": Status.ACTIVE}) # '{"s": "active"}'Members are genuine str instances — isinstance(Status.ACTIVE, str) is
true — so they pass anywhere a string is expected.
The gotcha: the mixin is not equivalent
class Status(str, Enum) and class Status(StrEnum) behave the same for
comparison and JSON, and differently for display. 3.11 changed
__format__ on mixin enums to include the class name:
| Operation | (str, Enum) |
StrEnum |
|---|---|---|
str(m) |
"Status.ACTIVE" |
"active" |
f"{m}" |
"Status.ACTIVE" |
"active" |
m == "active" |
True |
True |
json.dumps |
"active" |
"active" |
Gotcha: the two rows that differ are the ones that reach users. A log line or an f-string-built URL silently changed from
activetoStatus.ACTIVEwhen a codebase moved to 3.11 — while every test asserting== "active"kept passing.
That asymmetry is the whole reason to prefer StrEnum in new code: it makes
the display form match the value form, so there is nothing to remember.
auto() means something different here
from enum import StrEnum, auto
class Color(StrEnum):
RED = auto()
DARK_BLUE = auto()
Color.RED.value # "red"
Color.DARK_BLUE.value # "dark_blue"In a plain Enum, auto() produces 1, 2, 3. In StrEnum it produces the
lower-cased member name, which is usually what you want for an API value
and occasionally a surprise — renaming a member changes the wire format.
Spell the value out when it is part of a contract.
The rest of the family
| Type | Members are | Use for |
|---|---|---|
Enum |
opaque | internal closed sets |
StrEnum |
str |
API values, DB columns |
IntEnum |
int |
protocol and status codes |
Flag / IntFlag |
combinable | bitmask permissions |
Flag is the underused one — it gives you Perm.READ | Perm.WRITE with
in support, which beats hand-rolled bit arithmetic:
from enum import Flag, auto
class Perm(Flag):
READ = auto()
WRITE = auto()
ADMIN = READ | WRITE
Perm.READ in Perm.ADMIN # TrueGuarding against mistakes
from enum import StrEnum, verify, UNIQUE
@verify(UNIQUE)
class Status(StrEnum):
ACTIVE = "active"
ENABLED = "active" # raises at importWithout @verify(UNIQUE) the second name silently becomes an alias for the
first. @verify runs at class creation, so the failure is at import rather
than in production.
At the framework boundary
Pydantic and FastAPI both understand enums directly — the enum becomes an
OpenAPI enum constraint, and an invalid value is a 422 before your code
runs:
class Order(BaseModel):
status: Status # any StrEnum works
@app.get("/orders/{status}")
def list_orders(status: Status): ...SQLAlchemy maps one to a native ENUM column, or to a VARCHAR with a
check constraint. Prefer StrEnum here: a plain Enum round-trips as the
name, a StrEnum as the value, and the value is what the rest of your
system already agreed on.
When not to use one
- The set is open. If a third party can add a value, an enum means a deploy every time they do. Validate against a fetched list instead.
- The values are data. Country codes and currencies belong in a table, not in source.
- You need ordering. Enums are not ordered unless you make them
IntEnumor define__lt__; sortingStatusraisesTypeError.
Related
Interview angle 6
- “Why use an Enum over string constants?” - a closed, typed set of values that type checkers verify, with real names in tracebacks and no risk of a typo becoming a silent new value.
- “What does
StrEnumadd?” - members are genuinestrinstances (3.11+), so they serialise to JSON and compare to plain strings without.value. That removes the most common friction with enums at API boundaries. - “Is
class Foo(str, Enum)the same thing?” - not quite, and the difference bites. Comparison and JSON match, but 3.11 changed__format__on mixin enums, sostr()and f-strings giveFoo.MEMBERinstead of the value. Tests asserting equality keep passing while log lines and generated URLs change. - “What does
auto()do in aStrEnum?” - it yields the lower-cased member name, not an integer. Convenient, but it couples your wire format to your identifiers, so write the value out when it is part of a contract. - “When would you not use one?” - when the set is genuinely open or defined by external data. An enum that needs updating every time a third party adds a value is a maintenance liability.
- “How do you stop two names becoming aliases?” -
@verify(UNIQUE). By default a duplicate value silently makes the second name an alias for the first;@verifyturns that into an import-time error.