Ten artykuł jest obecnie po rosyjsku — trwa tłumaczenie na angielski.
Триггер — это код, который база данных запускает автоматически, когда с таблицей происходит определённое действие.
Например:
Кто-то вставил строку.
Кто-то обновил строку.
Кто-то удалил строку.
И база сама вызывает нужную функцию.
На практике триггеры используют, когда нужно выполнить служебную логику прямо на уровне базы:
- автоматически обновить поле
updated_at;
- привести email к нижнему регистру;
- записать историю изменений;
- запретить опасную операцию;
- синхронизировать связанные данные;
- сохранить аудит: кто, что и когда поменял.
Главная идея такая:
Триггер срабатывает независимо от того, кто изменил данные: приложение, админка, ETL-скрипт или разработчик через psql.
Это его сильная сторона. Но одновременно и опасность: логика становится скрытой. Человек смотрит на обычный UPDATE, а за ним в базе может выполняться ещё несколько действий.
Поэтому триггеры нужно использовать аккуратно: для понятных, предсказуемых и действительно нужных задач.
Простой пример из жизни
Допустим, у нас есть таблица заметок:
CREATE TABLE notes (
id bigint PRIMARY KEY,
title text NOT NULL,
body text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Мы хотим, чтобы поле updated_at обновлялось каждый раз, когда заметку меняют.
Можно каждый раз писать в приложении:
UPDATE notes
SET title = 'New title',
updated_at = now()
WHERE id = 1;
Но это легко забыть.
Где-то разработчик обновит title, но забудет updated_at.
Где-то скрипт поправит body, но тоже не обновит дату.
Где-то тестировщик руками выполнит UPDATE в базе.
В итоге поле updated_at перестанет быть честным.
Триггер решает это так: база сама проставляет updated_at перед каждым обновлением строки.
В PostgreSQL триггер состоит из двух частей
В PostgreSQL триггер обычно состоит из двух объектов:
- Функция, которую нужно выполнить.
- Сам триггер, который привязывает эту функцию к таблице и событию.
Сначала создаём функцию:
CREATE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Потом создаём триггер:
CREATE TRIGGER notes_touch_updated_at
BEFORE UPDATE ON notes
FOR EACH ROW
EXECUTE FUNCTION touch_updated_at();
Теперь при каждом UPDATE таблицы notes PostgreSQL сам вызовет функцию touch_updated_at().
Приложению больше не нужно помнить про updated_at.
BEFORE и AFTER: когда срабатывает триггер
У триггера есть момент срабатывания.
Самые частые варианты:
BEFORE
и
AFTER
BEFORE означает: триггер сработает до того, как строка будет записана в таблицу.
Это удобно, когда нужно изменить данные перед записью.
Например:
- проставить
updated_at;
- нормализовать email;
- заполнить служебное поле;
- отменить операцию для конкретной строки.
AFTER означает: триггер сработает после того, как действие со строкой уже выполнено.
Важно: AFTER — это не значит «после коммита транзакции». Транзакция всё ещё может откатиться. Но сама операция над строкой уже произошла.
AFTER удобно использовать для побочных действий:
- записать аудит;
- добавить строку в таблицу истории;
- обновить агрегат;
- отправить событие внутри базы.
Проще запомнить так:
| Тип |
Когда срабатывает |
Для чего чаще используют |
BEFORE |
До записи строки |
Изменить данные перед сохранением |
AFTER |
После изменения строки |
Записать историю или выполнить побочный эффект |
ROW и STATEMENT: сколько раз сработает триггер
У триггера есть ещё одна важная настройка:
FOR EACH ROW
или
FOR EACH STATEMENT
FOR EACH ROW означает: триггер выполнится для каждой затронутой строки.
Например:
UPDATE notes
SET title = 'Updated';
Если этот запрос обновил 1000 строк, row-level триггер сработает 1000 раз.
FOR EACH STATEMENT означает: триггер выполнится один раз на весь SQL-запрос.
Если тот же UPDATE обновил 1000 строк, statement-level триггер сработает один раз.
Сравнение:
| Гранулярность |
Сколько раз вызывается |
FOR EACH ROW |
Один раз на каждую строку |
FOR EACH STATEMENT |
Один раз на весь запрос |
Для updated_at, нормализации и аудита обычно используют FOR EACH ROW, потому что нужна работа с конкретной строкой.
NEW и OLD: новое и старое состояние строки
Внутри триггерной функции PostgreSQL даёт специальные переменные:
NEW
и
OLD
Они содержат состояние строки.
NEW — новая версия строки.
Она доступна в:
OLD — старая версия строки.
Она доступна в:
Пример для UPDATE.
Допустим, было:
| id |
title |
updated_at |
| 1 |
Old title |
2026-06-01 10:00:00 |
Мы выполняем:
UPDATE notes
SET title = 'New title'
WHERE id = 1;
Внутри BEFORE UPDATE-триггера:
OLD.title = 'Old title'
NEW.title = 'New title'
То есть OLD — как было до изменения, NEW — как станет после изменения.
Какие переменные доступны при INSERT, UPDATE и DELETE
Удобная шпаргалка:
| Операция |
OLD |
NEW |
INSERT |
Нет |
Есть |
UPDATE |
Есть |
Есть |
DELETE |
Есть |
Нет |
При INSERT старой строки ещё не было, поэтому OLD нет.
При DELETE новой строки уже не будет, поэтому NEW нет.
При UPDATE есть и старая, и новая версия строки.
Что нужно возвращать из триггерной функции
Функция триггера в PostgreSQL возвращает тип:
trigger
В BEFORE row-level триггерах возвращаемое значение важно.
Если вернуть NEW, операция продолжится с новой версией строки:
RETURN NEW;
Если вернуть OLD в BEFORE DELETE, удаление продолжится:
RETURN OLD;
Если вернуть NULL, операция для этой строки будет отменена.
Например, в BEFORE UPDATE можно вернуть NULL, и конкретная строка не обновится.
В AFTER-триггерах возвращаемое значение игнорируется. Часто пишут:
RETURN NEW;
или:
RETURN NULL;
Операцию это уже не изменит.
Главное правило:
Изменять NEW имеет смысл в BEFORE-триггере.
В AFTER-триггере строка уже записана, поэтому правки NEW не применятся.
BEFORE-триггер для нормализации email
Допустим, у нас есть таблица пользователей:
CREATE TABLE users (
id bigint PRIMARY KEY,
email text NOT NULL,
name text NOT NULL,
country text
);
Пользователь может ввести email так:
Bob@Example.COM
А мы хотим хранить его так:
bob@example.com
Создадим функцию:
CREATE FUNCTION normalize_user() RETURNS trigger AS $$
BEGIN
NEW.email := lower(trim(NEW.email));
IF NEW.country IS NULL THEN
NEW.country := 'US';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Теперь привяжем её к таблице:
CREATE TRIGGER users_normalize
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION normalize_user();
Что произойдёт при вставке?
INSERT INTO users (id, email, name)
VALUES (1, ' Bob@Example.COM ', 'Bob');
Перед сохранением строки PostgreSQL вызовет триггер.
Внутри функции:
NEW.email := lower(trim(NEW.email));
Email превратится в:
bob@example.com
А если country не передали, база поставит:
US
То есть триггер меняет строку до того, как она попадёт в таблицу.
Паттерн updated_at
Самый популярный триггер в PostgreSQL — автоматическое обновление updated_at.
Есть таблица:
CREATE TABLE notes (
id bigint PRIMARY KEY,
title text NOT NULL,
body text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Создаём универсальную функцию:
CREATE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Создаём триггер:
CREATE TRIGGER notes_touch_updated_at
BEFORE UPDATE ON notes
FOR EACH ROW
EXECUTE FUNCTION touch_updated_at();
Теперь любой UPDATE автоматически обновит updated_at.
Например:
UPDATE notes
SET title = 'New title'
WHERE id = 1;
Даже если в запросе нет updated_at, PostgreSQL сам его проставит.
Это хороший пример задачи, которую удобно держать в базе.
Почему updated_at делают через BEFORE
Для updated_at нужен именно BEFORE UPDATE.
Почему?
Потому что мы хотим изменить строку до записи.
В BEFORE-триггере можно сделать:
NEW.updated_at := now();
RETURN NEW;
И PostgreSQL запишет уже изменённую версию строки.
Если попытаться сделать то же самое в AFTER UPDATE, строка уже будет обновлена. Простое изменение NEW.updated_at ничего не даст.
Тогда пришлось бы делать дополнительный UPDATE внутри триггера, а это плохая идея: можно получить лишнюю запись, рекурсию и проблемы с производительностью.
Поэтому для автополей вроде updated_at почти всегда используют:
BEFORE UPDATE
FOR EACH ROW
Обновлять updated_at только при реальных изменениях
Иногда нужно, чтобы updated_at менялся только тогда, когда данные действительно изменились.
Потому что такой запрос технически является UPDATE, хотя значение осталось тем же:
UPDATE notes
SET title = title
WHERE id = 1;
Простой триггер всё равно обновит updated_at.
Если это нежелательно, можно добавить условие WHEN:
CREATE TRIGGER notes_touch_updated_at
BEFORE UPDATE ON notes
FOR EACH ROW
WHEN (OLD.* IS DISTINCT FROM NEW.*)
EXECUTE FUNCTION touch_updated_at();
IS DISTINCT FROM удобен тем, что корректно сравнивает значения с учётом NULL.
Но здесь важно не усложнять без необходимости. Во многих проектах любое выполнение UPDATE считается изменением строки, и простой вариант срабатывает нормально.
Аудит изменений через AFTER-триггер
Другая популярная задача — аудит.
Аудит отвечает на вопросы:
Кто изменил строку?
Когда изменил?
Что было до изменения?
Что стало после изменения?
Какая операция была выполнена: INSERT, UPDATE или DELETE?
Создадим таблицу заказов:
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
И таблицу аудита:
CREATE TABLE orders_audit (
id bigserial PRIMARY KEY,
order_id bigint,
action text NOT NULL,
old_row jsonb,
new_row jsonb,
changed_at timestamptz NOT NULL DEFAULT now()
);
Теперь создадим функцию:
CREATE FUNCTION log_order_change() RETURNS trigger AS $$
BEGIN
INSERT INTO orders_audit (order_id, action, old_row, new_row)
VALUES (
COALESCE(NEW.id, OLD.id),
TG_OP,
to_jsonb(OLD),
to_jsonb(NEW)
);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
И триггер:
CREATE TRIGGER orders_audit_trg
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW
EXECUTE FUNCTION log_order_change();
Теперь при каждом изменении заказа в orders_audit будет появляться запись.
Что такое TG_OP
Внутри триггерной функции PostgreSQL даёт специальные переменные. Одна из полезных — TG_OP.
Она показывает, какая операция вызвала триггер:
INSERT
UPDATE
DELETE
В нашем примере:
TG_OP
записывается в колонку action.
Поэтому одна функция может обрабатывать сразу три операции:
AFTER INSERT OR UPDATE OR DELETE
Для INSERT будет:
old_row = NULL
new_row = новая строка
Для UPDATE будет:
old_row = старая строка
new_row = новая строка
Для DELETE будет:
old_row = удалённая строка
new_row = NULL
Это удобно для истории изменений.
Почему аудит делают через AFTER
Аудит обычно делают через AFTER-триггер, потому что мы хотим записать историю после того, как основная операция прошла проверки.
Например, если UPDATE orders нарушает ограничение и не может быть выполнен, аудит тоже не должен писать «изменение произошло».
AFTER-триггер запускается после успешного изменения строки.
Но помним важный нюанс: транзакция ещё может откатиться. Если вся транзакция будет отменена, запись в аудит тоже откатится, потому что она сделана внутри той же транзакции.
Это чаще всего правильное поведение: если изменение не сохранилось, аудит внутри этой же базы тоже не сохраняется.
Как запретить операцию через триггер
Триггер может не только менять данные, но и запрещать операцию.
Например, запретим уменьшать сумму оплаченного заказа:
CREATE FUNCTION prevent_paid_order_amount_decrease() RETURNS trigger AS $$
BEGIN
IF OLD.status = 'paid'
AND NEW.amount < OLD.amount THEN
RAISE EXCEPTION 'Cannot decrease amount for paid order %', OLD.id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Создадим триггер:
CREATE TRIGGER orders_prevent_paid_amount_decrease
BEFORE UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION prevent_paid_order_amount_decrease();
Теперь такой запрос упадёт:
UPDATE orders
SET amount = amount - 100
WHERE id = 1;
если заказ уже был в статусе paid.
Но здесь стоит быть осторожным. Если правило можно выразить через CHECK, FOREIGN KEY, UNIQUE или другой обычный constraint, чаще лучше использовать constraint.
Триггер стоит оставлять для логики, которую обычным ограничением выразить сложно.
Когда триггеры полезны
Триггеры хорошо подходят для задач, которые должны выполняться всегда, независимо от клиента.
Хорошие примеры:
| Задача |
Почему триггер подходит |
Автоматически обновлять updated_at |
Простая служебная логика перед записью |
| Нормализовать email |
Значение нужно привести к единому виду перед сохранением |
| Писать аудит изменений |
Нужно сохранять OLD и NEW |
| Запрещать сложное изменение |
Правило сложно выразить обычным constraint |
| Вести историю версий строки |
Нужно реагировать на INSERT/UPDATE/DELETE |
Триггер особенно полезен там, где нельзя доверять только приложению.
Например, если данные меняют разные сервисы, скрипты и администраторы, правило в базе защищает лучше, чем правило только в одном бэкенде.
Когда триггеры вредят
Триггеры мощные, но у них есть цена.
Главная проблема — скрытая логика.
Разработчик видит запрос:
UPDATE orders
SET status = 'paid'
WHERE id = 1;
А на самом деле за этим могут сработать:
триггер аудита;
триггер обновления updated_at;
триггер пересчёта агрегатов;
триггер записи в историю;
триггер на другой таблице.
Если об этом не знать, отладка становится сложной.
Вторая проблема — производительность.
Row-level триггер срабатывает на каждую строку.
Если вы делаете:
UPDATE orders
SET status = 'archived'
WHERE created_at < '2025-01-01';
и запрос обновляет миллион строк, триггер выполнится миллион раз.
Если внутри триггера тяжёлая логика, массовая операция может стать очень медленной.
Третья проблема — каскады и циклы.
Триггер на таблице orders может обновить таблицу users.
А на users может быть свой триггер, который снова трогает orders.
Так легко получить рекурсию, неожиданные блокировки и очень неприятные баги.
Порядок выполнения нескольких триггеров
На одной таблице может быть несколько триггеров на одно событие.
Например:
BEFORE UPDATE trigger для updated_at
BEFORE UPDATE trigger для нормализации
BEFORE UPDATE trigger для проверки бизнес-правила
В PostgreSQL триггеры одного типа на одно событие выполняются по алфавиту имён.
Поэтому не стоит надеяться на «логичный» порядок.
Если порядок важен, называйте триггеры осознанно.
Например:
trg_10_normalize_user
trg_20_validate_user
trg_30_touch_updated_at
Так будущему разработчику будет проще понять, что должно выполняться раньше, а что позже.
Триггер или constraint?
Перед тем как писать триггер, полезно спросить себя:
Можно ли выразить это обычным ограничением базы?
Если можно — часто лучше выбрать constraint.
Например, сумма заказа должна быть больше нуля:
ALTER TABLE orders
ADD CONSTRAINT chk_orders_amount_positive
CHECK (amount > 0);
Это лучше, чем триггер, который вручную проверяет amount.
Email должен быть уникальным:
CREATE UNIQUE INDEX users_email_uniq
ON users (email);
Это лучше, чем триггер, который ищет дубликаты.
Внешний ключ должен существовать:
ALTER TABLE orders
ADD CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id);
Это лучше, чем триггер, который вручную проверяет наличие пользователя.
Триггер нужен, когда обычные ограничения уже не справляются.
Триггер или generated column?
Иногда вместо триггера лучше использовать генерируемый столбец.
Например, итог заказа считается по простой формуле:
total = amount * (1 + tax_rate)
Для этого можно использовать:
total numeric(12,2)
GENERATED ALWAYS AS (round(amount * (1 + tax_rate), 2))
STORED
Это проще и прозрачнее, чем триггер.
Но если значение зависит от другой таблицы, текущего времени или сложной логики, generated column уже не подойдёт. Тогда можно смотреть в сторону триггера.
Общее правило:
Простая формула по колонкам этой же строки — generated column.
Сложная логика при INSERT/UPDATE/DELETE — триггер.
MySQL: синтаксис другой
В MySQL триггеры устроены немного иначе.
Там обычно не создают отдельную функцию, как в PostgreSQL. Тело триггера пишется прямо в CREATE TRIGGER.
Пример для updated_at:
CREATE TRIGGER notes_touch_updated_at
BEFORE UPDATE ON notes
FOR EACH ROW
BEGIN
SET NEW.updated_at = NOW();
END;
В MySQL тоже есть NEW и OLD, но синтаксис присваивания другой:
SET NEW.updated_at = NOW();
А не:
NEW.updated_at := now();
Также в MySQL нет триггеров уровня FOR EACH STATEMENT. Триггеры работают построчно — FOR EACH ROW.
Идея похожая, но детали отличаются.
ClickHouse: классических DML-триггеров нет
В ClickHouse нет классических триггеров на INSERT, UPDATE и DELETE в стиле PostgreSQL или MySQL.
Это связано с тем, что ClickHouse — аналитическая колоночная база, а не классическая OLTP-база для частых построчных изменений.
Похожие задачи там часто решают другими инструментами:
- материализованными представлениями;
- отдельными таблицами событий;
- пайплайнами загрузки данных;
- обработкой данных до вставки;
- фоновыми процессами.
Например, материализованное представление может реагировать на вставку данных и перекладывать или агрегировать их в другую таблицу.
Но это не то же самое, что обычный row-level триггер в PostgreSQL.
Короткая шпаргалка
Создать функцию для updated_at:
CREATE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Создать триггер:
CREATE TRIGGER notes_touch_updated_at
BEFORE UPDATE ON notes
FOR EACH ROW
EXECUTE FUNCTION touch_updated_at();
Нормализовать email:
CREATE FUNCTION normalize_user() RETURNS trigger AS $$
BEGIN
NEW.email := lower(trim(NEW.email));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Аудит изменений:
CREATE TRIGGER orders_audit_trg
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW
EXECUTE FUNCTION log_order_change();
Доступность NEW и OLD:
| Операция |
OLD |
NEW |
INSERT |
Нет |
Есть |
UPDATE |
Есть |
Есть |
DELETE |
Есть |
Нет |
Когда использовать:
| Нужно |
Лучше выбрать |
| Изменить строку перед записью |
BEFORE FOR EACH ROW |
Проставить updated_at |
BEFORE UPDATE FOR EACH ROW |
| Записать аудит |
AFTER FOR EACH ROW |
| Проверить простое правило |
CHECK, UNIQUE, FOREIGN KEY |
| Сложная логика при изменении данных |
Триггер |
Главное, что нужно запомнить
Триггер — это автоматическая реакция базы на изменение данных.
Он может сработать при:
INSERT
UPDATE
DELETE
и выполнить нужную функцию.
Для новичка главное запомнить три вещи.
Первая:
BEFORE — до записи, можно изменить NEW.
AFTER — после изменения строки, удобно писать аудит и историю.
Вторая:
NEW — новая версия строки.
OLD — старая версия строки.
Третья:
FOR EACH ROW — на каждую строку.
FOR EACH STATEMENT — один раз на весь запрос.
Самый практичный пример — автоматическое поле updated_at:
CREATE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER notes_touch_updated_at
BEFORE UPDATE ON notes
FOR EACH ROW
EXECUTE FUNCTION touch_updated_at();
Это снимает ответственность с приложения и делает поведение одинаковым для всех клиентов базы.
Но триггеры не стоит превращать в склад всей бизнес-логики.
Хороший триггер — простой, понятный и предсказуемый.
Плохой триггер — тот, о котором все забыли, но он продолжает незаметно менять данные, тормозить массовые операции и удивлять разработчиков на проде.
Используйте триггеры там, где они действительно усиливают целостность данных, а не прячут важную логику от команды.
Триггер — это код, который база данных запускает автоматически, когда с таблицей происходит определённое действие.
Например:
И база сама вызывает нужную функцию.
На практике триггеры используют, когда нужно выполнить служебную логику прямо на уровне базы:
updated_at;Главная идея такая:
Это его сильная сторона. Но одновременно и опасность: логика становится скрытой. Человек смотрит на обычный
UPDATE, а за ним в базе может выполняться ещё несколько действий.Поэтому триггеры нужно использовать аккуратно: для понятных, предсказуемых и действительно нужных задач.
Простой пример из жизни
Допустим, у нас есть таблица заметок:
CREATE TABLE notes ( id bigint PRIMARY KEY, title text NOT NULL, body text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() );Мы хотим, чтобы поле
updated_atобновлялось каждый раз, когда заметку меняют.Можно каждый раз писать в приложении:
UPDATE notes SET title = 'New title', updated_at = now() WHERE id = 1;Но это легко забыть.
Где-то разработчик обновит
title, но забудетupdated_at.Где-то скрипт поправит
body, но тоже не обновит дату.Где-то тестировщик руками выполнит
UPDATEв базе.В итоге поле
updated_atперестанет быть честным.Триггер решает это так: база сама проставляет
updated_atперед каждым обновлением строки.В PostgreSQL триггер состоит из двух частей
В PostgreSQL триггер обычно состоит из двух объектов:
Сначала создаём функцию:
CREATE FUNCTION touch_updated_at() RETURNS trigger AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END; $$ LANGUAGE plpgsql;Потом создаём триггер:
CREATE TRIGGER notes_touch_updated_at BEFORE UPDATE ON notes FOR EACH ROW EXECUTE FUNCTION touch_updated_at();Теперь при каждом
UPDATEтаблицыnotesPostgreSQL сам вызовет функциюtouch_updated_at().Приложению больше не нужно помнить про
updated_at.BEFORE и AFTER: когда срабатывает триггер
У триггера есть момент срабатывания.
Самые частые варианты:
и
BEFOREозначает: триггер сработает до того, как строка будет записана в таблицу.Это удобно, когда нужно изменить данные перед записью.
Например:
updated_at;AFTERозначает: триггер сработает после того, как действие со строкой уже выполнено.Важно:
AFTER— это не значит «после коммита транзакции». Транзакция всё ещё может откатиться. Но сама операция над строкой уже произошла.AFTERудобно использовать для побочных действий:Проще запомнить так:
BEFOREAFTERROW и STATEMENT: сколько раз сработает триггер
У триггера есть ещё одна важная настройка:
FOR EACH ROWили
FOR EACH STATEMENTFOR EACH ROWозначает: триггер выполнится для каждой затронутой строки.Например:
UPDATE notes SET title = 'Updated';Если этот запрос обновил 1000 строк, row-level триггер сработает 1000 раз.
FOR EACH STATEMENTозначает: триггер выполнится один раз на весь SQL-запрос.Если тот же
UPDATEобновил 1000 строк, statement-level триггер сработает один раз.Сравнение:
FOR EACH ROWFOR EACH STATEMENTДля
updated_at, нормализации и аудита обычно используютFOR EACH ROW, потому что нужна работа с конкретной строкой.NEW и OLD: новое и старое состояние строки
Внутри триггерной функции PostgreSQL даёт специальные переменные:
NEWи
OLDОни содержат состояние строки.
NEW— новая версия строки.Она доступна в:
INSERT;UPDATE.OLD— старая версия строки.Она доступна в:
UPDATE;DELETE.Пример для
UPDATE.Допустим, было:
Old title2026-06-01 10:00:00Мы выполняем:
UPDATE notes SET title = 'New title' WHERE id = 1;Внутри
BEFORE UPDATE-триггера:То есть
OLD— как было до изменения,NEW— как станет после изменения.Какие переменные доступны при INSERT, UPDATE и DELETE
Удобная шпаргалка:
OLDNEWINSERTUPDATEDELETEПри
INSERTстарой строки ещё не было, поэтомуOLDнет.При
DELETEновой строки уже не будет, поэтомуNEWнет.При
UPDATEесть и старая, и новая версия строки.Что нужно возвращать из триггерной функции
Функция триггера в PostgreSQL возвращает тип:
triggerВ
BEFORErow-level триггерах возвращаемое значение важно.Если вернуть
NEW, операция продолжится с новой версией строки:RETURN NEW;Если вернуть
OLDвBEFORE DELETE, удаление продолжится:RETURN OLD;Если вернуть
NULL, операция для этой строки будет отменена.Например, в
BEFORE UPDATEможно вернутьNULL, и конкретная строка не обновится.В
AFTER-триггерах возвращаемое значение игнорируется. Часто пишут:RETURN NEW;или:
RETURN NULL;Операцию это уже не изменит.
Главное правило:
BEFORE-триггер для нормализации email
Допустим, у нас есть таблица пользователей:
CREATE TABLE users ( id bigint PRIMARY KEY, email text NOT NULL, name text NOT NULL, country text );Пользователь может ввести email так:
А мы хотим хранить его так:
Создадим функцию:
CREATE FUNCTION normalize_user() RETURNS trigger AS $$ BEGIN NEW.email := lower(trim(NEW.email)); IF NEW.country IS NULL THEN NEW.country := 'US'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql;Теперь привяжем её к таблице:
CREATE TRIGGER users_normalize BEFORE INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION normalize_user();Что произойдёт при вставке?
INSERT INTO users (id, email, name) VALUES (1, ' Bob@Example.COM ', 'Bob');Перед сохранением строки PostgreSQL вызовет триггер.
Внутри функции:
NEW.email := lower(trim(NEW.email));Email превратится в:
А если
countryне передали, база поставит:То есть триггер меняет строку до того, как она попадёт в таблицу.
Паттерн updated_at
Самый популярный триггер в PostgreSQL — автоматическое обновление
updated_at.Есть таблица:
CREATE TABLE notes ( id bigint PRIMARY KEY, title text NOT NULL, body text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() );Создаём универсальную функцию:
CREATE FUNCTION touch_updated_at() RETURNS trigger AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END; $$ LANGUAGE plpgsql;Создаём триггер:
CREATE TRIGGER notes_touch_updated_at BEFORE UPDATE ON notes FOR EACH ROW EXECUTE FUNCTION touch_updated_at();Теперь любой
UPDATEавтоматически обновитupdated_at.Например:
UPDATE notes SET title = 'New title' WHERE id = 1;Даже если в запросе нет
updated_at, PostgreSQL сам его проставит.Это хороший пример задачи, которую удобно держать в базе.
Почему updated_at делают через BEFORE
Для
updated_atнужен именноBEFORE UPDATE.Почему?
Потому что мы хотим изменить строку до записи.
В
BEFORE-триггере можно сделать:NEW.updated_at := now(); RETURN NEW;И PostgreSQL запишет уже изменённую версию строки.
Если попытаться сделать то же самое в
AFTER UPDATE, строка уже будет обновлена. Простое изменениеNEW.updated_atничего не даст.Тогда пришлось бы делать дополнительный
UPDATEвнутри триггера, а это плохая идея: можно получить лишнюю запись, рекурсию и проблемы с производительностью.Поэтому для автополей вроде
updated_atпочти всегда используют:BEFORE UPDATE FOR EACH ROWОбновлять updated_at только при реальных изменениях
Иногда нужно, чтобы
updated_atменялся только тогда, когда данные действительно изменились.Потому что такой запрос технически является
UPDATE, хотя значение осталось тем же:UPDATE notes SET title = title WHERE id = 1;Простой триггер всё равно обновит
updated_at.Если это нежелательно, можно добавить условие
WHEN:CREATE TRIGGER notes_touch_updated_at BEFORE UPDATE ON notes FOR EACH ROW WHEN (OLD.* IS DISTINCT FROM NEW.*) EXECUTE FUNCTION touch_updated_at();IS DISTINCT FROMудобен тем, что корректно сравнивает значения с учётомNULL.Но здесь важно не усложнять без необходимости. Во многих проектах любое выполнение
UPDATEсчитается изменением строки, и простой вариант срабатывает нормально.Аудит изменений через AFTER-триггер
Другая популярная задача — аудит.
Аудит отвечает на вопросы:
Создадим таблицу заказов:
CREATE TABLE orders ( id bigint PRIMARY KEY, user_id bigint NOT NULL, amount numeric(12,2) NOT NULL, status text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() );И таблицу аудита:
CREATE TABLE orders_audit ( id bigserial PRIMARY KEY, order_id bigint, action text NOT NULL, old_row jsonb, new_row jsonb, changed_at timestamptz NOT NULL DEFAULT now() );Теперь создадим функцию:
CREATE FUNCTION log_order_change() RETURNS trigger AS $$ BEGIN INSERT INTO orders_audit (order_id, action, old_row, new_row) VALUES ( COALESCE(NEW.id, OLD.id), TG_OP, to_jsonb(OLD), to_jsonb(NEW) ); RETURN NULL; END; $$ LANGUAGE plpgsql;И триггер:
CREATE TRIGGER orders_audit_trg AFTER INSERT OR UPDATE OR DELETE ON orders FOR EACH ROW EXECUTE FUNCTION log_order_change();Теперь при каждом изменении заказа в
orders_auditбудет появляться запись.Что такое TG_OP
Внутри триггерной функции PostgreSQL даёт специальные переменные. Одна из полезных —
TG_OP.Она показывает, какая операция вызвала триггер:
В нашем примере:
записывается в колонку
action.Поэтому одна функция может обрабатывать сразу три операции:
AFTER INSERT OR UPDATE OR DELETEДля
INSERTбудет:Для
UPDATEбудет:Для
DELETEбудет:Это удобно для истории изменений.
Почему аудит делают через AFTER
Аудит обычно делают через
AFTER-триггер, потому что мы хотим записать историю после того, как основная операция прошла проверки.Например, если
UPDATE ordersнарушает ограничение и не может быть выполнен, аудит тоже не должен писать «изменение произошло».AFTER-триггер запускается после успешного изменения строки.Но помним важный нюанс: транзакция ещё может откатиться. Если вся транзакция будет отменена, запись в аудит тоже откатится, потому что она сделана внутри той же транзакции.
Это чаще всего правильное поведение: если изменение не сохранилось, аудит внутри этой же базы тоже не сохраняется.
Как запретить операцию через триггер
Триггер может не только менять данные, но и запрещать операцию.
Например, запретим уменьшать сумму оплаченного заказа:
CREATE FUNCTION prevent_paid_order_amount_decrease() RETURNS trigger AS $$ BEGIN IF OLD.status = 'paid' AND NEW.amount < OLD.amount THEN RAISE EXCEPTION 'Cannot decrease amount for paid order %', OLD.id; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql;Создадим триггер:
CREATE TRIGGER orders_prevent_paid_amount_decrease BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION prevent_paid_order_amount_decrease();Теперь такой запрос упадёт:
UPDATE orders SET amount = amount - 100 WHERE id = 1;если заказ уже был в статусе
paid.Но здесь стоит быть осторожным. Если правило можно выразить через
CHECK,FOREIGN KEY,UNIQUEили другой обычный constraint, чаще лучше использовать constraint.Триггер стоит оставлять для логики, которую обычным ограничением выразить сложно.
Когда триггеры полезны
Триггеры хорошо подходят для задач, которые должны выполняться всегда, независимо от клиента.
Хорошие примеры:
updated_atOLDиNEWINSERT/UPDATE/DELETEТриггер особенно полезен там, где нельзя доверять только приложению.
Например, если данные меняют разные сервисы, скрипты и администраторы, правило в базе защищает лучше, чем правило только в одном бэкенде.
Когда триггеры вредят
Триггеры мощные, но у них есть цена.
Главная проблема — скрытая логика.
Разработчик видит запрос:
UPDATE orders SET status = 'paid' WHERE id = 1;А на самом деле за этим могут сработать:
Если об этом не знать, отладка становится сложной.
Вторая проблема — производительность.
Row-level триггер срабатывает на каждую строку.
Если вы делаете:
UPDATE orders SET status = 'archived' WHERE created_at < '2025-01-01';и запрос обновляет миллион строк, триггер выполнится миллион раз.
Если внутри триггера тяжёлая логика, массовая операция может стать очень медленной.
Третья проблема — каскады и циклы.
Триггер на таблице
ordersможет обновить таблицуusers.А на
usersможет быть свой триггер, который снова трогаетorders.Так легко получить рекурсию, неожиданные блокировки и очень неприятные баги.
Порядок выполнения нескольких триггеров
На одной таблице может быть несколько триггеров на одно событие.
Например:
В PostgreSQL триггеры одного типа на одно событие выполняются по алфавиту имён.
Поэтому не стоит надеяться на «логичный» порядок.
Если порядок важен, называйте триггеры осознанно.
Например:
Так будущему разработчику будет проще понять, что должно выполняться раньше, а что позже.
Триггер или constraint?
Перед тем как писать триггер, полезно спросить себя:
Если можно — часто лучше выбрать constraint.
Например, сумма заказа должна быть больше нуля:
ALTER TABLE orders ADD CONSTRAINT chk_orders_amount_positive CHECK (amount > 0);Это лучше, чем триггер, который вручную проверяет
amount.Email должен быть уникальным:
CREATE UNIQUE INDEX users_email_uniq ON users (email);Это лучше, чем триггер, который ищет дубликаты.
Внешний ключ должен существовать:
ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id);Это лучше, чем триггер, который вручную проверяет наличие пользователя.
Триггер нужен, когда обычные ограничения уже не справляются.
Триггер или generated column?
Иногда вместо триггера лучше использовать генерируемый столбец.
Например, итог заказа считается по простой формуле:
Для этого можно использовать:
total numeric(12,2) GENERATED ALWAYS AS (round(amount * (1 + tax_rate), 2)) STOREDЭто проще и прозрачнее, чем триггер.
Но если значение зависит от другой таблицы, текущего времени или сложной логики, generated column уже не подойдёт. Тогда можно смотреть в сторону триггера.
Общее правило:
MySQL: синтаксис другой
В MySQL триггеры устроены немного иначе.
Там обычно не создают отдельную функцию, как в PostgreSQL. Тело триггера пишется прямо в
CREATE TRIGGER.Пример для
updated_at:CREATE TRIGGER notes_touch_updated_at BEFORE UPDATE ON notes FOR EACH ROW BEGIN SET NEW.updated_at = NOW(); END;В MySQL тоже есть
NEWиOLD, но синтаксис присваивания другой:SET NEW.updated_at = NOW();А не:
NEW.updated_at := now();Также в MySQL нет триггеров уровня
FOR EACH STATEMENT. Триггеры работают построчно —FOR EACH ROW.Идея похожая, но детали отличаются.
ClickHouse: классических DML-триггеров нет
В ClickHouse нет классических триггеров на
INSERT,UPDATEиDELETEв стиле PostgreSQL или MySQL.Это связано с тем, что ClickHouse — аналитическая колоночная база, а не классическая OLTP-база для частых построчных изменений.
Похожие задачи там часто решают другими инструментами:
Например, материализованное представление может реагировать на вставку данных и перекладывать или агрегировать их в другую таблицу.
Но это не то же самое, что обычный row-level триггер в PostgreSQL.
Короткая шпаргалка
Создать функцию для
updated_at:CREATE FUNCTION touch_updated_at() RETURNS trigger AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END; $$ LANGUAGE plpgsql;Создать триггер:
CREATE TRIGGER notes_touch_updated_at BEFORE UPDATE ON notes FOR EACH ROW EXECUTE FUNCTION touch_updated_at();Нормализовать email:
CREATE FUNCTION normalize_user() RETURNS trigger AS $$ BEGIN NEW.email := lower(trim(NEW.email)); RETURN NEW; END; $$ LANGUAGE plpgsql;Аудит изменений:
CREATE TRIGGER orders_audit_trg AFTER INSERT OR UPDATE OR DELETE ON orders FOR EACH ROW EXECUTE FUNCTION log_order_change();Доступность
NEWиOLD:OLDNEWINSERTUPDATEDELETEКогда использовать:
BEFORE FOR EACH ROWupdated_atBEFORE UPDATE FOR EACH ROWAFTER FOR EACH ROWCHECK,UNIQUE,FOREIGN KEYГлавное, что нужно запомнить
Триггер — это автоматическая реакция базы на изменение данных.
Он может сработать при:
и выполнить нужную функцию.
Для новичка главное запомнить три вещи.
Первая:
Вторая:
Третья:
Самый практичный пример — автоматическое поле
updated_at:CREATE FUNCTION touch_updated_at() RETURNS trigger AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER notes_touch_updated_at BEFORE UPDATE ON notes FOR EACH ROW EXECUTE FUNCTION touch_updated_at();Это снимает ответственность с приложения и делает поведение одинаковым для всех клиентов базы.
Но триггеры не стоит превращать в склад всей бизнес-логики.
Хороший триггер — простой, понятный и предсказуемый.
Плохой триггер — тот, о котором все забыли, но он продолжает незаметно менять данные, тормозить массовые операции и удивлять разработчиков на проде.
Используйте триггеры там, где они действительно усиливают целостность данных, а не прячут важную логику от команды.