Type something to search...
Enums in Python: Replacing Magic Strings and Numbers

Enums in Python: Replacing Magic Strings and Numbers

Every codebase has them: if status == "shiped":, if role == 3:, set_mode("fast"). Magic strings and numbers work right up until someone makes a typo, or renames a value in one place and not the others. Python won't complain. The comparison just quietly returns False and the bug shows up as wrong behaviour, not as an error.

An enum gives a fixed set of related values a name and a type. Typos become AttributeErrors at the line where they happen, your editor can autocomplete the options, and a type checker can tell you when you've passed a string where a status was expected.

This post covers the enum module from the ground up: defining and looking up members, auto(), the StrEnum and IntEnum variants, Flag for combinable options, aliases and @unique, adding methods and properties to an enum, handling messy input with _missing_, and using enums with match, JSON, and databases. Everything here runs on Python 3.11 and newer, and the examples were tested on 3.13.

The Problem with Magic Values

Here's the kind of code enums replace:

def ship(order: dict) -> None:
    if order["status"] != "paid":
        raise ValueError("Only paid orders can ship")
    order["status"] = "shipped"


def is_done(order: dict) -> bool:
    return order["status"] in ("shipped", "canceled")  # or was it "cancelled"?

The valid statuses are scattered across string literals. There's no single place that lists them, nothing prevents "canceled" and "cancelled" from coexisting, and a type hint can only say str.

Defining Your First Enum

Subclass Enum and list the members as class attributes:

from enum import Enum


class OrderStatus(Enum):
    PENDING = "pending"
    PAID = "paid"
    SHIPPED = "shipped"
    CANCELLED = "cancelled"

Each attribute becomes a member: a singleton instance of OrderStatus with a name and a value.

s = OrderStatus.PAID
print(s)
print(repr(s))
print(s.name, s.value)
print(isinstance(s, OrderStatus), type(s))
OrderStatus.PAID
<OrderStatus.PAID: 'paid'>
PAID paid
True <enum 'OrderStatus'>

Members are constants. You can't reassign one:

OrderStatus.PAID = "x"
# AttributeError: cannot reassign member 'PAID'

And a typo fails loudly, right where you made it:

OrderStatus.SHIPED
# AttributeError: type object 'OrderStatus' has no attribute 'SHIPED'

Looking Up Members

You'll often need to go from raw data back to a member. Call the class with a value, or index it with a name:

print(OrderStatus("shipped"))     # by value
print(OrderStatus["CANCELLED"])   # by name
OrderStatus.SHIPPED
OrderStatus.CANCELLED

An invalid value raises ValueError, and an invalid name raises KeyError, which is exactly what you want when parsing input:

OrderStatus("refunded")
# ValueError: 'refunded' is not a valid OrderStatus

Iterating and Membership

Enum classes are iterable and have a length:

print(list(OrderStatus))
print(len(OrderStatus))
[<OrderStatus.PENDING: 'pending'>, <OrderStatus.PAID: 'paid'>, <OrderStatus.SHIPPED: 'shipped'>, <OrderStatus.CANCELLED: 'cancelled'>]
4

That's handy for building dropdowns, validating CLI choices, or generating documentation. Membership tests work with members, and since Python 3.12 they also accept raw values:

print(OrderStatus.PAID in OrderStatus)  # True
print("paid" in OrderStatus)            # True on 3.12+

Comparing Members

Members compare by identity. Because each member is a singleton, is and == both work, and members are hashable, so they make good dictionary keys:

s = OrderStatus.PAID
print(s == "paid", s is OrderStatus.PAID, s == OrderStatus.PAID)
False True True

Note the first result. A plain Enum member is not equal to its value. OrderStatus.PAID == "paid" is False. This is deliberate: it stops you from mixing raw strings and enum members by accident. If you do want string equality, use StrEnum (covered below).

Letting Python Pick Values with auto()

When the values don't matter, only the names do, use auto():

from enum import Enum, auto


class Color(Enum):
    RED = auto()
    GREEN = auto()
    BLUE = auto()


print(list(Color), Color.RED.value)
[<Color.RED: 1>, <Color.GREEN: 2>, <Color.BLUE: 3>] 1

For a plain Enum, auto() produces integers starting at 1. Use it when the value is an implementation detail. If the value is stored in a database or sent over an API, write it out explicitly: you don't want reordering the class to change what's in your tables.

StrEnum and IntEnum: Enums That Behave Like Their Values

Sometimes you need members that are strings or integers, because they're passed to code that expects one. Python provides two ready-made mixed-in types.

StrEnum

StrEnum (Python 3.11+) members are real str instances. They compare equal to strings, str() returns the value, and string methods work:

import json
from enum import StrEnum, auto


class Priority(StrEnum):
    LOW = auto()
    HIGH = auto()


