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