Ez a cikk jelenleg oroszul van — az angol fordítás folyamatban van.
CHECK — это ограничение, которое заставляет базу проверять условие перед вставкой или обновлением строки.
Если условие истинно, строка записывается.
Если условие ложно, PostgreSQL отклоняет операцию с ошибкой.
Например:
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric(12, 2) NOT NULL CHECK (amount > 0),
status text NOT NULL CHECK (status IN ('new', 'paid', 'shipped', 'cancelled')),
created_at timestamptz NOT NULL DEFAULT now()
);
Здесь есть два правила:
amount должен быть больше 0
status должен быть одним из разрешённых значений
Если попробовать вставить заказ с нулевой суммой:
INSERT INTO orders (id, user_id, amount, status)
VALUES (1, 10, 0, 'new');
PostgreSQL не даст записать такую строку.
Если попробовать вставить неизвестный статус:
INSERT INTO orders (id, user_id, amount, status)
VALUES (2, 10, 100, 'refunded');
PostgreSQL тоже отклонит операцию, потому что 'refunded' не входит в разрешённый список.
Главная идея:
CHECK переносит важное бизнес-правило из приложения в схему базы данных.
Зачем нужны CHECK-ограничения
Можно спросить:
А почему просто не проверять это в приложении?
Проверять в приложении тоже нужно. Но приложение — не единственный путь записи данных.
Данные могут попасть в базу через:
- другой сервис;
- админку;
- миграцию;
- SQL-скрипт;
- импорт;
- ETL-процесс;
- ручной
INSERT;
- ручной
UPDATE;
- фоновую задачу;
- временный сервис, который потом забыли удалить.
Если правило живёт только в коде одного приложения, его легко обойти.
Если правило закреплено в базе, некорректная строка не запишется независимо от того, кто отправил запрос.
Например, сумма заказа не должна быть отрицательной.
Можно написать проверку в API, но кто-то потом выполнит:
UPDATE orders
SET amount = -100
WHERE id = 1;
Если в базе нет CHECK, запрос пройдёт.
Если есть:
CHECK (amount > 0)
база остановит ошибку.
CHECK как инвариант
CHECK хорошо подходит для инвариантов.
Инвариант — это правило, которое всегда должно быть правдой для строки.
Например:
цена товара больше 0
зарплата не отрицательная
дата окончания позже даты начала
скидка от 0 до 100
статус входит в разрешённый список
пользователь не может быть менеджером сам себе
Такие правила лучше хранить как можно ближе к данным.
Пример:
CREATE TABLE products (
id bigint PRIMARY KEY,
name text NOT NULL,
price numeric(12, 2) NOT NULL,
discount numeric(5, 2) NOT NULL DEFAULT 0,
CHECK (price > 0),
CHECK (discount >= 0 AND discount <= 100)
);
Теперь база гарантирует:
price всегда больше 0
discount всегда от 0 до 100
Базовый синтаксис CHECK
CHECK можно указать прямо при создании таблицы.
CREATE TABLE employees (
id bigint PRIMARY KEY,
name text NOT NULL,
salary numeric NOT NULL CHECK (salary > 0)
);
Здесь ограничение относится к колонке salary.
Можно записать его отдельно на уровне таблицы:
CREATE TABLE employees (
id bigint PRIMARY KEY,
name text NOT NULL,
salary numeric NOT NULL,
CHECK (salary > 0)
);
Для одной колонки оба варианта похожи.
Но если условие использует несколько колонок, его обычно пишут на уровне таблицы.
Например:
CREATE TABLE subscriptions (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
starts_at date NOT NULL,
ends_at date NOT NULL,
CHECK (ends_at > starts_at)
);
Здесь условие проверяет сразу две колонки:
ends_at должен быть позже starts_at
Именованные ограничения
Если не дать ограничению имя, PostgreSQL сгенерирует его сам.
Например:
orders_amount_check
Иногда автогенерированного имени достаточно, но в реальных проектах лучше задавать имя явно.
CREATE TABLE orders (
id bigint PRIMARY KEY,
amount numeric(12, 2) NOT NULL,
CONSTRAINT orders_amount_positive CHECK (amount > 0)
);
Или через ALTER TABLE:
ALTER TABLE products
ADD CONSTRAINT products_price_positive CHECK (price > 0);
Почему имя полезно?
Потому что оно:
- видно в ошибке;
- понятно в логах;
- удобно при ревью схемы;
- нужно для удаления ограничения;
- нужно для изменения правила через миграцию.
Например, удалить ограничение можно так:
ALTER TABLE products
DROP CONSTRAINT products_price_positive;
Если имя было автогенерированным, его сначала придётся искать.
Хорошие имена для CHECK
Имя ограничения должно объяснять смысл правила.
Хорошо:
orders_amount_positive
products_price_positive
employees_salary_non_negative
subscriptions_dates_valid
users_country_allowed
Плохо:
check1
constraint_123
valid_data
test_check
Хорошее имя помогает быстро понять, почему вставка или обновление упали.
Например, ошибка с ограничением:
orders_amount_positive
сразу говорит: проблема в положительности суммы заказа.
Добавить CHECK к существующей таблице
Ограничение можно добавить к уже существующей таблице.
Например, есть таблица products, и нужно запретить цену меньше или равную нулю.
ALTER TABLE products
ADD CONSTRAINT products_price_positive CHECK (price > 0);
После этого PostgreSQL будет проверять правило при новых вставках и обновлениях.
Но важно: при обычном ADD CONSTRAINT база также проверит уже существующие строки.
Если в таблице уже есть товар с price = 0, добавление ограничения упадёт.
Сначала нужно найти плохие данные:
SELECT id, name, price
FROM products
WHERE price <= 0;
Потом исправить:
UPDATE products
SET price = 1
WHERE price <= 0;
И только потом добавить CHECK.
CHECK со списком допустимых значений
Частый пример — ограничить статус списком значений.
CREATE TABLE orders (
id bigint PRIMARY KEY,
status text NOT NULL,
CONSTRAINT orders_status_allowed
CHECK (status IN ('new', 'paid', 'shipped', 'cancelled'))
);
Теперь нельзя записать статус:
unknown
refunded
done
processing
если его нет в списке.
Плюс такого подхода — простота.
Минус — если статусов много или они часто меняются, лучше подумать о другой модели:
- отдельная таблица справочника;
- foreign key;
- enum-тип PostgreSQL;
- домен;
- бизнес-логика на уровне приложения плюс справочник.
CHECK status IN (...) хорош для небольших и стабильных наборов.
Например:
gender: male/female/other
currency: USD/EUR/GBP
status: new/paid/cancelled
Но если список управляется пользователями или админкой, лучше справочник.
CHECK для диапазонов
CHECK отлично подходит для диапазонов.
Например, скидка от 0 до 100:
ALTER TABLE products
ADD CONSTRAINT products_discount_range
CHECK (discount >= 0 AND discount <= 100);
Или через BETWEEN:
ALTER TABLE products
ADD CONSTRAINT products_discount_range
CHECK (discount BETWEEN 0 AND 100);
Для зарплаты:
ALTER TABLE employees
ADD CONSTRAINT employees_salary_band
CHECK (salary BETWEEN 30000 AND 500000);
Для рейтинга:
ALTER TABLE reviews
ADD CONSTRAINT reviews_rating_range
CHECK (rating BETWEEN 1 AND 5);
Важно: BETWEEN включает обе границы.
rating BETWEEN 1 AND 5
означает:
rating >= 1 AND rating <= 5
CHECK для нескольких колонок
CHECK может сравнивать колонки одной строки между собой.
Например, дата окончания должна быть позже даты начала:
CREATE TABLE bookings (
id bigint PRIMARY KEY,
room_id bigint NOT NULL,
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
CONSTRAINT bookings_dates_valid
CHECK (ends_at > starts_at)
);
Теперь такая строка не пройдёт:
INSERT INTO bookings (id, room_id, starts_at, ends_at)
VALUES (
1,
10,
'2026-01-10 12:00',
'2026-01-10 10:00'
);
Потому что окончание раньше начала.
Другой пример: пользователь не может быть менеджером сам себе.
ALTER TABLE employees
ADD CONSTRAINT employees_not_self_manager
CHECK (manager_id IS NULL OR manager_id <> id);
Здесь условие означает:
manager_id может быть NULL
или manager_id должен отличаться от id сотрудника
CHECK видит только одну строку
Очень важное ограничение:
CHECK проверяет только текущую строку.
Он не должен использовать другие строки таблицы и не может нормально заменить внешние ключи, уникальные ограничения или триггеры.
Например, CHECK хорошо проверяет:
amount > 0
ends_at > starts_at
discount BETWEEN 0 AND 100
manager_id <> id
Потому что все нужные данные лежат в той же строке.
Но CHECK не подходит для правил:
у пользователя не больше 3 активных подписок
сумма всех заказов пользователя не больше лимита
booking не пересекается с другими booking
manager_id должен существовать в employees
email должен быть уникальным
Для таких правил нужны другие инструменты:
UNIQUE;
FOREIGN KEY;
- exclusion constraints;
- триггеры;
- транзакции и блокировки;
- отдельная бизнес-логика;
- нормальная модель данных.
Например, чтобы проверить, что manager_id существует в employees, нужен внешний ключ, а не CHECK.
ALTER TABLE employees
ADD CONSTRAINT employees_manager_fk
FOREIGN KEY (manager_id) REFERENCES employees(id);
А CHECK (manager_id <> id) может только запретить ссылку на самого себя.
CHECK и NULL
Это главная ловушка.
CHECK блокирует только условие, которое вернуло FALSE.
Если условие вернуло TRUE, строка проходит.
Если условие вернуло NULL, строка тоже проходит.
Почему?
Потому что SQL использует трёхзначную логику:
TRUE
FALSE
UNKNOWN / NULL
Для CHECK нарушением считается только FALSE.
Пример:
CREATE TABLE users (
id bigint PRIMARY KEY,
email text NOT NULL,
country text,
CONSTRAINT users_country_allowed
CHECK (country IN ('US', 'GB', 'DE', 'BR'))
);
Кажется, что country теперь обязан быть одним из четырёх значений.
Но это не совсем так.
Такой INSERT пройдёт:
INSERT INTO users (id, email, country)
VALUES (1, 'a@example.com', NULL);
Почему?
Потому что выражение:
country IN ('US', 'GB', 'DE', 'BR')
при country = NULL возвращает не FALSE, а NULL.
А CHECK пропускает NULL.
Как запретить NULL
Если колонка обязательна, лучше добавить NOT NULL.
CREATE TABLE users (
id bigint PRIMARY KEY,
email text NOT NULL,
country text NOT NULL,
CONSTRAINT users_country_allowed
CHECK (country IN ('US', 'GB', 'DE', 'BR'))
);
Теперь NULL не пройдёт из-за NOT NULL.
Можно также встроить проверку прямо в CHECK:
ALTER TABLE users
ADD CONSTRAINT users_country_required_allowed
CHECK (
country IS NOT NULL
AND country IN ('US', 'GB', 'DE', 'BR')
);
Но обычно понятнее разделять:
NOT NULL отвечает за обязательность
CHECK отвечает за допустимые значения
То есть лучше:
country text NOT NULL,
CHECK (country IN ('US', 'GB', 'DE', 'BR'))
CHECK и пустая строка
NULL и пустая строка — разные вещи.
Например:
NULL -- значения нет
'' -- значение есть, но это пустой текст
Если нужно запретить пустые строки, используйте CHECK.
CREATE TABLE users (
id bigint PRIMARY KEY,
email text NOT NULL,
CONSTRAINT users_email_not_blank
CHECK (trim(email) <> '')
);
Теперь такой email не пройдёт:
INSERT INTO users (id, email)
VALUES (1, '');
И такой тоже:
INSERT INTO users (id, email)
VALUES (2, ' ');
потому что trim(email) уберёт пробелы по краям.
Но если колонка допускает NULL, то trim(NULL) <> '' даст NULL, а CHECK это пропустит.
Поэтому для обязательного непустого email лучше:
email text NOT NULL,
CONSTRAINT users_email_not_blank CHECK (trim(email) <> '')
CHECK и now()
Иногда хочется написать:
ALTER TABLE users
ADD CONSTRAINT users_created_not_future
CHECK (created_at <= now());
Технически такие выражения могут выглядеть удобно, но с ними нужно быть осторожным.
CHECK проверяется только в момент вставки или обновления строки.
Он не перепроверяется каждую секунду сам по себе.
Например, правило с текущим временем зависит от момента проверки. Сегодня оно может быть истинным, завтра — уже иметь другой смысл.
PostgreSQL в целом ожидает, что выражения в CHECK ведут себя как стабильные инварианты для одной строки, а не как правило, которое зависит от внешнего времени, других таблиц или меняющейся среды.
Лучше использовать CHECK для таких условий:
CHECK (amount > 0)
CHECK (ends_at > starts_at)
CHECK (discount BETWEEN 0 AND 100)
А для правил, завязанных на текущее время или сложную бизнес-логику, часто лучше подходят:
DEFAULT now();
- триггеры;
- валидация в приложении;
- периодические проверки;
- отдельные задачи контроля данных.
Например, если created_at должен автоматически проставляться при создании, обычно достаточно:
created_at timestamptz NOT NULL DEFAULT now()
А не CHECK (created_at <= now()).
CHECK и функции
В CHECK можно использовать выражения и функции.
Например:
ALTER TABLE users
ADD CONSTRAINT users_email_lowercase
CHECK (email = lower(email));
Это запретит email с заглавными буквами.
Или:
ALTER TABLE users
ADD CONSTRAINT users_email_not_blank
CHECK (length(trim(email)) > 0);
Но важно: функция в CHECK должна быть предсказуемой для одной и той же строки.
Если функция меняет результат со временем или зависит от внешних данных, это плохой кандидат для CHECK.
Например, не стоит использовать CHECK как способ обращаться к другой таблице через функцию. Это ломает идею локального инварианта строки.
CHECK при INSERT и UPDATE
CHECK проверяется при вставке строки.
INSERT INTO products (id, name, price)
VALUES (1, 'Keyboard', 100);
Если price > 0, вставка пройдёт.
Если price <= 0, вставка упадёт.
CHECK также проверяется при обновлении.
UPDATE products
SET price = -10
WHERE id = 1;
Если есть ограничение:
CHECK (price > 0)
PostgreSQL не даст выполнить такой UPDATE.
Это важно: ограничение защищает данные не только при создании строки, но и при изменении.
Добавление CHECK на большую таблицу
Если таблица большая, обычное добавление ограничения может быть тяжёлым.
Например:
ALTER TABLE orders
ADD CONSTRAINT orders_amount_positive CHECK (amount > 0);
PostgreSQL должен проверить уже существующие строки.
На миллионах строк это может занять время.
Для больших таблиц в PostgreSQL есть полезный приём:
NOT VALID
Пример:
ALTER TABLE orders
ADD CONSTRAINT orders_amount_positive
CHECK (amount > 0) NOT VALID;
Что это значит:
- ограничение добавляется в схему;
- новые строки уже будут проверяться;
- обновляемые строки тоже будут проверяться;
- старые строки сразу не проверяются.
Потом можно отдельно запустить валидацию:
ALTER TABLE orders
VALIDATE CONSTRAINT orders_amount_positive;
Это удобно для продакшена, где нельзя надолго блокировать большую таблицу.
Как выкатывать CHECK через NOT VALID
Обычно процесс такой.
Сначала ищем плохие строки:
SELECT id, amount
FROM orders
WHERE amount <= 0;
Потом исправляем данные:
UPDATE orders
SET amount = 1
WHERE amount <= 0;
Потом добавляем ограничение без полной проверки старых строк:
ALTER TABLE orders
ADD CONSTRAINT orders_amount_positive
CHECK (amount > 0) NOT VALID;
Новые данные уже защищены.
Потом, когда можно безопасно проверить старые строки, запускаем:
ALTER TABLE orders
VALIDATE CONSTRAINT orders_amount_positive;
После успешной валидации ограничение становится полностью валидным.
Главная идея:
NOT VALID помогает безопаснее добавить правило на большую существующую таблицу.
CHECK и домены
Если одно и то же правило повторяется во многих таблицах, можно подумать о domain.
Например, положительная сумма:
CREATE DOMAIN positive_amount AS numeric(12, 2)
CHECK (VALUE > 0);
Теперь можно использовать тип:
CREATE TABLE orders (
id bigint PRIMARY KEY,
amount positive_amount NOT NULL
);
Домен хранит правило на уровне типа.
Это удобно, если один и тот же инвариант используется много раз.
Например:
- положительная сумма;
- процент от 0 до 100;
- непустая строка;
- код страны;
- рейтинг от 1 до 5.
Но домены тоже нужно использовать осознанно: они добавляют слой абстракции, который команда должна понимать.
CHECK против ENUM
Для статусов иногда выбирают между CHECK и ENUM.
Вариант через CHECK:
CREATE TABLE orders (
id bigint PRIMARY KEY,
status text NOT NULL,
CONSTRAINT orders_status_allowed
CHECK (status IN ('new', 'paid', 'shipped', 'cancelled'))
);
Вариант через ENUM:
CREATE TYPE order_status AS ENUM (
'new',
'paid',
'shipped',
'cancelled'
);
CREATE TABLE orders (
id bigint PRIMARY KEY,
status order_status NOT NULL
);
Что выбрать?
CHECK проще менять через миграцию ограничения, но список хранится как текстовое условие.
ENUM делает тип более строгим, но изменение enum-значений может быть менее гибким, особенно если нужно удалять или переименовывать значения.
Для учебных и простых задач CHECK status IN (...) отлично подходит.
Для устойчивой доменной модели можно рассмотреть ENUM или справочник с foreign key.
CHECK против FOREIGN KEY
CHECK не заменяет внешний ключ.
Плохо:
CHECK (user_id > 0)
Это проверяет только то, что user_id положительный.
Но не проверяет, что такой пользователь существует.
Для существования связанной строки нужен FOREIGN KEY.
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
amount numeric NOT NULL CHECK (amount > 0)
);
Здесь:
REFERENCES users(id)
проверяет существование пользователя.
А:
CHECK (amount > 0)
проверяет корректность суммы заказа.
У каждого инструмента своя задача.
CHECK против UNIQUE
CHECK не проверяет уникальность.
Например, нельзя нормально выразить правилом CHECK, что email должен быть уникальным.
Для этого нужен UNIQUE.
CREATE TABLE users (
id bigint PRIMARY KEY,
email text NOT NULL UNIQUE
);
CHECK отвечает за локальное условие строки.
UNIQUE отвечает за уникальность среди строк таблицы.
CHECK против триггера
Триггер мощнее CHECK.
Он может:
- смотреть в другие таблицы;
- выполнять сложную логику;
- менять данные;
- писать аудит;
- проверять правила, которые не выразить простым условием.
Но триггеры сложнее читать, тестировать и сопровождать.
Если правило можно выразить через простой CHECK, лучше использовать CHECK.
Например:
CHECK (amount > 0)
лучше, чем триггер, который проверяет положительность суммы.
Правило:
Простые инварианты строки — через CHECK.
Сложная межстрочная логика — через другие механизмы.
CHECK и ошибки
Когда CHECK нарушается, PostgreSQL возвращает ошибку с именем ограничения.
Например:
INSERT INTO products (id, name, price)
VALUES (1, 'Keyboard', -10);
Если есть ограничение:
CONSTRAINT products_price_positive CHECK (price > 0)
ошибка будет указывать на products_price_positive.
Именно поэтому хорошие имена ограничений важны.
По имени сразу понятно, какое правило нарушено.
MySQL
В MySQL CHECK начал реально применяться с версии 8.0.16.
В старых версиях MySQL синтаксис CHECK мог приниматься, но фактически игнорировался.
Это очень важная историческая ловушка.
Например, на старой версии можно было написать:
CREATE TABLE products (
id bigint PRIMARY KEY,
price numeric(12, 2),
CHECK (price > 0)
);
и думать, что база защищает цену.
Но старый MySQL мог просто не проверять это правило.
В современных версиях MySQL CHECK работает, но синтаксис, возможности и нюансы могут отличаться от PostgreSQL.
Также в MySQL нет PostgreSQL-механики:
NOT VALID
VALIDATE CONSTRAINT
Поэтому при переносе миграций между PostgreSQL и MySQL такие места нужно перепроверять отдельно.
ClickHouse
В ClickHouse тоже есть CONSTRAINT ... CHECK.
Например:
CREATE TABLE orders
(
id UInt64,
amount Decimal(12, 2),
CONSTRAINT amount_positive CHECK amount > 0
)
ENGINE = MergeTree
ORDER BY id;
Но ClickHouse — аналитическая колоночная СУБД, а не классическая OLTP-база.
Ограничения там стоит воспринимать иначе, чем в PostgreSQL.
В PostgreSQL CHECK часто используют как строгий инвариант бизнес-данных.
В ClickHouse такие проверки чаще относятся к контролю качества вставляемых аналитических данных, а не к полноценной транзакционной модели с постоянными обновлениями строк.
При переносе логики из PostgreSQL в ClickHouse важно учитывать другую модель хранения и записи данных.
Практические шаблоны
Положительная сумма заказа
CREATE TABLE orders (
id bigint PRIMARY KEY,
amount numeric(12, 2) NOT NULL,
CONSTRAINT orders_amount_positive CHECK (amount > 0)
);
Статус из списка
CREATE TABLE orders (
id bigint PRIMARY KEY,
status text NOT NULL,
CONSTRAINT orders_status_allowed
CHECK (status IN ('new', 'paid', 'shipped', 'cancelled'))
);
Скидка от 0 до 100
ALTER TABLE products
ADD CONSTRAINT products_discount_range
CHECK (discount BETWEEN 0 AND 100);
Дата окончания позже даты начала
ALTER TABLE bookings
ADD CONSTRAINT bookings_dates_valid
CHECK (ends_at > starts_at);
Пользователь не менеджер сам себе
ALTER TABLE employees
ADD CONSTRAINT employees_not_self_manager
CHECK (manager_id IS NULL OR manager_id <> id);
Непустой email
ALTER TABLE users
ADD CONSTRAINT users_email_not_blank
CHECK (trim(email) <> '');
При этом сама колонка тоже должна быть NOT NULL, если NULL запрещён.
Обязательная страна из списка
ALTER TABLE users
ALTER COLUMN country SET NOT NULL;
ALTER TABLE users
ADD CONSTRAINT users_country_allowed
CHECK (country IN ('US', 'GB', 'DE', 'BR'));
Добавить CHECK безопаснее на большой таблице
ALTER TABLE orders
ADD CONSTRAINT orders_amount_positive
CHECK (amount > 0) NOT VALID;
Потом:
ALTER TABLE orders
VALIDATE CONSTRAINT orders_amount_positive;
Удалить CHECK
ALTER TABLE orders
DROP CONSTRAINT orders_amount_positive;
Изменить CHECK
В PostgreSQL обычно правило меняют так:
ALTER TABLE orders
DROP CONSTRAINT orders_status_allowed;
ALTER TABLE orders
ADD CONSTRAINT orders_status_allowed
CHECK (status IN ('new', 'paid', 'shipped', 'cancelled', 'refunded'));
То есть старое ограничение удаляют, новое добавляют.
Частые ошибки
Думают, что CHECK запрещает NULL
Такое ограничение:
CHECK (country IN ('US', 'GB', 'DE'))
не запрещает country = NULL.
Если NULL запрещён, добавьте:
country text NOT NULL
или явно:
CHECK (country IS NOT NULL AND country IN ('US', 'GB', 'DE'))
Пытаются CHECK заменить foreign key
Плохо:
CHECK (user_id > 0)
если нужно проверить, что пользователь существует.
Правильно:
FOREIGN KEY (user_id) REFERENCES users(id)
Пытаются CHECK заменить UNIQUE
Плохо пытаться через CHECK проверять уникальность email.
Правильно:
email text NOT NULL UNIQUE
Используют CHECK для межстрочной логики
Например:
у пользователя должно быть не больше 3 активных подписок
Это не задача для обычного CHECK, потому что нужно смотреть другие строки.
Забывают дать имя ограничению
Плохо:
CHECK (price > 0)
Лучше:
CONSTRAINT products_price_positive CHECK (price > 0)
Используют volatile-логику
Правила, завязанные на текущее время, внешние таблицы или меняющееся окружение, плохо подходят для CHECK.
CHECK лучше использовать для стабильных условий одной строки.
Что важно запомнить
CHECK — это ограничение, которое проверяет условие при вставке или обновлении строки.
Пример:
CREATE TABLE orders (
id bigint PRIMARY KEY,
amount numeric(12, 2) NOT NULL,
CONSTRAINT orders_amount_positive CHECK (amount > 0)
);
Главные правила:
CHECK закрепляет инвариант на уровне базы;
- если условие возвращает
FALSE, строка не записывается;
- если условие возвращает
TRUE, строка проходит;
- если условие возвращает
NULL, строка тоже проходит;
- чтобы запретить
NULL, используйте NOT NULL;
CHECK может ссылаться на несколько колонок одной строки;
CHECK не должен заменять FOREIGN KEY, UNIQUE или триггеры;
- именованные ограничения удобнее сопровождать;
- для больших таблиц в PostgreSQL полезен
NOT VALID;
- для изменения правила обычно удаляют старое ограничение и добавляют новое;
- в MySQL старые версии могли игнорировать
CHECK;
- в ClickHouse семантика и сценарии применения отличаются от PostgreSQL.
Короткий вывод
CHECK нужен, когда вы хотите гарантировать простое правило для каждой строки.
Например:
CHECK (amount > 0)
CHECK (discount BETWEEN 0 AND 100)
CHECK (ends_at > starts_at)
CHECK (status IN ('new', 'paid', 'cancelled'))
Главная мысль:
CHECK защищает данные там, где они живут, — в базе данных.
Приложение может ошибиться, другой сервис может обойти вашу валидацию, миграция может записать данные напрямую. А ограничение в базе не даст сохранить строку, которая нарушает инвариант.
Используйте CHECK для простых локальных правил одной строки, давайте ограничениям понятные имена, помните про NULL и не пытайтесь решать через CHECK задачи, для которых нужны внешние ключи, уникальные ограничения или триггеры.
CHECK— это ограничение, которое заставляет базу проверять условие перед вставкой или обновлением строки.Если условие истинно, строка записывается.
Если условие ложно, PostgreSQL отклоняет операцию с ошибкой.
Например:
CREATE TABLE orders ( id bigint PRIMARY KEY, user_id bigint NOT NULL, amount numeric(12, 2) NOT NULL CHECK (amount > 0), status text NOT NULL CHECK (status IN ('new', 'paid', 'shipped', 'cancelled')), created_at timestamptz NOT NULL DEFAULT now() );Здесь есть два правила:
Если попробовать вставить заказ с нулевой суммой:
INSERT INTO orders (id, user_id, amount, status) VALUES (1, 10, 0, 'new');PostgreSQL не даст записать такую строку.
Если попробовать вставить неизвестный статус:
INSERT INTO orders (id, user_id, amount, status) VALUES (2, 10, 100, 'refunded');PostgreSQL тоже отклонит операцию, потому что
'refunded'не входит в разрешённый список.Главная идея:
Зачем нужны CHECK-ограничения
Можно спросить:
Проверять в приложении тоже нужно. Но приложение — не единственный путь записи данных.
Данные могут попасть в базу через:
INSERT;UPDATE;Если правило живёт только в коде одного приложения, его легко обойти.
Если правило закреплено в базе, некорректная строка не запишется независимо от того, кто отправил запрос.
Например, сумма заказа не должна быть отрицательной.
Можно написать проверку в API, но кто-то потом выполнит:
UPDATE orders SET amount = -100 WHERE id = 1;Если в базе нет
CHECK, запрос пройдёт.Если есть:
CHECK (amount > 0)база остановит ошибку.
CHECK как инвариант
CHECKхорошо подходит для инвариантов.Инвариант — это правило, которое всегда должно быть правдой для строки.
Например:
Такие правила лучше хранить как можно ближе к данным.
Пример:
CREATE TABLE products ( id bigint PRIMARY KEY, name text NOT NULL, price numeric(12, 2) NOT NULL, discount numeric(5, 2) NOT NULL DEFAULT 0, CHECK (price > 0), CHECK (discount >= 0 AND discount <= 100) );Теперь база гарантирует:
Базовый синтаксис CHECK
CHECKможно указать прямо при создании таблицы.CREATE TABLE employees ( id bigint PRIMARY KEY, name text NOT NULL, salary numeric NOT NULL CHECK (salary > 0) );Здесь ограничение относится к колонке
salary.Можно записать его отдельно на уровне таблицы:
CREATE TABLE employees ( id bigint PRIMARY KEY, name text NOT NULL, salary numeric NOT NULL, CHECK (salary > 0) );Для одной колонки оба варианта похожи.
Но если условие использует несколько колонок, его обычно пишут на уровне таблицы.
Например:
CREATE TABLE subscriptions ( id bigint PRIMARY KEY, user_id bigint NOT NULL, starts_at date NOT NULL, ends_at date NOT NULL, CHECK (ends_at > starts_at) );Здесь условие проверяет сразу две колонки:
Именованные ограничения
Если не дать ограничению имя, PostgreSQL сгенерирует его сам.
Например:
Иногда автогенерированного имени достаточно, но в реальных проектах лучше задавать имя явно.
CREATE TABLE orders ( id bigint PRIMARY KEY, amount numeric(12, 2) NOT NULL, CONSTRAINT orders_amount_positive CHECK (amount > 0) );Или через
ALTER TABLE:ALTER TABLE products ADD CONSTRAINT products_price_positive CHECK (price > 0);Почему имя полезно?
Потому что оно:
Например, удалить ограничение можно так:
ALTER TABLE products DROP CONSTRAINT products_price_positive;Если имя было автогенерированным, его сначала придётся искать.
Хорошие имена для CHECK
Имя ограничения должно объяснять смысл правила.
Хорошо:
Плохо:
Хорошее имя помогает быстро понять, почему вставка или обновление упали.
Например, ошибка с ограничением:
сразу говорит: проблема в положительности суммы заказа.
Добавить CHECK к существующей таблице
Ограничение можно добавить к уже существующей таблице.
Например, есть таблица
products, и нужно запретить цену меньше или равную нулю.ALTER TABLE products ADD CONSTRAINT products_price_positive CHECK (price > 0);После этого PostgreSQL будет проверять правило при новых вставках и обновлениях.
Но важно: при обычном
ADD CONSTRAINTбаза также проверит уже существующие строки.Если в таблице уже есть товар с
price = 0, добавление ограничения упадёт.Сначала нужно найти плохие данные:
SELECT id, name, price FROM products WHERE price <= 0;Потом исправить:
UPDATE products SET price = 1 WHERE price <= 0;И только потом добавить
CHECK.CHECK со списком допустимых значений
Частый пример — ограничить статус списком значений.
CREATE TABLE orders ( id bigint PRIMARY KEY, status text NOT NULL, CONSTRAINT orders_status_allowed CHECK (status IN ('new', 'paid', 'shipped', 'cancelled')) );Теперь нельзя записать статус:
если его нет в списке.
Плюс такого подхода — простота.
Минус — если статусов много или они часто меняются, лучше подумать о другой модели:
CHECK status IN (...)хорош для небольших и стабильных наборов.Например:
Но если список управляется пользователями или админкой, лучше справочник.
CHECK для диапазонов
CHECKотлично подходит для диапазонов.Например, скидка от 0 до 100:
ALTER TABLE products ADD CONSTRAINT products_discount_range CHECK (discount >= 0 AND discount <= 100);Или через
BETWEEN:ALTER TABLE products ADD CONSTRAINT products_discount_range CHECK (discount BETWEEN 0 AND 100);Для зарплаты:
ALTER TABLE employees ADD CONSTRAINT employees_salary_band CHECK (salary BETWEEN 30000 AND 500000);Для рейтинга:
ALTER TABLE reviews ADD CONSTRAINT reviews_rating_range CHECK (rating BETWEEN 1 AND 5);Важно:
BETWEENвключает обе границы.означает:
CHECK для нескольких колонок
CHECKможет сравнивать колонки одной строки между собой.Например, дата окончания должна быть позже даты начала:
CREATE TABLE bookings ( id bigint PRIMARY KEY, room_id bigint NOT NULL, starts_at timestamptz NOT NULL, ends_at timestamptz NOT NULL, CONSTRAINT bookings_dates_valid CHECK (ends_at > starts_at) );Теперь такая строка не пройдёт:
INSERT INTO bookings (id, room_id, starts_at, ends_at) VALUES ( 1, 10, '2026-01-10 12:00', '2026-01-10 10:00' );Потому что окончание раньше начала.
Другой пример: пользователь не может быть менеджером сам себе.
ALTER TABLE employees ADD CONSTRAINT employees_not_self_manager CHECK (manager_id IS NULL OR manager_id <> id);Здесь условие означает:
CHECK видит только одну строку
Очень важное ограничение:
Он не должен использовать другие строки таблицы и не может нормально заменить внешние ключи, уникальные ограничения или триггеры.
Например,
CHECKхорошо проверяет:Потому что все нужные данные лежат в той же строке.
Но
CHECKне подходит для правил:Для таких правил нужны другие инструменты:
UNIQUE;FOREIGN KEY;Например, чтобы проверить, что
manager_idсуществует вemployees, нужен внешний ключ, а неCHECK.ALTER TABLE employees ADD CONSTRAINT employees_manager_fk FOREIGN KEY (manager_id) REFERENCES employees(id);А
CHECK (manager_id <> id)может только запретить ссылку на самого себя.CHECK и NULL
Это главная ловушка.
CHECKблокирует только условие, которое вернулоFALSE.Если условие вернуло
TRUE, строка проходит.Если условие вернуло
NULL, строка тоже проходит.Почему?
Потому что SQL использует трёхзначную логику:
Для
CHECKнарушением считается толькоFALSE.Пример:
CREATE TABLE users ( id bigint PRIMARY KEY, email text NOT NULL, country text, CONSTRAINT users_country_allowed CHECK (country IN ('US', 'GB', 'DE', 'BR')) );Кажется, что
countryтеперь обязан быть одним из четырёх значений.Но это не совсем так.
Такой
INSERTпройдёт:INSERT INTO users (id, email, country) VALUES (1, 'a@example.com', NULL);Почему?
Потому что выражение:
country IN ('US', 'GB', 'DE', 'BR')при
country = NULLвозвращает неFALSE, аNULL.А
CHECKпропускаетNULL.Как запретить NULL
Если колонка обязательна, лучше добавить
NOT NULL.CREATE TABLE users ( id bigint PRIMARY KEY, email text NOT NULL, country text NOT NULL, CONSTRAINT users_country_allowed CHECK (country IN ('US', 'GB', 'DE', 'BR')) );Теперь
NULLне пройдёт из-заNOT NULL.Можно также встроить проверку прямо в
CHECK:ALTER TABLE users ADD CONSTRAINT users_country_required_allowed CHECK ( country IS NOT NULL AND country IN ('US', 'GB', 'DE', 'BR') );Но обычно понятнее разделять:
То есть лучше:
country text NOT NULL, CHECK (country IN ('US', 'GB', 'DE', 'BR'))CHECK и пустая строка
NULLи пустая строка — разные вещи.Например:
Если нужно запретить пустые строки, используйте
CHECK.CREATE TABLE users ( id bigint PRIMARY KEY, email text NOT NULL, CONSTRAINT users_email_not_blank CHECK (trim(email) <> '') );Теперь такой email не пройдёт:
INSERT INTO users (id, email) VALUES (1, '');И такой тоже:
INSERT INTO users (id, email) VALUES (2, ' ');потому что
trim(email)уберёт пробелы по краям.Но если колонка допускает
NULL, тоtrim(NULL) <> ''дастNULL, аCHECKэто пропустит.Поэтому для обязательного непустого email лучше:
email text NOT NULL, CONSTRAINT users_email_not_blank CHECK (trim(email) <> '')CHECK и now()
Иногда хочется написать:
ALTER TABLE users ADD CONSTRAINT users_created_not_future CHECK (created_at <= now());Технически такие выражения могут выглядеть удобно, но с ними нужно быть осторожным.
CHECKпроверяется только в момент вставки или обновления строки.Он не перепроверяется каждую секунду сам по себе.
Например, правило с текущим временем зависит от момента проверки. Сегодня оно может быть истинным, завтра — уже иметь другой смысл.
PostgreSQL в целом ожидает, что выражения в
CHECKведут себя как стабильные инварианты для одной строки, а не как правило, которое зависит от внешнего времени, других таблиц или меняющейся среды.Лучше использовать
CHECKдля таких условий:CHECK (amount > 0) CHECK (ends_at > starts_at) CHECK (discount BETWEEN 0 AND 100)А для правил, завязанных на текущее время или сложную бизнес-логику, часто лучше подходят:
DEFAULT now();Например, если
created_atдолжен автоматически проставляться при создании, обычно достаточно:created_at timestamptz NOT NULL DEFAULT now()А не
CHECK (created_at <= now()).CHECK и функции
В
CHECKможно использовать выражения и функции.Например:
ALTER TABLE users ADD CONSTRAINT users_email_lowercase CHECK (email = lower(email));Это запретит email с заглавными буквами.
Или:
ALTER TABLE users ADD CONSTRAINT users_email_not_blank CHECK (length(trim(email)) > 0);Но важно: функция в
CHECKдолжна быть предсказуемой для одной и той же строки.Если функция меняет результат со временем или зависит от внешних данных, это плохой кандидат для
CHECK.Например, не стоит использовать
CHECKкак способ обращаться к другой таблице через функцию. Это ломает идею локального инварианта строки.CHECK при INSERT и UPDATE
CHECKпроверяется при вставке строки.INSERT INTO products (id, name, price) VALUES (1, 'Keyboard', 100);Если
price > 0, вставка пройдёт.Если
price <= 0, вставка упадёт.CHECKтакже проверяется при обновлении.UPDATE products SET price = -10 WHERE id = 1;Если есть ограничение:
CHECK (price > 0)PostgreSQL не даст выполнить такой
UPDATE.Это важно: ограничение защищает данные не только при создании строки, но и при изменении.
Добавление CHECK на большую таблицу
Если таблица большая, обычное добавление ограничения может быть тяжёлым.
Например:
ALTER TABLE orders ADD CONSTRAINT orders_amount_positive CHECK (amount > 0);PostgreSQL должен проверить уже существующие строки.
На миллионах строк это может занять время.
Для больших таблиц в PostgreSQL есть полезный приём:
NOT VALIDПример:
ALTER TABLE orders ADD CONSTRAINT orders_amount_positive CHECK (amount > 0) NOT VALID;Что это значит:
Потом можно отдельно запустить валидацию:
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_positive;Это удобно для продакшена, где нельзя надолго блокировать большую таблицу.
Как выкатывать CHECK через NOT VALID
Обычно процесс такой.
Сначала ищем плохие строки:
SELECT id, amount FROM orders WHERE amount <= 0;Потом исправляем данные:
UPDATE orders SET amount = 1 WHERE amount <= 0;Потом добавляем ограничение без полной проверки старых строк:
ALTER TABLE orders ADD CONSTRAINT orders_amount_positive CHECK (amount > 0) NOT VALID;Новые данные уже защищены.
Потом, когда можно безопасно проверить старые строки, запускаем:
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_positive;После успешной валидации ограничение становится полностью валидным.
Главная идея:
CHECK и домены
Если одно и то же правило повторяется во многих таблицах, можно подумать о domain.
Например, положительная сумма:
CREATE DOMAIN positive_amount AS numeric(12, 2) CHECK (VALUE > 0);Теперь можно использовать тип:
CREATE TABLE orders ( id bigint PRIMARY KEY, amount positive_amount NOT NULL );Домен хранит правило на уровне типа.
Это удобно, если один и тот же инвариант используется много раз.
Например:
Но домены тоже нужно использовать осознанно: они добавляют слой абстракции, который команда должна понимать.
CHECK против ENUM
Для статусов иногда выбирают между
CHECKиENUM.Вариант через
CHECK:CREATE TABLE orders ( id bigint PRIMARY KEY, status text NOT NULL, CONSTRAINT orders_status_allowed CHECK (status IN ('new', 'paid', 'shipped', 'cancelled')) );Вариант через
ENUM:CREATE TYPE order_status AS ENUM ( 'new', 'paid', 'shipped', 'cancelled' ); CREATE TABLE orders ( id bigint PRIMARY KEY, status order_status NOT NULL );Что выбрать?
CHECKпроще менять через миграцию ограничения, но список хранится как текстовое условие.ENUMделает тип более строгим, но изменение enum-значений может быть менее гибким, особенно если нужно удалять или переименовывать значения.Для учебных и простых задач
CHECK status IN (...)отлично подходит.Для устойчивой доменной модели можно рассмотреть
ENUMили справочник с foreign key.CHECK против FOREIGN KEY
CHECKне заменяет внешний ключ.Плохо:
CHECK (user_id > 0)Это проверяет только то, что
user_idположительный.Но не проверяет, что такой пользователь существует.
Для существования связанной строки нужен
FOREIGN KEY.CREATE TABLE orders ( id bigint PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id), amount numeric NOT NULL CHECK (amount > 0) );Здесь:
REFERENCES users(id)проверяет существование пользователя.
А:
CHECK (amount > 0)проверяет корректность суммы заказа.
У каждого инструмента своя задача.
CHECK против UNIQUE
CHECKне проверяет уникальность.Например, нельзя нормально выразить правилом
CHECK, что email должен быть уникальным.Для этого нужен
UNIQUE.CREATE TABLE users ( id bigint PRIMARY KEY, email text NOT NULL UNIQUE );CHECKотвечает за локальное условие строки.UNIQUEотвечает за уникальность среди строк таблицы.CHECK против триггера
Триггер мощнее
CHECK.Он может:
Но триггеры сложнее читать, тестировать и сопровождать.
Если правило можно выразить через простой
CHECK, лучше использоватьCHECK.Например:
CHECK (amount > 0)лучше, чем триггер, который проверяет положительность суммы.
Правило:
CHECK и ошибки
Когда
CHECKнарушается, PostgreSQL возвращает ошибку с именем ограничения.Например:
INSERT INTO products (id, name, price) VALUES (1, 'Keyboard', -10);Если есть ограничение:
CONSTRAINT products_price_positive CHECK (price > 0)ошибка будет указывать на
products_price_positive.Именно поэтому хорошие имена ограничений важны.
По имени сразу понятно, какое правило нарушено.
MySQL
В MySQL
CHECKначал реально применяться с версии 8.0.16.В старых версиях MySQL синтаксис
CHECKмог приниматься, но фактически игнорировался.Это очень важная историческая ловушка.
Например, на старой версии можно было написать:
CREATE TABLE products ( id bigint PRIMARY KEY, price numeric(12, 2), CHECK (price > 0) );и думать, что база защищает цену.
Но старый MySQL мог просто не проверять это правило.
В современных версиях MySQL
CHECKработает, но синтаксис, возможности и нюансы могут отличаться от PostgreSQL.Также в MySQL нет PostgreSQL-механики:
NOT VALID VALIDATE CONSTRAINTПоэтому при переносе миграций между PostgreSQL и MySQL такие места нужно перепроверять отдельно.
ClickHouse
В ClickHouse тоже есть
CONSTRAINT ... CHECK.Например:
CREATE TABLE orders ( id UInt64, amount Decimal(12, 2), CONSTRAINT amount_positive CHECK amount > 0 ) ENGINE = MergeTree ORDER BY id;Но ClickHouse — аналитическая колоночная СУБД, а не классическая OLTP-база.
Ограничения там стоит воспринимать иначе, чем в PostgreSQL.
В PostgreSQL
CHECKчасто используют как строгий инвариант бизнес-данных.В ClickHouse такие проверки чаще относятся к контролю качества вставляемых аналитических данных, а не к полноценной транзакционной модели с постоянными обновлениями строк.
При переносе логики из PostgreSQL в ClickHouse важно учитывать другую модель хранения и записи данных.
Практические шаблоны
Положительная сумма заказа
CREATE TABLE orders ( id bigint PRIMARY KEY, amount numeric(12, 2) NOT NULL, CONSTRAINT orders_amount_positive CHECK (amount > 0) );Статус из списка
CREATE TABLE orders ( id bigint PRIMARY KEY, status text NOT NULL, CONSTRAINT orders_status_allowed CHECK (status IN ('new', 'paid', 'shipped', 'cancelled')) );Скидка от 0 до 100
ALTER TABLE products ADD CONSTRAINT products_discount_range CHECK (discount BETWEEN 0 AND 100);Дата окончания позже даты начала
ALTER TABLE bookings ADD CONSTRAINT bookings_dates_valid CHECK (ends_at > starts_at);Пользователь не менеджер сам себе
ALTER TABLE employees ADD CONSTRAINT employees_not_self_manager CHECK (manager_id IS NULL OR manager_id <> id);Непустой email
ALTER TABLE users ADD CONSTRAINT users_email_not_blank CHECK (trim(email) <> '');При этом сама колонка тоже должна быть
NOT NULL, еслиNULLзапрещён.Обязательная страна из списка
ALTER TABLE users ALTER COLUMN country SET NOT NULL; ALTER TABLE users ADD CONSTRAINT users_country_allowed CHECK (country IN ('US', 'GB', 'DE', 'BR'));Добавить CHECK безопаснее на большой таблице
ALTER TABLE orders ADD CONSTRAINT orders_amount_positive CHECK (amount > 0) NOT VALID;Потом:
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_positive;Удалить CHECK
ALTER TABLE orders DROP CONSTRAINT orders_amount_positive;Изменить CHECK
В PostgreSQL обычно правило меняют так:
ALTER TABLE orders DROP CONSTRAINT orders_status_allowed; ALTER TABLE orders ADD CONSTRAINT orders_status_allowed CHECK (status IN ('new', 'paid', 'shipped', 'cancelled', 'refunded'));То есть старое ограничение удаляют, новое добавляют.
Частые ошибки
Думают, что CHECK запрещает NULL
Такое ограничение:
CHECK (country IN ('US', 'GB', 'DE'))не запрещает
country = NULL.Если
NULLзапрещён, добавьте:country text NOT NULLили явно:
CHECK (country IS NOT NULL AND country IN ('US', 'GB', 'DE'))Пытаются CHECK заменить foreign key
Плохо:
CHECK (user_id > 0)если нужно проверить, что пользователь существует.
Правильно:
FOREIGN KEY (user_id) REFERENCES users(id)Пытаются CHECK заменить UNIQUE
Плохо пытаться через
CHECKпроверять уникальность email.Правильно:
email text NOT NULL UNIQUEИспользуют CHECK для межстрочной логики
Например:
Это не задача для обычного
CHECK, потому что нужно смотреть другие строки.Забывают дать имя ограничению
Плохо:
CHECK (price > 0)Лучше:
CONSTRAINT products_price_positive CHECK (price > 0)Используют volatile-логику
Правила, завязанные на текущее время, внешние таблицы или меняющееся окружение, плохо подходят для
CHECK.CHECKлучше использовать для стабильных условий одной строки.Что важно запомнить
CHECK— это ограничение, которое проверяет условие при вставке или обновлении строки.Пример:
CREATE TABLE orders ( id bigint PRIMARY KEY, amount numeric(12, 2) NOT NULL, CONSTRAINT orders_amount_positive CHECK (amount > 0) );Главные правила:
CHECKзакрепляет инвариант на уровне базы;FALSE, строка не записывается;TRUE, строка проходит;NULL, строка тоже проходит;NULL, используйтеNOT NULL;CHECKможет ссылаться на несколько колонок одной строки;CHECKне должен заменятьFOREIGN KEY,UNIQUEили триггеры;NOT VALID;CHECK;Короткий вывод
CHECKнужен, когда вы хотите гарантировать простое правило для каждой строки.Например:
CHECK (amount > 0)CHECK (discount BETWEEN 0 AND 100)CHECK (ends_at > starts_at)CHECK (status IN ('new', 'paid', 'cancelled'))Главная мысль:
Приложение может ошибиться, другой сервис может обойти вашу валидацию, миграция может записать данные напрямую. А ограничение в базе не даст сохранить строку, которая нарушает инвариант.
Используйте
CHECKдля простых локальных правил одной строки, давайте ограничениям понятные имена, помните проNULLи не пытайтесь решать черезCHECKзадачи, для которых нужны внешние ключи, уникальные ограничения или триггеры.