Learning Platform
Урок 12.06 · 16 мин
Средний
GENERATED columnSTOREDVIRTUALComputed columnTriggerstsvector

Зачем нужны generated columns

Часто значение колонки — это не самостоятельный факт, а функция от других колонок этой же строки. Полное имя = first_name || ' ' || last_name. Цена в долларах = price_cents / 100.0. Сумма позиции заказа = qty * unit_price_cents. Поисковой вектор = to_tsvector('russian', title || ' ' || description).

До Postgres 12 такие штуки делали тремя способами:

  1. В каждом SELECT писали выражение руками. DRY-нарушение, легко забыть.
  2. Оборачивали в VIEW. Не индексируется по выражению напрямую (нужны functional indexes).
  3. Заводили обычную колонку + триггер BEFORE INSERT/UPDATE, который пересчитывает выражение. Работает, но триггер — это код, который надо поддерживать, тестировать и мигрировать.

С Postgres 12 появился стандарт SQL —

generated columns
. Декларативно, на уровне схемы:

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.

Generated column: что под капотом

STORED-колонка вычисляется при записи, лежит на диске как обычные данные, индексируется как обычные данные. VIRTUAL — это «отложенная макроподстановка».

STORED (Postgres 12+)value сохраняется в heap
когда вычисляетсяна INSERT и UPDATE
место на дискекак у обычной колонки
индексыобычные, по колонке
чтениемгновенное — уже посчитано
VIRTUAL (Postgres 18+, MySQL)не хранится
когда вычисляетсякаждый раз при SELECT
место на дискеноль
индексынужен functional index
чтениезависит от стоимости выражения

Простой пример: full_name и line_total

Generated columns для составного имени и суммы строки заказа

PostgreSQL

Заметьте: мы нигде не указали full_name и line_total_cents в INSERT — Postgres их посчитал сам. Если попробовать указать руками — будет ошибка:

Generated column нельзя вставить руками

PostgreSQL

Ограничения на выражение

Не любое выражение разрешено. 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-ограничения

PostgreSQL

Generated column vs триггер: когда что

Generated columns не покрывают всё, что умеют триггеры. Сравнение:

Generated columnTrigger 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(), другие таблицы, динамика).
Проверка знанийKnowledge check
У вас products (id, sku, name, price_cents). Поиск по prefix sku работает регистрозависимо — пользователи жалуются. Как сделать регистронезависимый поиск без переписывания приложения и без потери производительности?
ОтветAnswer
Самый чистый путь — generated column + индекс. ALTER TABLE products ADD COLUMN sku_lower TEXT GENERATED ALWAYS AS (lower(sku)) STORED; CREATE INDEX ON products (sku_lower text_pattern_ops); Теперь приложение пишет тот же запрос, только меняет WHERE sku LIKE 'LP-%' на WHERE sku_lower LIKE 'lp-%' — индекс по sku_lower работает. Альтернатива — functional index CREATE INDEX ON products (lower(sku)) — но тогда запрос должен буквально содержать lower(sku), что менее красиво в коде приложения. С generated column колонка «реальная», и API запросов выглядит обычно.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Что произойдёт при INSERT INTO t (a, b, computed) VALUES (1, 2, 999), если computed объявлена как GENERATED ALWAYS AS (a + b) STORED?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6