← назад к разделу

Статус заказа, тип уведомления, роль пользователя, валюта — это всё перечислимые значения: поле принимает строго один из заранее известного набора вариантов. В PostgreSQL есть три способа это выразить, и у каждого своя область применения.

тип: CREATE TYPE order_status AS ENUM (…) NEW PAID SHIPPED DELIVERED CANCELLED REFUNDED ALTER TYPE … ADD VALUE 'REFUNDED' — встаёт в конец набора убрать значение нечем: тип пересоздают вместе с колонкой справочник: таблица order_status_dict NEW PAID SHIPPED DELIVERED CANCELLED REFUNDED INSERT INTO order_status_dict … — обычная строка DELETE … WHERE code = 'SHIPPED' — значения больше нет

Добавить значение в ENUM — одна быстрая команда, новое значение встаёт в конец набора. Убрать — нечем: тип пересоздают вместе с колонкой. В справочнике тот же набор живёт обычными строками: INSERT и DELETE.

Обязательно

Три способа хранить перечисление

Возьмём конкретный пример: статус заказа — NEW, PAID, SHIPPED, DELIVERED, CANCELLED.

Вариант 1: тип ENUM

CREATE TYPE order_status AS ENUM ('NEW', 'PAID', 'SHIPPED', 'DELIVERED', 'CANCELLED');

CREATE TABLE order_doc (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    status order_status NOT NULL DEFAULT 'NEW'
);

В колонке лежат 4 байта — ссылка на запись системного каталога, где хранится само имя значения. Это не порядковый номер: за порядком сортировки следит отдельное поле каталога, и вставка значения в середину набора чужих ссылок не трогает. В запросах вы всё равно работаете со строками, а сортируются значения в порядке объявления, а не по алфавиту: ORDER BY status начнёт с NEW, а не с CANCELLED.

Это удобно, когда:

  • набор значений известен заранее и меняется редко;
  • нет нужды хранить рядом метаданные (описание, порядок сортировки и т.д.);
  • важна компактность хранения.

Главная ловушка: удалить значение из ENUM нативно невозможно. Добавить новое — одна команда, убрать старое — нечем: придётся пересоздавать тип целиком вместе со всеми зависящими от него таблицами. Это сложная миграция.

Вариант 2: справочная таблица

CREATE TABLE order_status_dict (
    code        varchar(20) PRIMARY KEY,
    description text NOT NULL,
    sort_order  smallint NOT NULL,
    is_terminal boolean NOT NULL
);

INSERT INTO order_status_dict VALUES
    ('NEW',       'Создан',     10, false),
    ('PAID',      'Оплачен',    20, false),
    ('SHIPPED',   'Отправлен',  30, false),
    ('DELIVERED', 'Доставлен',  40, true),
    ('CANCELLED', 'Отменён',    50, true);

CREATE TABLE order_doc (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    status varchar(20) NOT NULL DEFAULT 'NEW' REFERENCES order_status_dict(code)
);

Каждый статус — строка в отдельной таблице: добавить, переименовать или удалить значение можно обычными SQL-командами, как любые данные.

Дополнительный плюс: рядом с кодом можно хранить атрибуты — в примере описание, порядок сортировки и флаг «терминальный статус» (в нём заказ уже не изменится).

Из минусов: в колонке лежит сама строка — 'DELIVERED' это девять символов плюс байт длины, 10 байт против 4 у ENUM, а при выборке данных нужен JOIN к справочнику, если хочется показать описание.

Вариант 3: text + CHECK

CREATE TABLE order_doc (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    status text NOT NULL DEFAULT 'NEW'
        CHECK (status IN ('NEW', 'PAID', 'SHIPPED', 'DELIVERED', 'CANCELLED'))
);

Самый простой вариант: никаких типов, никаких справочников. Ограничение прямо в определении таблицы.

Подходит для небольшого стабильного набора (до 5–7 значений), когда справочник кажется избыточным. На десяти значениях список в DDL уже трудно читать, и внешнего ключа здесь нет — другая таблица не может сослаться на этот набор.

