CHECK и UNIQUE в PostgreSQL: ошибки новичков
Ограничение CHECK может пропустить NULL, а UNIQUE — несколько строк без значения. Разбираем типичные ошибки в PostgreSQL 16: проверку существующих данных, составные ключи, частичные индексы и поведение ограничений в проде.

В PostgreSQL 16 таблица с двумя строками, где email = NULL, может законно пройти ограничение UNIQUE, а CHECK (age >= 18) пропустит строку с неизвестным возрастом. Эти два сюрприза становятся причиной дублей, некорректных данных и падений миграций. Ниже — практические правила для проектирования и проверки CHECK и UNIQUE, без попытки заменить ими внешние ключи, триггеры и бизнес-логику.
Сначала определите, что именно запрещает правило
CHECK проверяет выражение для одной строки. UNIQUE не позволяет двум строкам иметь одинаковое значение или одинаковую комбинацию значений. Это разные уровни защиты, и подмена одного ограничения другим обычно создаёт дыру.
| Ограничение | Что проверяет | Типичная задача | Чего не делает |
|---|---|---|---|
CHECK |
Выражение в одной строке | Цена не меньше нуля, статус входит в допустимый набор | Не ищет совпадения в других строках |
UNIQUE |
Уникальность значения или набора колонок | Один логин на пользователя, одна пара «товар — склад» | Не проверяет диапазоны и смысл значения |
NOT NULL |
Наличие значения | Обязательный email или дата создания | Не проверяет формат и уникальность |
Термин «ограничение» (constraint) — правило, которое PostgreSQL проверяет при вставке или изменении данных и при необходимости отклоняет операцию. Ограничение задают на уровне базы, поэтому оно действует не только для одного приложения, но и для миграций, скриптов и ручных запросов.
Ошибка №1: считать CHECK проверкой на истинность без NULL
Условие CHECK нарушается, когда его результат равен FALSE. Результат UNKNOWN, который появляется из-за NULL, ошибкой не считается. Поэтому такой столбец не запрещает неизвестное значение:
CREATE TABLE products (
product_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
price numeric(12, 2) CHECK (price >= 0)
);
INSERT INTO products (name, price)
VALUES ('Товар без цены', NULL);
Запрос выполнится. Если цена обязательна, добавьте NOT NULL:
CREATE TABLE products (
product_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
price numeric(12, 2) NOT NULL,
CONSTRAINT products_price_check CHECK (price >= 0)
);
Это правило касается и перечислений. Условие status IN ('new', 'paid') разрешит NULL. Для обязательного статуса нужны оба ограничения: NOT NULL и CHECK.
Ошибка №2: ожидать от UNIQUE запрета нескольких NULL
Обычное ограничение UNIQUE считает несколько NULL допустимыми: два неизвестных значения не считаются одинаковыми. Для контактного email это часто не то, что требуется.
CREATE TABLE customers (
customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text UNIQUE
);
INSERT INTO customers (email) VALUES (NULL);
INSERT INTO customers (email) VALUES (NULL);
Если у каждого клиента должен быть email, используйте NOT NULL вместе с UNIQUE. Если email необязателен, но заполненное значение должно встречаться один раз, обычного UNIQUE достаточно.
В PostgreSQL 15 и новее можно явно сделать так, чтобы NULL тоже считался повторяющимся значением:
CREATE TABLE customer_contacts (
customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text,
CONSTRAINT customer_contacts_email_key
UNIQUE NULLS NOT DISTINCT (email)
);
Для PostgreSQL 14 и более старых версий этот синтаксис не подходит. Перед миграцией проверьте версию сервера, а не только версию клиента или ORM. Если база старее, задачу обычно решают частичным уникальным индексом для непустых значений либо отдельной моделью данных — выбор зависит от того, разрешена ли одна строка с NULL.
Если правило должно работать для всех способов записи, формулируйте его в базе явно: «поле обязательно», «значения уникальны» и «NULL считается отдельным случаем» — это три разные политики.
Ошибка №3: перепутать уникальность столбца и пары столбцов
В магазине один и тот же товар может продаваться на разных складах. Тогда уникальным должна быть пара (product_id, warehouse_id), а не каждый столбец по отдельности.
CREATE TABLE stock (
product_id bigint NOT NULL,
warehouse_id bigint NOT NULL,
quantity integer NOT NULL CHECK (quantity >= 0),
CONSTRAINT stock_product_warehouse_key
UNIQUE (product_id, warehouse_id)
);
Здесь одна пара встретится только один раз, но один товар может иметь несколько складов, а один склад — много товаров. Частая ошибка новичка — поставить UNIQUE на product_id и случайно запретить второй склад для того же товара.
Порядок колонок в составном ограничении влияет на создаваемый индекс и на полезность его левого префикса для запросов. Само правило уникальности для пары от перестановки не меняется, но для поиска по одному полю может понадобиться отдельный индекс. Это уже вопрос планов запросов, а не корректности ограничения.
Ошибка №4: пытаться проверить другие строки через CHECK
CHECK не предназначен для запросов к таблице. В его выражении нельзя использовать подзапрос, а функция, обращающаяся к другим строкам, создаёт хрупкое правило: PostgreSQL предполагает, что результат проверки не меняется сам по себе.
Если нужно запретить дубли, используйте UNIQUE. Если нужно связать значение с другой таблицей — внешний ключ. Если правило касается пересечения диапазонов или интервалов, может понадобиться ограничение EXCLUDE. Проверку сложного сценария, который действительно зависит от нескольких строк и не выражается стандартным ограничением, проектируют отдельно: например, с помощью транзакции и блокировок или триггера.
Не стоит делать в CHECK условие вроде «дата окончания больше текущей даты». Строка, прошедшая проверку сегодня, не будет автоматически перепроверена завтра. Для срока действия нужны запросы при чтении, фоновая задача или другая модель состояния.
Ошибка №5: добавлять ограничение на грязные данные
Миграция с ALTER TABLE ... ADD CONSTRAINT проверяет уже существующие строки. Если среди них есть нарушение, миграция завершится ошибкой, а ограничение не появится.
ALTER TABLE products
ADD CONSTRAINT products_price_check CHECK (price >= 0);
Перед такой операцией найдите проблемные записи тем же условием, но с отрицанием и отдельной проверкой NULL:
SELECT product_id, price
FROM products
WHERE price IS NULL OR price < 0;
После исправления данных повторите миграцию. Если таблица большая и окно блокировки ограничено, не копируйте схему вслепую: изучите вариант создания индекса отдельно и особенности конкретного ограничения в документации PostgreSQL. Для UNIQUE полезно сначала найти дубли:
SELECT email, COUNT(*) AS duplicate_count
FROM customers
WHERE email IS NOT NULL
GROUP BY email
HAVING COUNT(*) > 1;
Ошибка №6: игнорировать регистр, пробелы и формат
UNIQUE сравнивает значения согласно типу данных и правилам сортировки. Для обычного text строки user@example.com и USER@example.com обычно различаются. Пробел в конце, разные Unicode-формы и неодинаковая нормализация тоже могут создать два значения, которые пользователь считает одним.
Сначала определите политику: нужно ли считать адреса без учёта регистра, удалять ли внешние пробелы, допустимы ли разные варианты написания. Затем закрепите нормализацию на входе и, если это требование базы, используйте выражение в уникальном индексе. Не смешивайте это с CHECK: проверка формата не делает значения уникальными.
Что ломается в реальном проекте
- Гонка при проверке в приложении. Два запроса одновременно делают
SELECT, не находят запись и оба пытаются вставить её. Только ограничение в базе надёжно остановит один из них. - Неправильная обработка ошибки. Конфликт
UNIQUEнужно превращать в понятный ответ пользователю или повтор операции, а не показывать общий экран «ошибка сервера». - Разные правила в средах. Если ограничение есть в staging, но отсутствует в production, приложение получает разные гарантии. Проверяйте схему миграциями и автоматическим тестом.
- Изменение политики без очистки данных. Переход от обычного
UNIQUEкNULLS NOT DISTINCTможет не выполниться, если в таблице уже несколькоNULL.
Когда выбирать CHECK, UNIQUE или код приложения
Правило, которое можно выразить через одну строку, лучше оставить в CHECK. Уникальность одного значения или комбинации — в UNIQUE. Проверку, зависящую от внешнего сервиса, времени или сложного бизнес-процесса, можно выполнять в приложении, но критичные инварианты при этом стоит дополнительно защищать транзакцией или подходящим ограничением базы.
Не полагайтесь только на валидацию формы. Она улучшает сообщение об ошибке, но не защищает от параллельных запросов, импорта CSV и ручных изменений через SQL-клиент.
Что делать дальше
Проверьте каждое ограничение в миграциях на трёх наборах данных: корректная строка, явное нарушение и NULL. Для UNIQUE добавьте отдельный тест на параллельную вставку или хотя бы обработку конфликта в приложении. После выкладки смотрите ошибки нарушения ограничений в логах и отдельно измеряйте время миграций на объёме production-данных. Инвалидация кеша и проектирование внешних ключей — отдельные темы, здесь важны только CHECK и UNIQUE.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: PostgreSQL 16 Documentation: Constraints, PostgreSQL 16 Documentation: CREATE TABLE
Частые вопросы
Почему CHECK (value > 0) пропускает NULL?
Как запретить несколько NULL в уникальном поле?
Что произойдёт, если я не успею исправить данные до миграции?
Можно ли через CHECK проверить, что такого значения нет в другой строке?
Почему UNIQUE не считает одинаковыми два email в разном регистре?
Что делать, если приложение получило duplicate key value violates unique constraint?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.