print(Priority.LOW, repr(Priority.LOW), Priority.LOW == "low", Priority.HIGH.upper())
print(f"{Priority.HIGH}", json.dumps({"p": Priority.HIGH}))
low <Priority.LOW: 'low'> True HIGH
high {"p": "high"}

With StrEnum, auto() uses the lowercased member name as the value, which is usually exactly what you want. And because members are strings, json.dumps() serializes them without any extra work. Compare that with a plain Enum:

json.dumps({"s": OrderStatus.PAID})
# TypeError: Object of type OrderStatus is not JSON serializable

You'll see older code use class Color(str, Enum) to get a similar effect. It still works, but str() and f-strings give 'Color.RED' instead of 'red' on current Python versions, which surprises people. StrEnum is the cleaner choice on 3.11 and newer.

IntEnum

IntEnum members are integers, so they compare and sort with plain numbers:

from enum import IntEnum


class HttpStatus(IntEnum):
    OK = 200
    NOT_FOUND = 404


print(HttpStatus.OK == 200, HttpStatus.NOT_FOUND > 400, HttpStatus(404))
True True 404

The standard library uses this pattern itself: http.HTTPStatus.NOT_FOUND is an IntEnum member with extras like .phrase ('Not Found').

When to Use Which

TypeEqual to raw value?Good for
EnumNoInternal states where mixing with raw values would be a bug
StrEnumYes, to strValues that cross boundaries: JSON, URLs, config files, DB columns
IntEnumYes, to intInterop with numeric codes: HTTP statuses, protocol fields, legacy APIs
Flag / IntFlagNo / YesCombinable options (see next section)

My default is StrEnum for anything that gets serialized and plain Enum for purely internal states. The interop of StrEnum and IntEnum is convenient, but it also weakens the "you can't mix these up" guarantee, so don't reach for them by reflex.

Flag: Combinable Options

Some values aren't mutually exclusive. A user can have read and write permission. Flag members are bit values you can combine with |, intersect with &, and test with in:

from enum import Flag, auto


class Permission(Flag):
    READ = auto()
    WRITE = auto()
    DELETE = auto()
    ADMIN = READ | WRITE | DELETE


p = Permission.READ | Permission.WRITE
print(p)
print(Permission.WRITE in p, Permission.DELETE in p)
print(Permission.READ.value, Permission.WRITE.value, Permission.DELETE.value)
print(Permission.ADMIN, list(Permission.ADMIN))
Permission.READ|WRITE
True False
1 2 4
Permission.ADMIN [<Permission.READ: 1>, <Permission.WRITE: 2>, <Permission.DELETE: 4>]

For a Flag, auto() produces powers of two, so each member occupies its own bit. ADMIN is a named combination; iterating over it yields the individual flags it contains. Removing a flag uses & with the inverse:

p &= ~Permission.WRITE
print(p)  # Permission.READ

The empty flag, Permission(0), is falsy, so if user_perms: reads naturally. IntFlag is the integer-compatible version, useful when the flags come from an OS or C API that hands you a bitmask.

Aliases and @unique

Two members with the same value aren't two members. The second becomes an alias for the first:

from enum import Enum


class Size(Enum):
    SMALL = "s"
    S = "s"
    LARGE = "l"


print(Size.S, Size.S is Size.SMALL)
print(list(Size))
print(list(Size.__members__))
Size.SMALL True
[<Size.SMALL: 's'>, <Size.LARGE: 'l'>]
['SMALL', 'S', 'LARGE']

Iteration skips aliases, while __members__ includes them. Aliases are useful for renaming a member without breaking old code. When duplicates would be a mistake, decorate the class with @unique and Python will reject it at definition time:

from enum import Enum, unique


@unique
class Bad(Enum):
    A = 1
    B = 1
# ValueError: duplicate values found in <enum 'Bad'>: B -> A

Enums Are Classes: Add Methods and Properties

This is the part people miss. An enum is a full class, so you can attach behaviour that belongs to the set of values. That's often the cleanest home for logic that would otherwise be a scattered if/elif chain.

# orders/status.py
from enum import StrEnum, auto


class OrderStatus(StrEnum):
    PENDING = auto()
    PAID = auto()
    SHIPPED = auto()
    CANCELLED = auto()

    @property
    def is_final(self) -> bool:
        return self in {OrderStatus.SHIPPED, OrderStatus.CANCELLED}

    def can_transition_to(self, new: "OrderStatus") -> bool:
        allowed = {
            OrderStatus.PENDING: {OrderStatus.PAID, OrderStatus.CANCELLED},
            OrderStatus.PAID: {OrderStatus.SHIPPED, OrderStatus.CANCELLED},
        }
        return new in allowed.get(self, set())


print(OrderStatus.PAID.is_final, OrderStatus.SHIPPED.is_final)
print(OrderStatus.PENDING.can_transition_to(OrderStatus.SHIPPED))
print(OrderStatus.PAID.can_transition_to(OrderStatus.SHIPPED))
False True
False
True