Как выбрать

Начинать стоит с колонки, которая уже есть: набор значений в реальных данных почти всегда шире, чем в голове. В песочнице такая колонка — статус в таблице orders; это тот же заказ, что и order_doc из примеров выше, только с данными, на которых запрос можно запустить.

живой пример

SELECT status, count(*) AS cnt
FROM orders
GROUP BY status
ORDER BY cnt DESC;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Если рядом стоят PAID и paid или всплывает забытый статус — переносить такой набор в ENUM рано: сначала чистка данных.

СитуацияВыбор
Набор фиксирован, меняется редко, без атрибутовENUM
Набор растёт, нужно переименование или удалениесправочная таблица
Нужны атрибуты рядом с кодомсправочная таблица
Простой, до 5–7 значений, без атрибутовCHECK IN
Значения часто меняются без деплоясправочная таблица

Практическое правило:

  • Статусы доменных сущностей (order_status, payment_status) — почти всегда справочная таблица. Со временем к ним добавляются атрибуты, а старые значения иногда нужно убирать.
  • Технические перечисления (event_kind, notification_channel) — ENUM или CHECK, если набор устоявшийся.
  • Стандартные коды (страна по ISO 3166, валюта по ISO 4217, язык по ISO 639) — справочная таблица со стандартными значениями.

Добавление и удаление значений ENUM

Если вы всё же выбрали ENUM и нужно добавить новое значение:

ALTER TYPE order_status ADD VALUE 'REFUNDED';

Это работает быстро даже на большой таблице: данные не переписываются. Новое значение встаёт в конец порядка; другое место задают BEFORE или AFTER:

ALTER TYPE order_status ADD VALUE 'REFUNDED' AFTER 'PAID';

Но есть важная деталь: в той же транзакции новое значение использовать нельзя:

BEGIN;
ALTER TYPE order_status ADD VALUE 'REFUNDED';
INSERT INTO order_doc (status) VALUES ('REFUNDED');  -- unsafe use of new value
COMMIT;

Отказывает здесь вставка, а не сама команда: начиная с PostgreSQL 12 ALTER TYPE … ADD VALUE внутри транзакции разрешён, в более старых версиях не пускали и его. А вот записать новое значение в таблицу, пока транзакция не зафиксирована, нельзя — в каталоге оно ещё не закреплено.

Правильно — разнести добавление значения и вставку данных по разным миграциям: сначала деплой, потом использование.

И обязательная форма для миграций: ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'REFUNDED'. Без IF NOT EXISTS повторный прогон падает с enum label "REFUNDED" already exists, а повторный прогон случается регулярно: откат выкладки и повтор, ручной запуск миграции на стенде, перезапуск задания. Flyway и Liquibase считают такую миграцию сломанной и останавливают всю цепочку, так что чинить приходится руками, на проде и в спешке.

Переименовать значение (PostgreSQL 10+):

ALTER TYPE order_status RENAME VALUE 'OLD_NAME' TO 'NEW_NAME';

Удалить значение — нативного способа нет. Если это понадобится, придётся:

  1. Создать новый тип с нужным набором значений.
  2. Добавить временную колонку с новым типом.
  3. Перенести данные (старые значения → новые).
  4. Удалить старую колонку, переименовать новую.
  5. Удалить старый тип.

Это несколько миграций с деплоем приложения между ними. Есть хоть малейший шанс, что значение придётся удалить, — берите справочную таблицу сразу.

Цена у CHECK-варианта тоже есть, и она в изменении набора. Добавить статус — это DROP CONSTRAINT плюс ADD CONSTRAINT, а проверка нового ограничения читает таблицу целиком под блокировкой, которая не пускает к ней никого, включая чтение. На миллионах строк это заметная пауза в обслуживании. Лечится в два шага: сначала ADD CONSTRAINT … CHECK (…) NOT VALID — ограничение действует для новых строк сразу и таблицу не читает, потом отдельной командой VALIDATE CONSTRAINT, которая проверяет старые строки под лёгкой блокировкой, не мешая работе.

