Python: Enums, Defining Constants the Pythonic Way

Most languages bake enums into their syntax; Python tucked them into a standard library module back in 2013, and plenty of codebases still have not noticed. That is a shame, because a Python enum replaces stray magic strings with one fixed set of named constants your editor can autocomplete. This post covers Enum, IntEnum, StrEnum, Flag, auto(), the @unique decorator, and enums inside match/case, all tested.

“Magic numbers in code are bugs waiting to happen.”

Martin Fowler, Refactoring

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 16 minutes

Here is a bug I have watched ship more than once. The code has status = "active" sprinkled across fourteen files. One developer on the team, Rahul, types "Active" with a capital A. His teammate Anvi writes "ACTIVE". A week later a third developer, Viraj, invents "enabled" because that read better to him. Now the database holds four different strings that all mean the same thing, and the if checks only catch two of them. Nobody gets an error. The orders just quietly stop showing up. A Python enum kills this whole family of bugs before it starts.

An enum (short for enumeration) is a fixed set of named constants. Status.ACTIVE is always Status.ACTIVE. You cannot misspell it, your editor autocompletes it, and it shows up in type hints so tools can check it for you. The enum module ships several flavors: plain Enum, IntEnum (members that also behave like integers), StrEnum (members that behave like strings, added in 3.11), and Flag (members you can combine with bitwise OR).

Think of an enum like the gear stick in a car. It clicks into Park, Reverse, Neutral, or Drive, and that is the entire list. There is no slot halfway between Reverse and Drive, and you cannot invent a fifth gear by pushing harder. An enum gives your code that same comfort: these are the only valid values, and anything else is a mistake the moment you type it.

Enum GuaranteesUnique values@unique decoratorImmutableCan’t reassignIterablefor m in ColorHashableUse as dict keysPython EnumNamed constants withguaranteed uniquenessBasic Enumclass Color(Enum):RED = 1GREEN = 2IntEnumclass Priority(IntEnum):LOW = 1HIGH = 3Comparable with intStrEnum (3.11+)class Status(StrEnum):ACTIVE = ‘active’Compatible with strFlagclass Permission(Flag):READ = 1WRITE = 2Combinable with |auto()class Direction(Enum):NORTH = auto()SOUTH = auto()Auto-incrementingPython Enum: Enum, IntEnum, StrEnum, Flag, and auto() at a Glance

The diagram lays out the enum family at a glance. Enum is the generic base. IntEnum gives you members that compare as integers. StrEnum gives you members that compare as strings. Flag lets you combine options with bitwise OR. auto() fills in the values so you do not have to. Every flavor shares the four guarantees in the left column: unique values, you cannot reassign a member, you can loop over the members, and you can use members as dictionary keys. Pick the base class that matches the job and Python saves you a pile of conversion code.

Basic Enum

A basic enum works like a cricket team sheet: every player has a name and a jersey number, and you can find a player by either one. Start with the smallest useful version. You subclass Enum and list your members, one per line, each with a value. That is the whole pattern. Here it is doing every common job you will ask of it: print a member, read its name and value, look one up by value or by name, compare two members, and loop over the lot.

📄 basic_enum.py: define and use a simple enum

from enum import Enum

class Color(Enum):
    RED = 1
    GREEN = 2
    BLUE = 3

# Access by name or value
print(Color.RED)          # Color.RED
print(Color.RED.name)     # RED
print(Color.RED.value)    # 1
print(Color(2))           # Color.GREEN
print(Color["BLUE"])      # Color.BLUE

# Comparison
print(Color.RED == Color.RED)     # True
print(Color.RED == Color.GREEN)   # False
print(Color.RED == 1)             # False! Enum != int

# Iteration
for color in Color:
    print(f"{color.name}: {color.value}")

▶ Output

Color.RED
RED
1
Color.GREEN
Color.BLUE
True
False
False
RED: 1
GREEN: 2
BLUE: 3

