Backend / Python core / 24_enum_vs_strenum.md

Enum vs StrEnum

Updated 6 interview angles 4 min read source
On this page8
  1. Why an enum at all
  2. The problem StrEnum solves
  3. auto() means something different here
  4. The rest of the family
  5. At the framework boundary
  6. When not to use one
  7. Related
  8. Interview angle

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.

python
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:

python
Status.ACTIVE == "active"        # False
json.dumps({"s": Status.ACTIVE}) # TypeError

For years the workaround was mixing in str. StrEnum makes it official:

python
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 active to Status.ACTIVE when 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

python
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:

python
from enum import Flag, auto

class Perm(Flag):
    READ = auto()
    WRITE = auto()
    ADMIN = READ | WRITE

Perm.READ in Perm.ADMIN     # True

Guarding against mistakes

python
from enum import StrEnum, verify, UNIQUE

@verify(UNIQUE)
class Status(StrEnum):
    ACTIVE = "active"
    ENABLED = "active"      # raises at import

Without @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:

python
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 IntEnum or define __lt__; sorting Status raises TypeError.

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 StrEnum add?” - members are genuine str instances (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, so str() and f-strings give Foo.MEMBER instead of the value. Tests asserting equality keep passing while log lines and generated URLs change.
  • “What does auto() do in a StrEnum?” - 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; @verify turns that into an import-time error.