Stop Using Magic Strings: A Practical Deep Dive into Python's enum
Learn how to replace scattered magic strings and integer constants with real, self-documenting enums. This deep dive covers Enum basics, auto(), aliases, methods on members, IntEnum, Flag, StrEnum, the functional API, and the pitfalls that trip people up.
Almost every codebase is riddled with them: status == "active", if role == 2, direction = "N". These bare strings and integers — often called magic values — are easy to typo, impossible to autocomplete, and give you no protection when you pass the wrong one. Python's enum module fixes this by letting you define a fixed set of named, self-documenting constants that behave like first-class objects. This post is a practical tour of enum: the basics, the shortcuts, how to attach behavior to members, the specialized variants, and the handful of gotchas that cause most confusion.
The basics: named constants with identity
An enumeration is a class that subclasses Enum. Each attribute becomes a distinct member with a .name and a .value.
from enum import Enum
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
print(Color.RED) # Color.RED
print(Color.RED.name) # RED
print(Color.RED.value) # 1
You can look members up by value with call syntax, or by name with subscript syntax — handy when the value comes from a database row or the name from a config file:
print(Color(2)) # Color.GREEN (by value)
print(Color["BLUE"]) # Color.BLUE (by name)
Two properties make enums far safer than loose constants. First, members are singletons, so you compare them with is:
print(Color.RED is Color.RED) # True
print(Color.RED == Color.RED) # True
Second, enums are iterable and their members are hashable, so you can loop over them or use them as dictionary keys:
for color in Color:
print(color.name, color.value)
# RED 1 / GREEN 2 / BLUE 3
auto(): stop counting by hand
When the underlying integer value does not matter — you only care that each member is distinct — auto() assigns values for you, starting at 1:
from enum import Enum, auto
class Direction(Enum):
NORTH = auto()
SOUTH = auto()
EAST = auto()
WEST = auto()
print([(d.name, d.value) for d in Direction])
# [('NORTH', 1), ('SOUTH', 2), ('EAST', 3), ('WEST', 4)]
This keeps the definition clean and avoids the classic bug of accidentally reusing a number when you insert a new member in the middle.
Aliases and @unique
If two members share the same value, the second name becomes an alias of the first rather than a separate member. Aliases are skipped during iteration but still resolve on lookup:
from enum import Enum
class Status(Enum):
ACTIVE = 1
RUNNING = 1 # alias for ACTIVE
DONE = 2
print(Status.RUNNING is Status.ACTIVE) # True
print(list(Status)) # [Status.ACTIVE, Status.DONE]
print(list(Status.__members__.items()))
# [('ACTIVE', Status.ACTIVE), ('RUNNING', Status.ACTIVE), ('DONE', Status.DONE)]
Aliases are occasionally useful (supporting both British and American spellings, say), but they are more often an accident. When you want to guarantee every member has a unique value, decorate the class with @unique and Python raises ValueError at definition time if duplicates slip in:
from enum import Enum, unique
@unique
class Weekday(Enum):
MON = 1
TUE = 2
# WED = 1 # would raise ValueError: duplicate values found
Members can carry data and methods
This is where enums start to shine. Because an enum is a real class, members can hold structured data and the enum can define methods. Give each member a tuple, then unpack it in __init__:
from enum import Enum
class Planet(Enum):
MERCURY = (3.303e23, 2.4397e6)
EARTH = (5.976e24, 6.37814e6)
def __init__(self, mass, radius):
self.mass = mass # kilograms
self.radius = radius # meters
@property
def gravity(self):
G = 6.67300e-11
return G * self.mass / (self.radius * self.radius)
print(round(Planet.EARTH.gravity, 2)) # 9.8
Now Planet.EARTH is not just a label — it is a rich object with attributes and computed properties, all while remaining a singleton you can compare with is.
IntEnum: when you need to behave like a number
A plain Enum deliberately refuses to compare with integers — Color.RED == 1 is False. Sometimes, though, you genuinely want integer behavior, for example when values map to HTTP status codes or ordered priority levels. IntEnum members are integers, so they sort and do arithmetic:
from enum import IntEnum
class Priority(IntEnum):
LOW = 1
MEDIUM = 2
HIGH = 3
print(Priority.HIGH > Priority.LOW) # True
print(Priority.MEDIUM + 1) # 3
print(sorted([Priority.HIGH, Priority.LOW]))
# [Priority.LOW, Priority.HIGH]
The trade-off is that this looser typing lets an IntEnum compare equal to a raw 1, which is exactly the safety you gave up. Reach for it only when integer semantics are the point.
Flag: combinable options
Flag (and its integer cousin IntFlag) is designed for bitwise combinations — think file permissions or feature toggles. Members combine with | and you test membership with in:
from enum import Flag, auto
class Perm(Flag):
READ = auto()
WRITE = auto()
EXECUTE = auto()
combo = Perm.READ | Perm.WRITE
print(combo) # Perm.READ|WRITE
print(Perm.READ in combo) # True
print(Perm.EXECUTE in combo) # False
Because it is bit-based, you can also compute intersections with & and complements with ~. On Python 3.11+ you can even iterate a combined flag to get its individual members back with list(combo).
StrEnum: string members (Python 3.11+)
Mirroring IntEnum, Python 3.11 added StrEnum, whose members are real strings. This is perfect for values that flow into JSON, environment variables, or URLs, where you want the enum to serialize as plain text:
from enum import StrEnum
class Env(StrEnum):
DEV = "development"
PROD = "production"
print(Env.PROD == "production") # True
print(Env.PROD.upper()) # PRODUCTION
On Python 3.10 or earlier you can get the same effect by inheriting from both str and Enum: class Env(str, Enum): ....
The functional API
When member names are computed at runtime, you can build an enum in a single call — the same way namedtuple works:
from enum import Enum
Animal = Enum("Animal", ["DOG", "CAT", "BIRD"])
print(list(Animal)) # [Animal.DOG, Animal.CAT, Animal.BIRD]
print(Animal.CAT.value) # 2
You can pass names as a list (values auto-numbered from 1), a space-separated string, or a mapping for explicit values.
Graceful lookups with _missing_
By default, Color("purple") raises ValueError. You can customize how unknown values are resolved by defining a _missing_ classmethod — useful for case-insensitive parsing of external input:
from enum import Enum
class Role(Enum):
ADMIN = "admin"
USER = "user"
@classmethod
def _missing_(cls, value):
if isinstance(value, str):
for member in cls:
if member.value == value.lower():
return member
return None
print(Role("ADMIN")) # Role.ADMIN
Common pitfalls
Expecting plain Enum to equal a raw value. Color.RED == 1 is False by design. If you need that equality, use IntEnum or StrEnum — but understand you are trading away type safety.
Accidental aliases. Two members with the same value silently collapse into one. If each member must be distinct, add @unique so mistakes fail loudly instead of hiding.
Trying to add members after definition. Enums are effectively frozen — you cannot add, remove, or reassign members at runtime. Define the full set up front.
Mutable member data. Attaching a mutable object (like a list) as a member value undermines the singleton guarantee, because all references share the same mutable state. Prefer immutable data such as tuples.
Comparing across enum types. Members of two different enums are never equal, even if their values match. That is a feature: it stops you from mixing a Priority with a Color by mistake.
Wrap-up and next steps
Reaching for enum instead of bare strings and integers pays off almost immediately: your constants gain names, autocomplete, iteration, and a single source of truth, and invalid values fail early instead of propagating silently. Start with a plain Enum plus auto() for most cases; upgrade to IntEnum or StrEnum when you need numeric or string behavior, and use Flag for combinable options. From here, look at pairing enums with pattern matching (match/case) for clean dispatch, and at using them in type hints so functions declare exactly which set of values they accept.