Skip to content
← All Python articles

Python explainer

Replace String Constants with StrEnum

About 4 min to read. Allow extra time to work through examples.

List the valid status values in one place, turn incoming strings into members that still act like strings, and see where that stops.

This article uses Python. You can make it your practice mode to browse related content across the site.

An order moves through the statuses "pending", "active" and "done". The strings arrive in JSON and go back out to a database. If you pass plain strings around, a typo like "actve" passes through your code unnoticed, and something fails much later, far from the typo. A StrEnum lists the valid values in one place, and each member still works as a string.

import json
from enum import StrEnum, unique


@unique
class Status(StrEnum):
    PENDING = "pending"
    ACTIVE = "active"
    DONE = "done"


def is_status(status: Status, raw: str) -> bool:
    return status == raw


print(Status("active") is Status.ACTIVE)     # True
print(is_status(Status.DONE, "done"))        # True
print(f"order is {Status.PENDING}")          # order is pending
print(json.dumps({"status": Status.ACTIVE}))  # {"status": "active"}
print({"done": 2}[Status.DONE])              # 2

Each name in the class body, such as PENDING, is a member, and the string after = is its value. A member compares equal to its value, formats as its value, and works as a dictionary key wherever the value would.

The example puts the comparison in a function on purpose. mypy in strict mode reports an error for Status.DONE == "done" written out directly. It treats a member and a string literal as values that can never be equal, although at runtime the result is True. Comparing a Status parameter with a str parameter passes mypy.

Parse input at the edge of your program

Status(raw) finds the member whose value is raw. An unknown string raises ValueError, so a typo fails where the data comes in:

def parse_status(raw: str) -> Status:
    return Status(raw)


for raw in ["active", "ACTIVE"]:
    try:
        print(parse_status(raw).name)
    except ValueError as exc:
        print("rejected:", exc)
# ACTIVE
# rejected: 'ACTIVE' is not a valid Status

The lookup is exact, so "ACTIVE" and " active" both fail. To look a member up by its name instead, write Status["ACTIVE"].

The return type Status is narrower than str. When a function takes a Status parameter, mypy rejects a plain string passed to it. Code after the parser never sees an unchecked value.

Where it stops acting like a string

A StrEnum member is a real str. Three things still differ from a plain string:

  • type(member) is str is False. Pass str(member) to code that checks the exact type.
  • String methods return plain strings. Status.DONE.upper() is the str "DONE", not a member.
  • Members of two different StrEnum classes compare equal when their values match, because both equal the same string.

Two older patterns behave differently. A plain Enum member never equals its value, and json.dumps raises on it. A class Status(str, Enum) member equals its value, but str() and f-strings give Status.DONE instead of done. Before you switch an existing enum to StrEnum, check the places that format, store or compare its members.

Aliases and @unique

When two members have the same value, the second name becomes an alias, another name for the first member:

class Legacy(StrEnum):
    OLD = "same"
    NEW = "same"


print(Legacy["NEW"] is Legacy.OLD)  # True
print(Legacy.NEW.name)              # OLD
print(list(Legacy))                 # [<Legacy.OLD: 'same'>]

Looping over the class skips aliases. The @unique decorator turns an alias into an error. It raises ValueError when Python runs the class definition, which is usually at import time. Keep an alias only on purpose, for example so that an old name still works. Use @unique when every name needs its own value. You can also call unique(Legacy) on a class that already exists. It raises the same error and leaves the class as it was.

Check your understanding Should parse_status call .strip().lower() before it looks up the member? Show answer

Only if your program promises to accept those spellings. With that change, " ACTIVE " becomes a valid status. Without it, the parser rejects it. Either can be right, so decide on purpose and write it down. The exercise uses exact values, so normalizing the input would fail its tests.

Going further

  • In a StrEnum, auto() uses the member's name in lower case. PENDING = auto() gives the value "pending".
  • StrEnum arrived in Python 3.11, and the (str, Enum) pattern is what code used before it.
  • See the enum documentation.

step 48 of 55

Everyday Standard Library Tools

Up next Migrate a Status Enum to StrEnum