skip to content

When should you use enum.IntEnum or enum.StrEnum instead of a plain enum.Enum?

level: middleimportance: must knowfreq 50%

answer

  1. A compatibility shim, not a default
  2. Members genuinely are int or str
  3. Equal to raw values, and across classes
  4. enum.StrEnum arrived in 3.11
  5. auto() yields the lower-cased name

basics

~20 s

Only when a member must still work as a raw int or string somewhere you cannot change - a wire format, a stored column, a C constant. Those members really are int and str subclasses: a compatibility shim, not a default.

solid answer

~40 s

`enum.IntEnum` mixes `int` into the enum and `enum.StrEnum` (new in **3.11**) mixes in `str`, so members are genuine instances of those types. That gives equality with raw values (`Priority.HIGH == 3`), hashing as the raw value, ordering and arithmetic for ints, every string method for `StrEnum`, and `auto()` yielding the lower-cased member name in a `StrEnum`. Use them when the raw value has to remain acceptable somewhere you cannot change - a protocol field, a stored column, a legacy comparison against literals. The cost is exactly the isolation a plain `Enum` gives you: members of two unrelated `IntEnum` classes with value 3 compare equal, a zero-valued member is falsy, `Priority.LOW + 1` silently yields a plain `int`, and free ordering invites comparisons nobody designed. Default to plain `Enum`; convert with `.value` at the edge.

code

python · 15 lines
python
from enum import IntEnum, StrEnum, auto

class Priority(IntEnum):
    LOW = 1
    NORMAL = 2
    HIGH = 3
    URGENT = 4

class Queue(StrEnum):
    TRIAGE = auto()
    BACKLOG = auto()

assert Priority.HIGH == 3 and isinstance(Priority.HIGH, int)
assert Queue.TRIAGE == "triage" and "-".join([Queue.TRIAGE, Queue.BACKLOG])
print(sorted(Priority, reverse=True)[0].name, {3: "paged"}[Priority.HIGH])

go deeper

for a junior

Know that an enum.IntEnum member can be used anywhere an int is expected and equals its number, while a plain enum.Enum member does not. Be able to say which kind a piece of code declared.

for a middle

Explain the mechanism - these are data-type mixins, so equality, hashing, ordering and arithmetic all come straight from int or str - and name the 3.11 arrival of enum.StrEnum and its lower-cased auto() values.

for a senior

Argue the tradeoff with a concrete failure: cross-enum equality, a falsy zero member, ordering that was never designed producing an off-by-one boundary check. Show the alternative of a plain Enum plus one conversion at the serialization edge.

for a principal

Own the codebase policy: which boundaries are allowed to demand a raw type, how those conversions are localised, and how a team avoids a slow drift where every enum becomes an IntEnum because one protocol needed a number.