Now the order state machine lives next to the states. Code that ships an order calls status.can_transition_to(OrderStatus.SHIPPED) instead of re-encoding the rules.

Members with Multiple Values

If a member's value is a tuple and the enum defines __init__, the tuple is unpacked into it. That lets each member carry several related attributes:

from enum import Enum


class Planet(Enum):
    MERCURY = (3.303e23, 2.4397e6)
    EARTH = (5.976e24, 6.37814e6)

    def __init__(self, mass: float, radius: float) -> None:
        self.mass = mass
        self.radius = radius

    @property
    def surface_gravity(self) -> float:
        G = 6.67430e-11
        return G * self.mass / (self.radius ** 2)


print(f"{Planet.EARTH.surface_gravity:.2f}")  # 9.80

The .value is still the full tuple; mass and radius are extra attributes set on the member.

Handling Messy Input with _missing_

Real input is rarely clean: " PAID ", "Paid", "paid". Rather than normalising in every caller, override the _missing_ class method. Python calls it when a value lookup fails, and you can return a member or None:

from enum import StrEnum, auto


class OrderStatus(StrEnum):
    PENDING = auto()
    PAID = auto()
    SHIPPED = auto()
    CANCELLED = auto()

    @classmethod
    def _missing_(cls, value):
        if isinstance(value, str):
            lowered = value.strip().lower()
            for member in cls:
                if member.value == lowered:
                    return member
        return None


print(repr(OrderStatus(" PAID ")))
OrderStatus("nope")
<OrderStatus.PAID: 'paid'>
ValueError: 'nope' is not a valid OrderStatus

Returning None keeps the normal ValueError for values that really are invalid.

Enums and Pattern Matching

Enums pair well with match. Because members are accessed with a dot, they're treated as value patterns, not capture variables:

def describe(status: OrderStatus) -> str:
    match status:
        case OrderStatus.PENDING:
            return "Waiting for payment"
        case OrderStatus.PAID | OrderStatus.SHIPPED:
            return "In progress"
        case OrderStatus.CANCELLED:
            return "Cancelled"


print(describe(OrderStatus.SHIPPED))  # In progress

A type checker like mypy or Pyright can also tell you when a match over an enum doesn't handle every member, which is a nice safety net when you add a new state. The structural pattern matching post covers match in more depth.

Enums at the Boundaries: JSON, Databases, and APIs

Inside your program, pass members around. At the edges, convert to and from plain values:

  • Writing out: store member.value, not member.name or str(member). Values are the stable contract; names may get renamed for style.
  • Reading in: call OrderStatus(raw_value) and let the ValueError surface invalid data early.
  • JSON: StrEnum and IntEnum serialize directly. For a plain Enum, pass default=lambda o: o.value to json.dumps() or convert explicitly.
  • Libraries: Pydantic, SQLAlchemy, and Django all understand enums. Pydantic validates incoming strings into members; SQLAlchemy has an Enum column type; Django has its own TextChoices and IntegerChoices built on the same idea.

A small round trip:

import json
from enum import StrEnum, auto


class Priority(StrEnum):
    LOW = auto()
    HIGH = auto()


payload = json.dumps({"task": "deploy", "priority": Priority.HIGH})
data = json.loads(payload)
priority = Priority(data["priority"])
print(payload, repr(priority))
{"task": "deploy", "priority": "high"} <Priority.HIGH: 'high'>

The Functional API

For quick enums, or ones built from data, there's a one-line form:

from enum import Enum, StrEnum

Weekday = Enum("Weekday", ["MON", "TUE", "WED"])
Level = StrEnum("Level", ["DEBUG", "INFO"])

print(list(Weekday))
print(list(Level))
[<Weekday.MON: 1>, <Weekday.TUE: 2>, <Weekday.WED: 3>]
[<Level.DEBUG: 'debug'>, <Level.INFO: 'info'>]

It's handy in tests and scripts, but the class syntax is easier to read and document, and it's the only way to add methods.

Common Mistakes

  • Comparing a plain Enum to a string. OrderStatus.PAID == "paid" is False. Either compare members to members, convert the string first, or use StrEnum.
  • Using auto() for persisted values. Reordering members changes the numbers. Be explicit for anything stored.
  • Storing str(member). For a plain Enum that's 'OrderStatus.PAID', which leaks a class name into your data.
  • Mutable values. Members should be immutable. Use tuples, not lists, for multi-value members.
  • Huge enums for open-ended data. Country codes from a stable standard are fine as an enum; product categories managed by your marketing team probably belong in a database table.

Conclusion

Enums replace scattered string and number literals with a single, named, typed set of values. Typos fail immediately, editors autocomplete the options, and logic about those values (transitions, labels, derived properties) has an obvious place to live.

Use plain Enum for internal state, StrEnum when values cross into JSON, URLs, or databases, IntEnum for numeric codes, and Flag when options combine. Convert to .value at the edges, parse with MyEnum(raw) on the way in, and let the rest of your code work with members.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading