Enum в Python: почему строковые константы — плохая идея
Строки-статусы вроде «active» и «canceled» кажутся удобными, пока опечатка не проходит все проверки и не ломает логику в проде. Разбираем, чем Enum лучше и когда он мешает.

Опечатка в строке проходит интерпретатор, линтер и часто тесты — а падает в бою. Классика: где-то в коде status == "canceled", а база хранит "cancelled" с двумя «l». Сравнение молча возвращает False, заказ не отменяется, деньги не возвращаются, а трейсбека нет. Разберём, почему свободные строковые константы — источник таких багов, что даёт enum из стандартной библиотеки и в каких случаях он лишний. Материал для Python 3.11+, отдельно отмечу, что появилось в 3.11 и 3.12.
Задача: набор допустимых значений
Почти в любом сервисе есть поля с фиксированным множеством значений: статус заказа, роль пользователя, тип платежа, уровень логирования. Первое, что приходит в голову — разложить их по константам-строкам или просто писать литералы по месту.
# подход, который создаёт проблемы
ORDER_ACTIVE = "active"
ORDER_CANCELED = "canceled"
ORDER_SHIPPED = "shipped"
def can_refund(status: str) -> bool:
# опечатка здесь пройдёт молча
return status == "canceld"
Проблема не в том, что это некрасиво. Проблема в том, что тип str ничего не гарантирует. Функция принимает любую строку, а «любая строка» — это бесконечное множество, из которого валидны три значения. Всё остальное — потенциальный баг, который язык вам не подсветит.
Что конкретно ломается
- Опечатки не ловятся.
"canceld","Active","shiped"— валидныйstrдля интерпретатора. Ошибка проявится только на конкретной ветке логики. - Нет автодополнения. IDE не знает, какие строки допустимы, и не предложит их. Приходится помнить или лезть в код.
- Не видно всех значений. Чтобы узнать полный список статусов, надо грепать по проекту, а не открыть один класс.
- Рефактор опасен. Переименовать
"active"в"open"— это поиск по строке с риском задеть чужой литерал с тем же текстом.
Что даёт Enum
Модуль enum входит в стандартную библиотеку. Он превращает набор значений в замкнутый именованный тип: значений ровно столько, сколько вы объявили, и добраться до них можно только через сам класс.
from enum import Enum
class OrderStatus(Enum):
ACTIVE = "active"
CANCELED = "canceled"
SHIPPED = "shipped"
def can_refund(status: OrderStatus) -> bool:
# опечатка в имени члена — это AttributeError сразу
return status is OrderStatus.CANCELED
Теперь OrderStatus.CANCELD падает с AttributeError в момент выполнения этой строки, а не через три ветки условий. Аннотация status: OrderStatus заставляет mypy или pyright ругаться, если вы передадите туда голую строку. IDE подставит список членов после OrderStatus.. Полный перечень значений — в одном месте.
Сравнивать члены Enum лучше через is, а не ==: члены — синглтоны, и is яснее выражает намерение «это ровно тот самый член».
Строка говорит «здесь может быть что угодно». Enum говорит «здесь может быть только одно из этого списка». Разница в один класс, а багов меньше на целый класс.
StrEnum: когда значение должно оставаться строкой
Часто значение статуса надо отдать в JSON, записать в базу или сравнить со строкой из внешнего API. Обычный Enum для этого неудобен: OrderStatus.ACTIVE == "active" вернёт False, потому что это разные типы. Для таких случаев в Python 3.11 добавили StrEnum.
from enum import StrEnum # Python 3.11+
class OrderStatus(StrEnum):
ACTIVE = "active"
CANCELED = "canceled"
SHIPPED = "shipped"
# член ЯВЛЯЕТСЯ строкой
assert OrderStatus.ACTIVE == "active" # True
assert OrderStatus.ACTIVE.upper() == "ACTIVE"
import json
json.dumps({"status": OrderStatus.ACTIVE}) # '{"status": "active"}'
StrEnum наследуется и от str, и от Enum, поэтому член ведёт себя как строка везде, где нужна строка, но остаётся членом перечисления с проверкой типов. До 3.11 тот же эффект достигался объявлением class OrderStatus(str, Enum) — этот приём работает и сейчас, но у него есть отличия в поведении str(), о которых ниже.
IntEnum и битовые флаги
Для целочисленных значений есть IntEnum (член ведёт себя как int), а для наборов флагов, которые комбинируются побитово, — Flag и IntFlag. Комбинирование флагов через | — отдельная тема, здесь её не разворачиваю.
Чем именно платите за Enum
Enum не бесплатен, и для некоторых мест он избыточен. Сравним подходы.
| Подход | Проверка типов | Опечатка ловится | Сериализация в JSON/БД | Когда уместен |
|---|---|---|---|---|
Литералы "active" | нет | нет | сразу | одноразовый скрипт, значение встречается один раз |
Константы ORDER_ACTIVE = "active" | нет | частично | сразу | legacy-код, где Enum некуда встроить |
Enum | да | да | нужен .value | внутренняя логика, значение не уходит наружу как есть |
StrEnum (3.11+) | да | да | сразу, как строка | статусы в API, БД, конфигах |
Literal["active", ...] | да (статически) | да (линтером) | сразу | только аннотация, без объекта-контейнера |
Отдельно про typing.Literal: он даёт статическую проверку допустимых строк без создания класса и без рантайм-накладных расходов. Но у Literal нет объекта, который можно итерировать, у него нет методов и нельзя получить список всех значений в рантайме. Если вам нужен только контракт для линтера — Literal легче. Если нужен полноценный тип с методами и перебором — Enum.
Когда что выбирать
- Значение уходит в JSON, базу или внешний API —
StrEnum(3.11+) либоclass Foo(str, Enum)на более старых версиях. Не придётся вручную писать.valueпри каждой сериализации. - Значение живёт только внутри логики и наружу не отдаётся — обычный
Enum. Он строже: случайно сравнить с сырой строкой не выйдет, что как раз ловит ошибки. - Нужна только аннотация функции, объект-перечисление не нужен —
Literal. Минимум кода, вся проверка на этапе линтинга. - Разовый скрипт на 30 строк — литерал по месту. Enum здесь оверинжиниринг.
Что ломается на практике
Enum решает одни проблемы и создаёт другие, если не знать нюансов.
Enum.ACTIVE != "active". У обычногоEnumчлен не равен своему значению. Если код где-то сравнивает статус со строкой, миграция на обычныйEnumтихо сломает сравнения. Либо беритеStrEnum, либо явно пишите.value.- Разное поведение
str(). Дляclass Foo(str, Enum)в Python 3.11+ поведениеstr()и форматирования менялось и стало ближе к обычномуEnum(то естьstr(Foo.A)даёт"Foo.A", а не"active"). ДляStrEnumstr()возвращает само значение. Если вы полагались наf"{status}"при переходе между версиями — перепроверьте вывод. Точное поведение сверяйте с документацией вашей версии. - Значение не из перечисления. Чтобы получить член по значению, используют
OrderStatus("active"). Если строки нет среди значений — будетValueError. Это удобно для валидации входных данных, но требует обработки исключения. - Изменяемые значения. Нельзя присвоить одному члену два раза одно и то же значение без
@enum.unique— по умолчанию второй становится алиасом первого, что иногда неожиданно.
Что делать дальше
Начните с одного поля, где статусы разбросаны литералами по коду, и соберите их в StrEnum. Прогоните mypy или pyright в строгом режиме — он покажет места, где в функцию с аннотацией OrderStatus прилетает голая строка: это и есть точки, где раньше пряталась опечатка. Для входных данных из API добавьте валидацию через OrderStatus(value) с обработкой ValueError. Официальный раздел по перечислениям в документации Python описывает Flag, auto() и функциональный синтаксис создания — это следующий шаг, когда базовый переход уже сделан.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: enum — Support for enumerations (документация Python), typing.Literal (документация Python)
Частые вопросы
А если у меня Python 3.10 или младше, StrEnum же нет?
Enum медленнее обычных строк? Стоит ли беспокоиться о производительности?
Не сработало: сравнение OrderStatus.ACTIVE == "active" вернуло False. Почему?
Чем Enum лучше typing.Literal, если оба ловят опечатки?
Как получить член Enum по строке, пришедшей из запроса?
Стоит ли переписывать весь legacy-код на Enum разом?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.