Статус заказа, тип уведомления, роль пользователя, валюта — это всё перечислимые значения: поле принимает строго один из заранее известного набора вариантов. В 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';
Удалить значение — нативного способа нет. Если это понадобится, придётся:
- Создать новый тип с нужным набором значений.
- Добавить временную колонку с новым типом.
- Перенести данные (старые значения → новые).
- Удалить старую колонку, переименовать новую.
- Удалить старый тип.
Это несколько миграций с деплоем приложения между ними. Если есть хоть малейший шанс, что значение придётся удалить — лучше взять справочную таблицу с самого начала.
Маппинг в коде приложения
Независимо от способа хранения в базе, в коде приложения перечисление должно быть типизированным — не обычной строкой. Это позволяет компилятору (или линтеру) поймать опечатку, а 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 — когда оправдан, когда нет — для сложных структур.
- Миграции без остановки сервиса — как безопасно менять типы в продакшене.