What happened here: Each member carries two things, a name (the text on the left of the equals sign) and a value (the thing on the right). Color(2) looks a member up by its value, and Color["BLUE"] looks one up by its name, so you can go either direction. The line that surprises people is Color.RED == 1 printing False. A plain Enum member is not its value. Color.RED happens to wrap the number 1, but it is its own object, not the integer 1. If you actually want a member that doubles as an integer, that is exactly what IntEnum is for, and it is next.

IntEnum: Numbers That Keep Their Names

Sometimes you want the readable name and the raw number to work side by side. It is like hotel star ratings: “five star” is a label people say out loud, but it is also literally the number 5, so you can rank hotels by it. Think of a task priority. You want to write Priority.HIGH in your code, but you also want to sort a list of tasks, and sorting needs real numbers to compare. IntEnum gives you both. Its members are genuine integers, so they compare, sort, and do math like any other int, while still printing a friendly name.

📄 int_enum.py: members that are real integers

from enum import IntEnum

class Priority(IntEnum):
    LOW = 1
    MEDIUM = 2
    HIGH = 3

# IntEnum members ARE integers
print(Priority.HIGH == 3)            # True (Enum would say False)
print(Priority.LOW < Priority.HIGH)  # True
print(Priority.HIGH + 1)             # 4

# Because they sort like ints, sorting tasks just works
tasks = [Priority.HIGH, Priority.LOW, Priority.MEDIUM]
print(sorted(tasks))

▶ Output

True
True
4
[<Priority.LOW: 1>, <Priority.MEDIUM: 2>, <Priority.HIGH: 3>]

What happened here: Notice the very first line. With a plain Enum this would print False, but Priority.HIGH == 3 is True because an IntEnum member really is the integer 3 underneath. That is why sorted() can line the tasks up from LOW to HIGH without any extra key function. One thing to watch: sorted() sorts by the integer value, but when Python prints the members it shows their repr, which is the <Priority.LOW: 1> form, name and value together. The order is what you asked for, the display just spells out both halves.

StrEnum: Enums That Act Like Strings (3.11+)

Now the string version, added in Python 3.11. The same idea as IntEnum, but the member behaves like a str instead of an int. This is the one you reach for constantly with web APIs (Application Programming Interfaces) and databases, where a status arrives as plain text like "active". Think of the status stamp on a courier parcel: the word DELIVERED printed on the slip is the exact text everyone reads and writes, one fixed spelling, no translation step.

📄 str_enum.py: enums that work like strings

from enum import StrEnum, auto

class Status(StrEnum):
    ACTIVE = auto()      # "active"
    INACTIVE = auto()    # "inactive"
    SUSPENDED = auto()   # "suspended"

# Works directly in string operations
user_status = Status.ACTIVE
print(f"User is {user_status}")      # User is active
print(user_status == "active")       # True, StrEnum compares with str!

# Great for API responses and database values
data = {"status": Status.ACTIVE}
import json
print(json.dumps(data))              # {"status": "active"}

▶ Output

User is active
True
{"status": "active"}

What happened here: Two things make StrEnum pleasant. First, auto() here does not give 1, 2, 3 like it would for a plain Enum. For a StrEnum it hands each member the lowercased version of its own name, so ACTIVE becomes "active" for free. Second, because the member is a string, Status.ACTIVE == "active" is True, and json.dumps serializes it straight to "active" with no custom encoder. That is the whole reason this type exists. You compare against the strings coming off the wire without writing a single conversion line, and the bug from the intro (four spellings of the same status) simply cannot happen, because there is exactly one spelling and it lives in one place.

Flag: Combinable Permissions

Some values are not either-or. File permissions are the classic case: a user can have read, or read and write, or all of them at once. A Flag enum is built for exactly this. Think of it like the toppings on a pizza order. You are not picking one topping, you are ticking a set of boxes, and you want a tidy way to carry that whole set around as a single value.

📄 flag_enum.py: bitwise-combinable flags

from enum import Flag, auto

class Permission(Flag):
    READ = auto()      # 1
    WRITE = auto()     # 2
    EXECUTE = auto()   # 4
    DELETE = auto()    # 8

    # Convenience combinations
    RW = READ | WRITE
    ADMIN = READ | WRITE | EXECUTE | DELETE

