sqlpostgresqltriggersaudit

SQL Triggers: BEFORE/AFTER, NEW/OLD, and the updated_at Pattern

How BEFORE/AFTER and ROW/STATEMENT triggers work, what the function returns, the auto-updated_at pattern, audit logging, and when triggers hurt.

10 min lukuaikaReferencesql · postgresql · triggers · audit · mysql
Tämä artikkeli on tällä hetkellä venäjäksi — englanninkielinen käännös on työn alla.

Триггер — это код, который база данных запускает автоматически, когда с таблицей происходит определённое действие.

Например:

Кто-то вставил строку.
Кто-то обновил строку.
Кто-то удалил строку.

И база сама вызывает нужную функцию.

На практике триггеры используют, когда нужно выполнить служебную логику прямо на уровне базы:

  • автоматически обновить поле 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 триггер обычно состоит из двух объектов:

  1. Функция, которую нужно выполнить.
  2. Сам триггер, который привязывает эту функцию к таблице и событию.

Сначала создаём функцию:

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 — новая версия строки.

Она доступна в:

  • INSERT;
  • UPDATE.

OLD — старая версия строки.

Она доступна в:

  • UPDATE;
  • DELETE.

Пример для 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();

Это снимает ответственность с приложения и делает поведение одинаковым для всех клиентов базы.

Но триггеры не стоит превращать в склад всей бизнес-логики.

Хороший триггер — простой, понятный и предсказуемый.

Плохой триггер — тот, о котором все забыли, но он продолжает незаметно менять данные, тормозить массовые операции и удивлять разработчиков на проде.

Используйте триггеры там, где они действительно усиливают целостность данных, а не прячут важную логику от команды.

Harjoittele oikeilla tehtävillä

Ratkaise tehtäviä SQL-harjoittelussa välittömällä arvioinnilla ja vihjeillä.

Avaa harjoittelu