У справочной таблицы своя цена, и тоже не бесплатная. Внешний ключ добавляет проверку на каждую вставку и обновление статуса — база должна убедиться, что такой код в справочнике есть; на стороне справочника для этого нужен индекс (он и так есть, это первичный ключ), а на стороне ссылки индекс нужен, чтобы удаление строки справочника не читало всю таблицу заказов. И почти каждый запрос обрастает соединением:

SELECT o.id, s.title, s.is_terminal
FROM order_doc o
JOIN order_status_ref s ON s.code = o.status
WHERE s.is_terminal = false
ORDER BY s.sort_order, o.created_at DESC;

Зато именно ради таких запросов справочник и заводят: sort_order задаёт порядок статусов в интерфейсе, не требуя хардкода в коде, is_terminal одним условием отделяет живые заказы от завершённых, а title даёт название на языке пользователя. С ENUM всё это живёт в приложении и дублируется в каждом, кто ходит в базу.

Маппинг в коде приложения

Независимо от способа хранения, в коде перечисление должно быть типизированным — не обычной строкой. Тогда опечатку ловит компилятор (или линтер), а IDE подсказывает допустимые значения.

В Java поверх PG ENUM класс перечисления руками не пишут: его делает кодогенерация jOOQ по типу из базы, и колонка в сгенерированном классе таблицы уже типизирована им. Для справочной таблицы такого класса нет — там колонка обычный varchar, перечисление объявляют сами и подключают конвертером в настройках кодогенерации.

OrderStatus status = dsl
    .select(ORDER_DOC.STATUS)
    .from(ORDER_DOC)
    .where(ORDER_DOC.ID.eq(orderId))
    .fetchOne(r -> r.get(ORDER_DOC.STATUS));

dsl.update(ORDER_DOC)
    .set(ORDER_DOC.STATUS, OrderStatus.PAID)
    .where(ORDER_DOC.ID.eq(orderId))
    .execute();
type OrderStatus string

const (
    OrderStatusNew       OrderStatus = "NEW"
    OrderStatusPaid      OrderStatus = "PAID"
    OrderStatusShipped   OrderStatus = "SHIPPED"
    OrderStatusDelivered OrderStatus = "DELIVERED"
    OrderStatusCancelled OrderStatus = "CANCELLED"
)

// pgx v5: Scan напрямую в OrderStatus
var status OrderStatus
err := row.Scan(&status)

// Запись
_, err = pool.Exec(ctx,
    "UPDATE order_doc SET status = $1 WHERE id = $2",
    OrderStatusPaid, id,
)
enum OrderStatus {
    NEW       = "NEW",
    PAID      = "PAID",
    SHIPPED   = "SHIPPED",
    DELIVERED = "DELIVERED",
    CANCELLED = "CANCELLED",
}

// node-postgres возвращает строку — явное приведение
const { rows } = await pool.query<{ status: string }>(
    "SELECT status FROM order_doc WHERE id = $1",
    [id],
);
const status = rows[0].status as OrderStatus;

// Запись
await pool.query(
    "UPDATE order_doc SET status = $1 WHERE id = $2",
    [OrderStatus.PAID, id],
);
from enum import Enum
import psycopg

class OrderStatus(str, Enum):
    NEW       = "NEW"
    PAID      = "PAID"
    SHIPPED   = "SHIPPED"
    DELIVERED = "DELIVERED"
    CANCELLED = "CANCELLED"

async with await psycopg.AsyncConnection.connect(dsn) as conn:
    cur = await conn.execute(
        "SELECT status FROM order_doc WHERE id = %s", (order_id,)
    )
    row = await cur.fetchone()
    status = OrderStatus(row[0])

    await conn.execute(
        "UPDATE order_doc SET status = %s WHERE id = %s",
        (status.value, order_id),
    )

Что даёт типизация, видно и без базы:

Программа ниже отвечает на два вопроса: как строка из базы становится значением перечисления и почему в колонку пишут имя, а не порядковый номер.