### What the mixed-in kinds actually are `enum.IntEnum` is defined as an enum that also inherits `int`; `enum.StrEnum`, added in **Python 3.11**, is the same idea over `str`. A member is therefore a genuine instance of the data type: `isinstance(Priority.HIGH, int)` is `True`, `isinstance(Queue.TRIAGE, str)` is `True`. Everything that follows is a consequence of that one fact - none of it is special enum behaviour. * **Equality with raw values.** `Priority.HIGH == 3` and `Queue.TRIAGE == "triage"` are `True`, because `int.__eq__` and `str.__eq__` are doing the work. * **Hashing.** They hash as the underlying value, so `{3: "paged"}[Priority.HIGH]` and a set of raw strings answered by a `StrEnum` member both work. * **Ordering and arithmetic.** `Priority.HIGH > Priority.NORMAL` compares as ints, `sorted(Priority)` works, and `Priority.LOW + 1` evaluates - to a **plain `int` 2**, not to `Priority.NORMAL`. Arithmetic leaves the enum; nothing brings you back except calling the class. * **String operations.** A `StrEnum` member can be joined, formatted, sliced, used as a dict key, or handed to any API annotated `str`. * **`enum.auto()`.** In a plain `Enum` it counts 1, 2, 3...; in a `StrEnum` it produces the **lower-cased member name**, so `TRIAGE = auto()` has the value `"triage"`. `StrEnum` also rejects non-string values at class creation: assigning `1` raises `TypeError: 1 is not a string`. ### Why they exist: interop, not convenience The docs are blunt that these kinds are a compatibility device. You reach for them when the raw value must remain acceptable at a boundary you do not control - a wire protocol whose field is a number, a database column, a constant defined by a C library, an on-disk format, or an old code path that still compares against literals and cannot be changed in one go. In those places `IntEnum` lets you introduce named constants without a big-bang rewrite of every comparison. What you buy with it is the type isolation a plain `Enum` gave you for free. Two unrelated `IntEnum` classes that both define 3 have equal members: a priority can compare equal to an unrelated status code, and nothing complains. A member slips into arithmetic, into `range()`, into an index. And a zero-valued `IntEnum` member is falsy, so `if priority:` silently skips it. ### The boundary bug this invites Consider a ticket-triage bot whose escalation rule reads: ```python def pages_oncall(p: Priority) -> bool: return p > Priority.HIGH ``` Because `IntEnum` supports ordering, this compiles, reviews cleanly and is off by one: `HIGH` itself never pages, only `URGENT` does. With a plain `Enum` the expression would have raised `TypeError` immediately and the ordering would have had to be made explicit - a rank map, or an explicit `in {Priority.HIGH, Priority.URGENT}`. Ordering that comes free from the mixin is ordering nobody consciously designed, and it is exactly the kind of defect that survives a three-week release train because every test that exercises it uses `URGENT`. ### The older idiom, and how it differs now Before 3.11 the way to get a string enum was `class Status(str, Enum)`. On 3.14 both still work and have identical *equality* semantics, but they differ in **text**: `enum.StrEnum` derives from `enum.ReprEnum`, so `str(Queue.TRIAGE)` is `"triage"`, while the older mixin keeps Enum's `__str__` and `str(Status.OPEN)` is `"Status.OPEN"`. If you are migrating, that is the line to check. ### The rule to state Default to plain `enum.Enum`. It is the strict type, it forces conversions to be visible, and it costs you one `.value` at the edge. Choose `enum.IntEnum` or `enum.StrEnum` only when something outside your code genuinely requires the raw type, and treat that choice as a scoped compatibility decision with a note in the class docstring - not as the default way to declare constants, and never merely to make a serializer stop complaining.

  • On 3.14, how does `class Status(str, Enum)` differ from enum.StrEnum?
    Equality is identical - both are `str` subclasses equal to their values. The difference is text. `enum.StrEnum` derives from `enum.ReprEnum`, so `str()` and f-strings give the raw value, `"open"`; the hand-rolled mixin keeps Enum's `__str__` and gives `"Status.OPEN"`. `StrEnum` additionally enforces that every value is a string and makes `auto()` produce the lower-cased member name. If you migrate an old class to StrEnum, audit any log line or key built from `str(member)`.
  • What exactly does enum.IntEnum cost you compared with a plain enum.Enum?
    Type isolation. Members compare equal to bare ints and to same-valued members of *other* IntEnum classes, so a priority can silently match an unrelated status code. They enter arithmetic, indexing and `range()` without complaint, and arithmetic returns a plain int rather than a member. A zero-valued member is falsy, so `if priority:` skips it. Ordering comes free even when the enum has no meaningful order, which is how off-by-one boundary comparisons get written and reviewed without anyone noticing.
  • Does an enum.StrEnum accept a non-string member value?
    No. The class rejects it when the class body is executed, raising `TypeError: 1 is not a string`, so the mistake surfaces at import time rather than at the first comparison. That validation is one of the small wins of StrEnum over the older `(str, Enum)` mixin, which is less strict about what you assign.

A plain Enum is an internal badge that only your building's readers accept. IntEnum is a badge that also happens to be a valid national ID number - convenient at every external desk, and it means anyone quoting the right number gets treated as your member.

saying these in an interview costs you the question

  • Reaches for enum.IntEnum by default because it is easier to serialize
  • Thinks enum.StrEnum existed before Python 3.11
  • Believes IntEnum members from different classes never compare equal
  • Says enum.StrEnum accepts integer member values
  • Expects `Priority.LOW + 1` to return the next member
  • Assumes a plain enum.Enum sorts like an IntEnum

context