Python Enum Types — Deep Dive
How Enum works under the hood
Python’s Enum uses a custom metaclass (EnumMeta, renamed EnumType in 3.11). When you define a class body, the metaclass intercepts attribute assignments and wraps each value in a member object. The class itself becomes a container that’s iterable but not instantiable in the normal sense.
from enum import Enum
class Status(Enum):
ACTIVE = 1
INACTIVE = 2
type(Status) # <class 'enum.EnumMeta'>
type(Status.ACTIVE) # <enum 'Status'>
isinstance(Status.ACTIVE, Status) # True
The singleton guarantee
When you call Status(1), the metaclass looks up the existing member by value and returns it. No new object is created. This is enforced by EnumMeta.__new__, which stores a _value2member_map_ dictionary internally.
Status(1) is Status.ACTIVE # True — same object
This guarantee means you can safely use is for comparison, and Enum members are hashable and usable as dictionary keys.
Aliases vs unique values
If two members share the same value, the second becomes an alias:
class Shape(Enum):
SQUARE = 2
DIAMOND = 1
CIRCLE = 3
ALIAS_FOR_SQUARE = 2 # this is an alias
Shape.ALIAS_FOR_SQUARE is Shape.SQUARE # True
list(Shape) # [SQUARE, DIAMOND, CIRCLE] — aliases excluded from iteration
To forbid aliases, use the @unique decorator:
from enum import Enum, unique
@unique
class StrictShape(Enum):
SQUARE = 2
ALIAS_FOR_SQUARE = 2 # raises ValueError at class creation
Adding methods and properties
Enum members are objects and can have methods:
from enum import Enum
class Planet(Enum):
MERCURY = (3.303e+23, 2.4397e6)
VENUS = (4.869e+24, 6.0518e6)
EARTH = (5.976e+24, 6.37814e6)
def __init__(self, mass, radius):
self.mass = mass
self.radius = radius
@property
def surface_gravity(self):
G = 6.67430e-11
return G * self.mass / (self.radius ** 2)
Planet.EARTH.surface_gravity # ≈ 9.8 m/s²
The value is the tuple, and __init__ unpacks it into attributes. This pattern is useful for configuration enums where each member carries associated data.
Custom __new__ for value control
Sometimes you want the value to be different from the data stored:
from enum import Enum
class HTTPStatus(Enum):
def __new__(cls, code, phrase):
obj = object.__new__(cls)
obj._value_ = code
obj.phrase = phrase
return obj
OK = (200, "OK")
NOT_FOUND = (404, "Not Found")
SERVER_ERROR = (500, "Internal Server Error")
HTTPStatus(200) # HTTPStatus.OK
HTTPStatus.OK.phrase # "OK"
_value_ determines what HTTPStatus(200) resolves to. The phrase is extra data. This is how the standard library’s http.HTTPStatus is actually implemented.
Serialization strategies
JSON serialization
Enums aren’t JSON-serializable by default. Common approaches:
import json
from enum import Enum
class Role(Enum):
ADMIN = "admin"
USER = "user"
# Approach 1: Custom encoder
class EnumEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Enum):
return obj.value
return super().default(obj)
json.dumps({"role": Role.ADMIN}, cls=EnumEncoder)
# '{"role": "admin"}'
# Approach 2: StrEnum (3.11+) — no encoder needed
from enum import StrEnum
class Role(StrEnum):
ADMIN = "admin"
USER = "user"
json.dumps({"role": Role.ADMIN}) # works directly
Database serialization
With SQLAlchemy 2.0+, you can map Enums directly:
import sqlalchemy as sa
from enum import StrEnum
class OrderStatus(StrEnum):
PENDING = "pending"
SHIPPED = "shipped"
class Order(Base):
__tablename__ = "orders"
id = sa.Column(sa.Integer, primary_key=True)
status = sa.Column(sa.Enum(OrderStatus), default=OrderStatus.PENDING)
SQLAlchemy stores the string value and reconstructs the Enum on read. For PostgreSQL, this creates a native ENUM type; for SQLite, it uses a VARCHAR with a CHECK constraint.
Pydantic integration
Pydantic v2 handles Enums natively:
from pydantic import BaseModel
from enum import StrEnum
class Priority(StrEnum):
LOW = "low"
HIGH = "high"
class Task(BaseModel):
name: str
priority: Priority
task = Task(name="deploy", priority="high")
task.priority # Priority.HIGH
task.model_dump() # {"name": "deploy", "priority": "high"}
Flag internals and composition
Flag values must be powers of 2 (or 0 for a “no flags” member). auto() handles this automatically. Composite flags are instances of the same Flag class:
from enum import Flag, auto
class Perm(Flag):
R = auto() # 1
W = auto() # 2
X = auto() # 4
RWX = R | W | X # 7
combined = Perm.R | Perm.W
type(combined) # <enum 'Perm'>
combined.value # 3
# Decomposition
list(combined) # [Perm.R, Perm.W]
# Checking membership
Perm.R in combined # True
Perm.X in combined # False
Boundary handling in Python 3.11+
Python 3.11 added CONFORM, EJECT, KEEP, and STRICT boundary modes that control what happens when you create a Flag member with an invalid value (e.g., Perm(5) when 5 isn’t a named combination). The default changed from EJECT (return an int) to KEEP (return a pseudo-member), which can affect code that relied on the old behavior.
Functional API
You can create Enums without a class statement:
from enum import Enum
Animal = Enum("Animal", ["ANT", "BEE", "CAT"])
Animal.ANT.value # 1
This is useful for dynamic Enum creation, such as building Enums from database rows or configuration files. The functional API also accepts a string: Enum("Animal", "ANT BEE CAT").
Pattern matching (Python 3.10+)
Enums work naturally with match/case:
from enum import Enum
class Command(Enum):
START = "start"
STOP = "stop"
RESTART = "restart"
def handle(cmd: Command):
match cmd:
case Command.START:
boot_system()
case Command.STOP:
shutdown_system()
case Command.RESTART:
shutdown_system()
boot_system()
The exhaustiveness check isn’t enforced at runtime, but type checkers like mypy and pyright can warn about missing cases if you omit the wildcard case _.
Performance considerations
Enum member access (Status.ACTIVE) is a class attribute lookup — fast and cached. However, Status(1) does a dictionary lookup in _value2member_map_, which is O(1) but involves function call overhead. In hot loops processing millions of rows, accessing the value directly may be measurably faster than constructing Enum members from raw values.
For Flag, bitwise operations are handled by Python-level __or__, __and__, etc. In extremely hot paths (packet parsing, pixel manipulation), the overhead of Flag operations vs raw int bitwise ops can be significant. Profile before optimizing.
Migration patterns
From magic strings to StrEnum
# Before: scattered string literals
if order.status == "pending":
...
# After: centralized StrEnum
class OrderStatus(StrEnum):
PENDING = "pending"
SHIPPED = "shipped"
if order.status == OrderStatus.PENDING:
...
Because StrEnum compares equal to its string value, this migration is backward-compatible. Existing database rows, JSON payloads, and API responses continue to work.
From integer constants to IntEnum
# Before
ROLE_ADMIN = 1
ROLE_USER = 2
# After
class Role(IntEnum):
ADMIN = 1
USER = 2
Again, backward-compatible since Role.ADMIN == 1 is True. Once the migration is stable, consider upgrading to strict Enum to catch comparison bugs.
Pitfalls
-
Forgetting that
Enum.MEMBER == raw_valueisFalse. This is the most common source of confusion. UseIntEnumorStrEnumif you need raw comparison. -
Mutable attributes on members. Enum members are singletons — if you add mutable state, all references share it. Keep members immutable.
-
Subclassing Enums with members. You cannot subclass an Enum that already has members. This is enforced to prevent the diamond problem with values.
-
Pickle compatibility. Enums are picklable by name, not by value. Renaming a member breaks unpickling of previously pickled data.
-
__members__vs iteration.list(MyEnum)excludes aliases.MyEnum.__members__includes them. Use the right one for your use case.
The one thing to remember: Python’s Enum is a full-featured type system tool, not just a naming convention — master __new__, Flag composition, and serialization patterns, and you’ll eliminate an entire category of stringly-typed bugs from production code.
See Also
- Python Atexit How Python's atexit module lets your program clean up after itself right before it shuts down.
- Python Bisect Sorted Lists How Python's bisect module finds things in sorted lists the way you'd find a word in a dictionary — by jumping to the middle.
- Python Contextlib How Python's contextlib module makes the 'with' statement work for anything, not just files.
- Python Copy Module Why copying data in Python isn't as simple as it sounds, and how the copy module prevents sneaky bugs.
- Python Dataclass Field Metadata How Python dataclass fields can carry hidden notes — like sticky notes on a filing cabinet that tools read automatically.