# Combine with |
user_perms = Permission.READ | Permission.WRITE
print(user_perms)                        # Permission.RW
print(Permission.READ in user_perms)     # True
print(Permission.DELETE in user_perms)   # False

admin = Permission.ADMIN
print(admin)                             # Permission.ADMIN

# A combination with no named shortcut prints both flags
print(Permission.READ | Permission.EXECUTE)   # Permission.READ|EXECUTE

▶ Output

Permission.RW
True
False
Permission.ADMIN
Permission.READ|EXECUTE

What happened here: The | operator stacks flags together. READ | WRITE equals the value 3, and because you named that exact combination RW, Python prints the friendly Permission.RW. The ADMIN member you defined is its own named combination too, so print(admin) shows Permission.ADMIN rather than spelling out all four flags. When a combination has no name, like READ | EXECUTE, Python falls back to listing the flags it contains, joined with a pipe: Permission.READ|EXECUTE. The in operator is the clean way to ask “is this flag switched on?” without any bit-masking by hand.

auto() and @unique

Two small helpers come up in almost every real enum. auto() works like the token machine at a bakery counter: it hands each new member the next number so nobody keeps count by hand. And @unique is the strict clerk who refuses to give two customers the same token. In other words, auto() stops you from numbering members yourself, and @unique guards against an easy-to-miss accident: two members sharing the same value. You have already seen auto() at work in the StrEnum and Flag examples. Here is what it does for each base type, plus the trap that @unique exists to catch.

📄 auto_unique.py: auto-numbering and the duplicate-value trap

from enum import Enum, unique

# auto() fills in values so you don't repeat yourself.
# Plain Enum: 1, 2, 3...   StrEnum: lowercased name   Flag: 1, 2, 4, 8...
class Weekday(Enum):
    MON = 1
    TUE = 2
    WED = 3

# Without @unique, a repeated value becomes an ALIAS, not a new member
class Bad(Enum):
    ACTIVE = 1
    RUNNING = 1   # silently becomes another name for ACTIVE

print(Bad.RUNNING is Bad.ACTIVE)   # True, same member!
print(list(Bad))                   # only ACTIVE shows up

# @unique turns that silent alias into a loud error at class-creation time
@unique
class Status(Enum):
    ACTIVE = 1
    INACTIVE = 2
    SUSPENDED = 3

print([s.name for s in Status])

▶ Output

True
[<Bad.ACTIVE: 1>]
['ACTIVE', 'INACTIVE', 'SUSPENDED']

What happened here: The Bad class shows the quiet bug. Because RUNNING reuses the value 1, Python does not create a second member. It makes RUNNING an alias, just another label pointing at ACTIVE, which is why Bad.RUNNING is Bad.ACTIVE is True and looping over Bad only yields ACTIVE. Most of the time that is not what you meant. Stick @unique on top and Python refuses to build the class at all if two values collide, so you find out the moment you write the code instead of three bugs downstream.

If you genuinely want auto-numbering, swap the literal values for auto(): plain Enum gives 1, 2, 3, a StrEnum gives the lowercased names, and a Flag gives 1, 2, 4, 8.

Enums With Methods and match/case

An enum is a class, so it can carry methods like any other Python class. That is genuinely useful: the behavior tied to a value lives right next to the value. Pair that with match/case (added in Python 3.10) and you get clean, readable branching with no string typos in sight, like a hotel receptionist routing guests: every known request goes to exactly one desk, no guesswork.

📄 patterns.py: enums with methods and match/case

from enum import Enum

class HttpStatus(Enum):
    OK = 200
    NOT_FOUND = 404
    SERVER_ERROR = 500

    def is_success(self) -> bool:
        return 200 <= self.value < 300

    def is_error(self) -> bool:
        return self.value >= 400

# Using with match/case (3.10+)
def handle_response(status: HttpStatus) -> str:
    match status:
        case HttpStatus.OK:
            return "Success!"
        case HttpStatus.NOT_FOUND:
            return "Resource not found"
        case HttpStatus.SERVER_ERROR:
            return "Server error, try again later"

print(handle_response(HttpStatus.NOT_FOUND))
print(HttpStatus.OK.is_success())

