Зачем нужны generated columns
Часто значение колонки — это не самостоятельный факт, а функция от других колонок этой же строки. Полное имя = first_name || ' ' || last_name. Цена в долларах = price_cents / 100.0. Сумма позиции заказа = qty * unit_price_cents. Поисковой вектор = to_tsvector('russian', title || ' ' || description).
До Postgres 12 такие штуки делали тремя способами:
- В каждом SELECT писали выражение руками. DRY-нарушение, легко забыть.
- Оборачивали в VIEW. Не индексируется по выражению напрямую (нужны functional indexes).
- Заводили обычную колонку + триггер
BEFORE INSERT/UPDATE, который пересчитывает выражение. Работает, но триггер — это код, который надо поддерживать, тестировать и мигрировать.
С Postgres 12 появился стандарт SQL —
CREATE TABLE products (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
price_cents INT NOT NULL,
price_dollars NUMERIC(10, 2) GENERATED ALWAYS AS (price_cents / 100.0) STORED
);
Postgres сам пересчитает price_dollars на каждом INSERT/UPDATE. Вручную в эту колонку записать нельзя — пробуют — получаешь ошибку.
STORED vs VIRTUAL
Стандарт SQL различает два режима:
- STORED — значение материализуется на диск, занимает место, доступно для индексов как обычная колонка.
- VIRTUAL — значение вычисляется на лету при чтении, места не занимает, на индексах своя кухня.
Postgres до версии 17 поддерживал только STORED. В Postgres 18 (2026) появился VIRTUAL, но в большинстве production-инсталляций сейчас актуален именно STORED. Так что когда в Postgres-учебниках говорят «generated column» — почти всегда имеют в виду stored.
STORED-колонка вычисляется при записи, лежит на диске как обычные данные, индексируется как обычные данные. VIRTUAL — это «отложенная макроподстановка».
Простой пример: full_name и line_total
Generated columns для составного имени и суммы строки заказа
Заметьте: мы нигде не указали full_name и line_total_cents в INSERT — Postgres их посчитал сам. Если попробовать указать руками — будет ошибка:
Generated column нельзя вставить руками
Ограничения на выражение
Не любое выражение разрешено. Postgres требует, чтобы оно было:
- Детерминированным: одинаковый ввод → одинаковый вывод. Никаких
now(),random(),current_user. - Immutable: помечено в каталоге как
IMMUTABLE(если это user-defined функция). - Только из колонок этой же строки: нельзя обращаться к другим таблицам, к другим строкам, к подзапросам.
Эти ограничения — следствие STORED: значение записывается один раз и больше не пересчитывается, пока строка не изменится. Если бы выражение зависело от внешних факторов — данные «застревали» бы.
Если выражение временно: «сколько дней с создания записи» — generated column не подойдёт; вычисляйте на чтении.
Use case 1: денормализованные totals
Классика: позиции заказа хранят qty и unit_price_cents, а сумма строки нужна постоянно. Можно считать её на лету в каждом SELECT, но это:
- засоряет код;
- мешает индексам (нельзя индексировать
qty * unit_price_centsбез functional index); - невыгодно при больших таблицах с фильтрами по сумме.
Generated column решает всё это разом:
ALTER TABLE order_items
ADD COLUMN line_total_cents BIGINT
GENERATED ALWAYS AS (qty::BIGINT * unit_price_cents) STORED;
CREATE INDEX ON order_items (line_total_cents);
Теперь можно искать «позиции дороже 100k» через индекс. И ни одна строка не «забудет» посчитать сумму — Postgres гарантирует.
Use case 2: search vectors
Полнотекстовый поиск в Postgres строится на tsvector — специальном типе, который хранит нормализованные «токены». Чтобы искать по title и description, обычно делают:
ALTER TABLE articles ADD COLUMN search_tsv tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
setweight(to_tsvector('russian', coalesce(body, '')), 'B')
) STORED;
CREATE INDEX articles_search_idx ON articles USING GIN (search_tsv);
Каждое изменение title или body автоматически обновляет search_tsv. До generated columns на это уходили триггеры на 30 строк; теперь — одна строка в ALTER TABLE.
Use case 3: регистронезависимый поиск
Postgres-индексы по умолчанию регистрозависимы. Запрос WHERE email = '[email protected]' с email = [email protected] в базе вернёт пусто. Классическое решение — LOWER(email) = LOWER(...), но это требует functional index и аккуратности в коде приложения.
Generated column решает проще: храним нижний регистр как отдельную колонку.
ALTER TABLE customers ADD COLUMN email_lower TEXT
GENERATED ALWAYS AS (lower(email)) STORED;
CREATE UNIQUE INDEX customers_email_lower_idx ON customers (email_lower);
-- Теперь регистронезависимый поиск:
SELECT * FROM customers WHERE email_lower = lower('[email protected]');
И ещё бонус: уникальность по email_lower означает, что мы не сможем зарегистрировать одного и того же человека дважды с разным регистром в email. Это часто хочется бизнесу.
Generated column для регистронезависимого UNIQUE-ограничения
Generated column vs триггер: когда что
Generated columns не покрывают всё, что умеют триггеры. Сравнение:
| Generated column | Trigger BEFORE INSERT/UPDATE | |
|---|---|---|
| Декларативно? | да, в CREATE TABLE | нет, отдельный код в plpgsql |
Может использовать now(), другие таблицы? | нет | да |
| Может обновляться независимо от других колонок? | нет | да |
| Тестировать и мигрировать | легко | сложнее (отдельные объекты) |
| Производительность | выше (нет вызова функции) | ниже |
| Доступно с | Postgres 12 | всегда |
Правило: если можно generated column — берите его. Триггер оставьте для случаев, где правда нужна динамика (зависимость от других таблиц, от текущего времени, от пользователя).
Ограничения как часть схемы Как STORED-колонки хранятся на дискеЧек-лист
GENERATED ALWAYS AS (...) STORED— декларативный способ вычисляемой колонки в Postgres 12+.- Значение материализуется при
INSERT/UPDATE, хранится на диске как обычная колонка, индексируется обычными индексами. - Выражение должно быть детерминированным и зависеть только от колонок этой же строки. Никаких
now(), других таблиц, подзапросов. - Вручную вставить значение в generated column нельзя —
INSERT ... VALUES (..., ..., 'мой_total')упадёт с ошибкой. - Use cases: денормализованные totals (
qty * price), нормализованные имена (first || ' ' || last), search vectors (to_tsvector(...)), нижний регистр для регистронезависимого поиска. - Триггер
BEFORE INSERT/UPDATE— fallback, когда generated column недостаточно (нужныnow(), другие таблицы, динамика).