живой пример

public class OrderStatusDemo {
    enum OrderStatus { NEW, PAID, SHIPPED, DELIVERED, CANCELLED }

    public static void main(String[] args) {
        OrderStatus status = OrderStatus.valueOf("PAID");
        System.out.println(status.name() + ": ordinal " + status.ordinal());
        try {
            OrderStatus.valueOf("PAYED");
        } catch (IllegalArgumentException e) {
            System.out.println("опечатка не доедет до базы: " + e.getMessage());
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

valueOf принимает только объявленные имена: строка с опечаткой падает на границе с базой, а не превращается в неизвестный статус внутри логики. Второе в выводе — ordinal: номер зависит от порядка объявления. Поэтому в колонку пишут имя, а не номер — вставили значение в середину перечисления, и все номера уехали, а данные в базе остались старыми.

Есть и обратная сторона типизации, о которой узнают на выкате. Новое значение появляется в базе раньше, чем обновляется всё приложение: миграция прошла, один экземпляр уже новый и пишет REFUNDED, второй ещё старый — и на обычном чтении получает IllegalArgumentException: No enum constant OrderStatus.REFUNDED. Падает не запись, а чтение чужой строки, и падает в неожиданном месте: в списке заказов, в отчёте, в фоновом задании.

Отсюда порядок выкладки, который стоит знать до первого такого случая. Сначала выкатывают приложение, которое умеет прочитать новое значение, но ещё не пишет его. Потом миграцию, добавляющую значение в тип. И только потом — код, который начинает его писать. Тот же порядок спасает при откате: откатить приложение можно, откатить строки, уже записанные с новым статусом, — нет.

Второй способ — не падать на незнакомом значении: читать статус строкой и преобразовывать самому, отдавая неизвестное в UNKNOWN вместо исключения. Так делают там, где значения добавляет другая команда и порядок выкладок не контролируется.

ENUM в запросах ведёт себя не как text

Перечисление — отдельный тип, и это видно в запросах. Сравнение со списком работает как обычно, потому что строковые литералы приводятся к типу автоматически: WHERE status IN ('NEW', 'PAID'). А вот строковые операции — нет: WHERE status LIKE 'N%' отвечает operator does not exist: order_status ~~ unknown, и починка тут одна — явное приведение status::text LIKE 'N%', которое заодно отключает индекс по колонке.

Та же история с параметром из приложения. Драйвер отправляет значение как строку неизвестного типа, и в простых случаях база догадывается сама, а в составных (WHERE status = ANY(?), CASE, соединение по статусу) — отказывается: could not determine data type of parameter. Лечится приведением в самом запросе — WHERE status = ?::order_status, — или настройкой преобразования в jOOQ и Hibernate, где тип перечисления объявляют один раз. Это и есть та мелочь, из-за которой «в psql работает, а из кода нет».

Статус — это не машина состояний

Тип в базе отвечает только на вопрос «какие значения допустимы». Переходами между ними он не управляет.

Правило «из NEW можно перейти только в PAID или CANCELLED, но не сразу в SHIPPED» — это логика приложения, а не ограничение в базе: такие правила живут в коде доменного слоя.

Где именно в коде — отдельный разговор, и он тем важнее, чем больше статусов. Пока их три, хватает проверки в методе агрегата. Дальше набор правил просят таблицу переходов, а потом и отдельный класс на состояние: этот путь, вместе с готовыми движками и процессами, разобран в разделе про конечные автоматы. База в этой истории остаётся на своём месте — она хранит текущее состояние и не знает, как в него попали.

Одно исключение всё же есть: запретить невозможный переход можно и в базе — триггером BEFORE UPDATE, который сверяет старое и новое значение по таблице разрешённых пар. Так делают, когда к таблице ходит не одно приложение, а несколько, и договориться о правилах в коде не получится.

Boolean — отдельная история

Прежде чем говорить о перечислениях, стоит разобраться с полями-флагами. Иногда видишь такой код:

is_active smallint NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
is_active char(1)  NOT NULL DEFAULT 'Y' CHECK (is_active IN ('Y', 'N'))

Это попытка хранить «да/нет» числом или символом: так делали в старых базах, где не было булевого типа. В PostgreSQL есть boolean:

is_active   boolean NOT NULL DEFAULT true,
is_verified boolean NOT NULL DEFAULT false

Почему это лучше:

  • Занимает 1 байт, атомарный тип.
  • Запрос читается естественно: WHERE is_active, а не WHERE is_active = 1.
  • Любой язык и драйвер правильно понимает boolean без дополнительного маппинга.
Дополнительно: при первом чтении можно пропустить

Глубже: NULL в модели: уникальность, IS DISTINCT FROM, coalesceрасширенное

NULL встречается в этой фазе как данность, а в модели он ведёт себя не так, как значение, и это стоит зафиксировать.

В уникальном индексе NULL не равен другому NULL: колонка external_id с UNIQUE пропустит сколько угодно строк без внешнего идентификатора, и это обычно то, что нужно. Если нужно наоборот, «не больше одной строки с NULL», с PostgreSQL 15 есть UNIQUE NULLS NOT DISTINCT, а раньше делали частичный индекс WHERE external_id IS NULL. В составном уникальном ключе (customer_id, promo_code) строка с promo_code = NULL тоже не конфликтует ни с кем, и «один заказ без промокода на покупателя» такой ключ не гарантирует.

Сравнение с NULL в условиях даёт «неизвестно»: WHERE status <> 'PAID' не вернёт строки со статусом NULL, NOT IN со списком, где есть NULL, не вернёт ничего. Для сравнения, где NULL считается обычным значением, есть IS DISTINCT FROM и IS NOT DISTINCT FROM: WHERE old_status IS DISTINCT FROM new_status находит и изменения с NULL на значение. coalesce(discount, 0) подставляет значение по умолчанию в вычислениях, иначе price - discount с NULL в скидке даёт NULL, и итог по заказу молча пропадёт из суммы.

Правило для модели: NULL значит «неизвестно» или «неприменимо», и если у колонки есть осмысленное умолчание (нулевая скидка, пустой список), лучше NOT NULL DEFAULT. Каждая колонка, допускающая NULL, это условие в каждом запросе к ней, и этот налог платится вечно.

Коротко

  • boolean для флагов — не smallint 0/1 и не char 'Y'/'N'.
  • Три способа: ENUM (4 байта, типизация), справочная таблица (INSERT/DELETE, атрибуты, внешний ключ), CHECK IN (просто, до 5–7 значений).
  • Значение в ENUM можно добавить, но не удалить: удаление — миграция из нескольких шагов с пересозданием типа и колонки.
  • ALTER TYPE ADD VALUE и использование нового значения — разные миграции, а не одна транзакция; сортируется ENUM в порядке объявления.
  • Статусы доменных сущностей — обычно справочник: они растут и обрастают атрибутами; технические перечисления — ENUM или CHECK.
  • В коде перечисление типизированное, в базу пишут имя, а не номер; правила переходов между статусами живут в коде, не в типе.
  • NULL в уникальном индексе не конфликтует (с PostgreSQL 15 есть NULLS NOT DISTINCT), в условиях даёт «неизвестно»; сравнивают через IS DISTINCT FROM, считают через coalesce, а где есть умолчание, ставят NOT NULL DEFAULT.
  • ADD VALUE IF NOT EXISTS обязателен в миграциях, иначе повторный прогон рушит цепочку; порядок выкладки — сначала приложение, которое читает новое значение, потом миграция, потом запись.
  • Смена набора у CHECK — это перезапись ограничения под тяжёлой блокировкой, поэтому его добавляют через NOT VALID + VALIDATE CONSTRAINT; справочник платит соединением и проверкой ключа на каждой вставке.
  • ENUM не text: IN (…) работает, LIKE требует приведения ::text, а параметр из кода — ?::order_status или настроенного преобразования.

Что почитать дальше