▶ Output

Resource not found
True

What happened here: is_success() and is_error() are normal methods, but they live on the enum, so a status object knows how to describe itself. The match statement compares status against each member with an equality check, and for a plain Enum that equality check is a simple identity check, so it is both fast and typo-proof. There is no chance of writing "NOT_FOND" and getting a silent miss, because HttpStatus.NOT_FOUND is a name your editor checks. One practical tip for production code: add a case _: at the end as a catch-all so an unexpected status does not fall through and return None by accident.

Common Mistakes

Mistake 1: Using bare strings for a fixed set of values

❌ Wrong

# Stringly-typed: a typo causes a silent bug, never an error
if user.role == "admni":   # typo! this check just never matches
    grant_access()

✅ Correct

# Enum: your editor autocompletes it and mypy type-checks it
if user.role == Role.ADMIN:   # typo here is an error you see immediately
    grant_access()

Why: a misspelled string is still a perfectly valid string, so Python runs the comparison, gets False, and moves on without a peep. The access check silently fails open or closed depending on your logic, and you find out from a support ticket. Role.ADMIN is a name, so a typo like Role.ADMN raises AttributeError the instant it runs, and your editor flags it before you even save. (The snippets above are illustrative fragments. They assume a user object and a Role enum from the surrounding app.)

Mistake 2: Comparing a plain Enum to its raw value

❌ Wrong

from enum import Enum

class Color(Enum):
    RED = 1

# A plain Enum member is NOT its value, so this is always False
if Color.RED == 1:        # never True
    print("red")

✅ Correct

from enum import IntEnum

class Color(IntEnum):
    RED = 1

# IntEnum members ARE integers, so value comparison works
if Color.RED == 1:        # True
    print("red")

Why: if you need a member to compare equal to its raw number, you must opt in by subclassing IntEnum (or StrEnum for text). With a plain Enum, compare member to member (color == Color.RED) or read the value explicitly (color.value == 1). Mixing the two quietly returns False and sends you hunting for a bug that is not where you are looking.

Where You See This in Real Code

Enums are not a textbook curiosity. They are all over the libraries you already use:

  • The standard library’s http.HTTPStatus is an IntEnum, so HTTPStatus.NOT_FOUND both reads clearly and equals 404.
  • socket uses enums for address families and types, so you write socket.AF_INET instead of memorizing a number.
  • Regex flags such as re.IGNORECASE are a Flag enum, which is why you can combine them with re.IGNORECASE | re.MULTILINE.
  • Pydantic and SQLAlchemy both accept Python enums directly as field types, so the same StrEnum can validate an API request and map to a database column.

One honest word of caution so you do not over-apply this. An enum earns its place when the set of values is fixed and known ahead of time: order status, user role, log level, card suit. If the set is open-ended (every country in the world, every product in a catalog), an enum becomes a maintenance chore, and plain data in a database or config file fits better. Reach for an enum when you can name every value on one screen.

Practice Exercises

  1. Exercise 1: Define an OrderStatus enum (PENDING, SHIPPED, DELIVERED, CANCELLED). Loop over it and print each member’s name and value, then look one member up by name and another by value.
  2. Exercise 2: Build a LogLevel(IntEnum) (DEBUG, INFO, WARNING, ERROR) and a function should_log(message_level, threshold) that returns True only when the message is at or above the threshold. Lean on the fact that IntEnum members compare like numbers.
  3. Exercise 3: Create a Permission(Flag) enum and write describe(perms) that returns a readable list like ["READ", "WRITE"] for whatever flags are switched on. Test it with a combined value such as READ | WRITE.

Conclusion

You now have the full Python enum toolkit: plain Enum for named constants, IntEnum when the number matters, StrEnum for text coming from APIs and databases, Flag for combinable options, plus auto() and @unique to keep the definitions honest. The payoff is simple: one spelling per value, checked by your editor and your type checker, so the “four spellings of active” bug from the intro can never ship again. Up next we go deeper into pattern matching with match/case, which pairs beautifully with the enums you just learned. And if you want every post in order, from beginner basics to AI/ML, head to the Python + AI/ML tutorial series home.

