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

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

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 без дополнительного маппинга.

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

Возьмём конкретный пример: статус заказа — 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'
);

PostgreSQL хранит каждое значение как 4 байта (внутренний числовой код), а в запросах вы работаете со строками.

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

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

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

Вариант 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 REFERENCES order_status_dict(code) DEFAULT 'NEW'
);

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

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

Из минусов: каждое значение занимает до 20 байт вместо 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 значений), когда справочник кажется избыточным. Если значений станет 10 и больше, это трудно читать и поддерживать. Кроме того, нет внешнего ключа — другая таблица не может ссылаться на эти значения как на справочник.

Как выбрать

СитуацияВыбор
Набор фиксирован, меняется редко, без атрибутов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 'PARTIALLY_REFUNDED';

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

BEGIN;
ALTER TYPE order_status ADD VALUE 'PARTIALLY_REFUNDED';
INSERT INTO order_doc (status) VALUES ('PARTIALLY_REFUNDED');  -- ошибка!
COMMIT;

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

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

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

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

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

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

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

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

// jOOQ генерирует Java enum по PG ENUM автоматически.
// Для справочной таблицы (varchar-колонка) нужен ручной конвертер.
public enum OrderStatus { NEW, PAID, SHIPPED, DELIVERED, CANCELLED }

// Чтение статуса
OrderStatus status = dsl
    .select(ORDER_DOC.STATUS)
    .from(ORDER_DOC)
    .where(ORDER_DOC.ID.eq(orderId))
    .fetchOne(r -> r.get(ORDER_DOC.STATUS));

// Обновление — enum передаётся напрямую
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:
    row = await conn.execute(
        "SELECT status FROM order_doc WHERE id = %s", (order_id,)
    ).fetchone()
    status = OrderStatus(row[0])

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

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

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

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

Коротко

  • boolean для флагов — не smallint 0/1 и не char 'Y'/'N'.
  • Три способа: ENUM (4 байта, type-safety), справочная таблица (CRUD, атрибуты, внешний ключ), CHECK IN (простой, без справочника).
  • ENUM подходит, когда набор стабилен; справочная таблица — когда значения меняются или нужны атрибуты.
  • Удалить значение из ENUM нативно нельзя — это сложная миграция из нескольких шагов.
  • ALTER TYPE ADD VALUE и использование нового значения — разные миграции, не одна транзакция.
  • Статусы доменных сущностей — обычно справочная таблица: они растут и приобретают атрибуты.
  • В коде перечисление всегда типизированное, а не обычная строка.
  • Правила переходов между статусами — это отдельная логика в коде, не тип в базе.

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

  • Числа и точность в PostgreSQL — особенности числовых типов.
  • Строковые типы в PostgreSQL — varchar для кодов справочника.
  • JSONB — когда оправдан, когда нет — для сложных структур.
  • Миграции без остановки сервиса — как безопасно менять типы в продакшене.