Что такое constraint и зачем он нужен
В этом уроке разберём пять типов ограничений и поймём, какие из них стоит обязательно дублировать в приложении, а какие — наоборот, делегировать базе и убрать из кода.
NOT NULL — запрет на отсутствие значения
Самое простое. Запрещает класть NULL в колонку.
CREATE TABLE t (
required_field TEXT NOT NULL
);
Семантика: «у каждой строки в этой колонке должно быть конкретное значение». Если приложение делает INSERT без этой колонки и у неё нет DEFAULT — Postgres откажет.
Когда NOT NULL критичен:
- FK-колонки, где «нет родителя» бессмысленно (
order.customer_id). - Timestamps вроде
created_at. - Поля, на которых строится бизнес-логика и
NULL-проверки утомительны.
UNIQUE — запрет на дубликаты
UNIQUE гарантирует, что значения в колонке (или комбинации колонок) не повторяются. Под капотом Postgres создаёт уникальный B-tree индекс, который и обеспечивает гарантию, и попутно ускоряет поиск.
CREATE TABLE users (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE
);
-- Многоколоночный UNIQUE — пара (a, b) уникальна
CREATE TABLE memberships (
user_id INT NOT NULL,
group_id INT NOT NULL,
UNIQUE (user_id, group_id)
);
Главная ловушка UNIQUE: по стандарту SQL NULL = NULL это NULL, а не TRUE. Значит, две строки с NULL в одной и той же колонке не считаются дубликатами, и UNIQUE пустит их обе.
С Postgres 15 можно явно изменить это: UNIQUE NULLS NOT DISTINCT (email) — теперь NULL считается равным себе же, и второй NULL уже не пройдёт.
PRIMARY KEY — главный идентификатор
Под капотом PRIMARY KEY это NOT NULL UNIQUE плюс автоматический индекс плюс пометка «это главный ключ таблицы». На таблицу допустим только один PRIMARY KEY.
-- Простой PK
CREATE TABLE products (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku TEXT NOT NULL UNIQUE
);
-- Составной PK — два поля вместе образуют ключ
CREATE TABLE order_items (
order_id INT NOT NULL,
product_id INT NOT NULL,
qty INT NOT NULL,
PRIMARY KEY (order_id, product_id)
);
Составные ключи — норма для таблиц связи «многие ко многим». Если у вас есть отдельная сущность «позиция в заказе» с собственной жизнью (история изменений, ссылки извне) — добавляйте суррогатный id. Если позиция полностью определена парой (order_id, product_id) — этой пары как PK достаточно.
FOREIGN KEY — ссылочная целостность
FOREIGN KEY говорит: «значения в этой колонке должны существовать в другой таблице».
CREATE TABLE orders (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customers(id)
);
Что Postgres проверяет:
- На
INSERT/UPDATEвorders: значениеcustomer_idдолжно существовать вcustomers(id)(или бытьNULL, если колонка nullable). - На
DELETE/UPDATEвcustomers: если кто-то ссылается на удаляемую строку — нужно решить, что делать. По умолчанию операция падает.
Пять режимов ON DELETE / ON UPDATE
Что делать с дочерними строками, когда родителя удаляют или меняют его id:
Пять стандартных реакций FK на удаление/изменение родителя. NO ACTION — поведение по умолчанию.
Как выбирать:
CASCADE— когда дочерние записи бессмысленны без родителя. Удалили заказ —order_itemsдолжны исчезнуть. Удалили пользователя — его сессии и токены не нужны.RESTRICT/NO ACTION— когда удаление родителя должно быть осознанным. Удалить категорию, в которой есть товары, нельзя — сначала разберитесь с товарами.SET NULL— когда дочерняя сущность переживёт смерть родителя. Удалили автора — статьи остаются, ноauthor_idстановитсяNULL.
Сравни поведение CASCADE и RESTRICT
Производительность FK
Маленький, но важный нюанс: FK сам по себе не создаёт индекс на колонке-ссылке. Postgres строит индекс только на стороне родителя (customers.id уже под PK). А вот orders(customer_id) остаётся без индекса, если не создать его руками. Это приводит к двум проблемам:
- JOIN customers + orders идёт через sequential scan по
orders. DELETE FROM customers WHERE id = 5запускает проверку «есть ли заказы у этого клиента» — без индекса это full scan.
Правило: на каждую FOREIGN KEY колонку обычно нужен явный индекс.
CHECK — произвольные правила на значения
CHECK проверяет произвольный булев предикат. Это «всё остальное» — то, что не выражается через NOT NULL, UNIQUE, FOREIGN KEY.
CREATE TABLE products (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
price_cents INT NOT NULL CHECK (price_cents > 0),
discount NUMERIC(3, 2) CHECK (discount BETWEEN 0 AND 1),
status TEXT NOT NULL CHECK (status IN ('draft','active','archived'))
);
Многоколоночные CHECK (на уровне таблицы):
CREATE TABLE bookings (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
CHECK (ends_at > starts_at)
);
Ограничения CHECK:
- Предикат должен быть детерминированным. Не используйте
now(),random(), обращения к другим таблицам. (Для «значения, зависящего от других таблиц» естьFOREIGN KEYи триггеры.) CHECKпропускает строку, если предикат вернулNULLилиTRUE. То естьCHECK (x > 0)пустит строку сx = NULL. Если такого быть не должно — добавьтеNOT NULL.
CHECK и его ловушка с NULL
Constraint vs application-level validation
Большой философский вопрос: какие проверки делать в базе, а какие в коде приложения?
Constraint в БД — гарантия для всех клиентов. Даже если завтра придёт стажёр и напишет миграцию, обходящую ваш ORM, ограничение его остановит. Минусы: ошибка приходит «снизу», с не очень дружелюбным текстом; некоторые правила в SQL выражать неудобно.
Валидация в приложении — гибче и даёт лучшие сообщения пользователю. Минусы: можно обойти; разработчики из другой команды могут не знать ваших правил.
Эмпирическое правило, которое работает:
| Что | Где |
|---|---|
| FK, PK, UNIQUE | обязательно в БД |
NOT NULL для критичных полей | обязательно в БД |
Простые инварианты (price > 0, статус из enum) | в БД через CHECK, в приложении дублируется для UX |
| Сложная бизнес-логика («у этого тарифа максимум 5 проектов») | в приложении; в БД только если можно выразить через UNIQUE/CHECK |
| Кросс-табличные правила | в приложении или через триггер |
Главный принцип: критичные инварианты — всегда в БД. Удобство и UX — в приложении.
Referential integrity: внешние ключи и ON DELETE/UPDATE Уровни блокировок в PostgreSQLЧек-лист
NOT NULL,UNIQUE,PRIMARY KEY,FOREIGN KEY,CHECK— пять стандартных ограничений целостности в Postgres.PRIMARY KEY = NOT NULL + UNIQUE + индекс + «главный ключ». Один на таблицу.- У
FOREIGN KEYпять вариантовON DELETE/UPDATE:NO ACTION,RESTRICT,CASCADE,SET NULL,SET DEFAULT. Выбор зависит от того, может ли дочерняя сущность пережить смерть родителя. - FK не создаёт индекс на дочерней колонке — почти всегда нужен явный
CREATE INDEX. CHECKпринимает любой детерминированный предикат. Главная ловушка —NULLне валитCHECK, нужен отдельныйNOT NULL.- Критичные инварианты (ссылочная целостность, уникальность, обязательность) — всегда в БД. Сложная бизнес-логика — в приложении.