Frequently Asked Questions

What is a Python Enum?

A Python enum is a class with a fixed set of named constant members. Each member has a name and a value. Enums prevent magic strings/numbers, provide IDE autocomplete, and work with type checkers.

What is the difference between Enum and IntEnum?

Enum members don’t compare equal to their values (Color.RED != 1). IntEnum members ARE integers and compare with int (Priority.HIGH == 3 is True). Use IntEnum when you need integer compatibility.

What is StrEnum in Python?

StrEnum (Python 3.11+) makes enum members behave like strings. Status.ACTIVE == 'active' is True. With auto(), values are the lowercased member names. Ideal for API status codes and database values.

What does auto() do in Python enums?

auto() automatically assigns values. For basic Enum, it gives 1, 2, 3… For StrEnum, it gives the lowercased member name. For Flag, it gives powers of 2 (1, 2, 4, 8…).

Can I add methods to a Python Enum?

Yes. Enums are classes, so you can add methods, properties, and classmethods. This is useful for behavior tied to specific values, like HttpStatus.is_error().

Interview Questions on Python Enums

Try each one aloud first. The phrasing you produce under mild pressure is what interviews measure.

Q: Your API serializes a response with json.dumps and it crashes with “TypeError: Object of type OrderStatus is not JSON serializable”. What is going on and how do you fix it?

OrderStatus is a plain Enum, and a plain enum member is its own object, not a str or int, so the JSON encoder does not know what to do with it. The cleanest fix is to base the enum on StrEnum (or IntEnum for numeric codes) so members really are strings and serialize directly. If you cannot change the enum, pass default=lambda o: o.value to json.dumps, or convert with .value at the serialization boundary.

Q: A teammate adds CANCELLED = 3 to a Status enum that already has SUSPENDED = 3. Nothing errors, but iterating the enum never yields CANCELLED and logs show SUSPENDED where you expect CANCELLED. What happened and how do you prevent it?

Because the value 3 was already taken, Python did not create a new member. It made Status.CANCELLED an alias, so Status.CANCELLED is Status.SUSPENDED is True, and iteration only yields canonical members, never aliases. That also explains the logs: printing the alias shows the canonical name. Decorate the class with @unique so any duplicate value raises ValueError at class-creation time instead of silently aliasing.

Q: Why is it safe to compare plain enum members with “is” instead of “==”?

Enum members are singletons: each member is created exactly once when the class is built, and every reference to Color.RED points to that same object. For a plain Enum, equality effectively falls back to identity anyway, so is and == agree. Using is makes the intent explicit and can never accidentally match a raw value, which matters once IntEnum or StrEnum members enter the picture, since those also compare equal to plain ints and strings.

Q: When would you pick IntFlag over Flag?

Both support combining members with bitwise operators, but IntFlag members are also real integers, so they interoperate with raw ints, for example flags destined for a C library or an OS call that expects a plain bitmask. Flag is stricter: combining a member with a plain int raises TypeError. Prefer Flag when your code controls both sides, because the strictness catches mistakes, and reach for IntFlag only when you must exchange raw integer masks with external code.

Q: The set of valid statuses for your app is loaded from a config file at startup. Can you still build an enum from it?

Yes, with the functional API: Status = Enum("Status", ["ACTIVE", "INACTIVE"]), or pass (name, value) pairs when you need specific values. The trade-off is that static tools cannot see members created at runtime, so you lose autocomplete and type-checker coverage for them. Use it only when the member list genuinely is not known until runtime; otherwise define the class normally.

Q: Can you subclass an existing enum to add more members?

No. Python raises TypeError if you subclass an enum that already defines members, because extending it would break the promise that iterating the parent class lists every possible value. The supported pattern is the other way around: put shared methods on a member-less base class that subclasses Enum, then have each concrete enum inherit from that base and define its own members.

Reference: the complete, always-current details live in the official Python documentation.

Previous: Python: Dataclasses vs Pydantic vs attrs (Which Data Class Library?)

Next: Python: Pattern Matching with match/case (3.10+)

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *