Generated Columns sind Spalten, deren Wert sich aus anderen Spalten der selben Zeile berechnet. Postgres pflegt sie automatisch — kein Trigger nötig. STORED (PG 12+) speichert den Wert in der Tabelle, VIRTUAL (PG 18+) berechnet ihn zur Lesezeit. Beide ersetzen einen Großteil der klassischen Trigger-Use-Cases.
STORED — auf Disk gespeichert
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
price numeric(10,2) NOT NULL,
tax_rate numeric(4,2) NOT NULL DEFAULT 0.19,
price_with_tax numeric(10,2)
GENERATED ALWAYS AS (price * (1 + tax_rate)) STORED
);
INSERT INTO products (name, price) VALUES ('Widget', 100.00);
SELECT name, price, tax_rate, price_with_tax FROM products;Output:
name | price | tax_rate | price_with_tax
--------+--------+----------+----------------
Widget | 100.00 | 0.19 | 119.00price_with_tax wird von Postgres automatisch berechnet — bei jedem INSERT/UPDATE neu, wenn price oder tax_rate sich ändern.
GENERATED ALWAYS heißt: man kann den Wert nicht manuell setzen — INSERT ... VALUES (..., ..., 999) für die Generated Column scheitert.
Use-Cases für STORED
Vollständiger Name
CREATE TABLE persons (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
firstname text NOT NULL,
lastname text NOT NULL,
fullname text GENERATED ALWAYS AS (firstname || ' ' || lastname) STORED
);Klassiker — kann indiziert werden, ohne Index-on-Expression.
Tsvector für Volltext-Suche
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
search tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('german', title), 'A') ||
setweight(to_tsvector('german', body), 'B')
) STORED
);
CREATE INDEX articles_search_idx ON articles USING gin (search);Vor PG 12 brauchte man dafür Trigger oder einen Expression-Index. Heute eleganter mit Generated Column + GIN-Index.
Normalisierte Spalten für Suche
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
email_lower text GENERATED ALWAYS AS (lower(email)) STORED
);
CREATE UNIQUE INDEX customers_email_lower_idx
ON customers (email_lower);Eindeutigkeit case-insensitive, ohne Expression-Index nutzen zu müssen.
Einschränkungen für Generated-Ausdrücke
Der Ausdruck muss immutable sein — also für die selben Eingabewerte immer das selbe Resultat liefern.
-- now(): VOLATILE → verboten
created_age int GENERATED ALWAYS AS (
EXTRACT(EPOCH FROM now() - created_at)
) STORED;
-- ERROR: generation expression is not immutable
-- Zugriff auf andere Tabellen: verboten
customer_name text GENERATED ALWAYS AS (
(SELECT name FROM customers WHERE id = customer_id)
) STORED;
-- ERROR: cannot use subquery in generation expression
-- Verweis auf andere Generated Columns: verboten
col_b text GENERATED ALWAYS AS ('B') STORED,
col_c text GENERATED ALWAYS AS (col_b || 'C') STORED
-- ERROR: cannot use generated column "col_b" in column generation expressionFaustregel: nur Spalten der selben Zeile, immutable Funktionen, einfache Expressions. Komplexere Logik weiterhin über Trigger.
VIRTUAL ab PG 18
Seit PostgreSQL 18: VIRTUAL als Alternative zu STORED.
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
price numeric(10,2) NOT NULL,
tax_rate numeric(4,2) NOT NULL DEFAULT 0.19,
price_with_tax numeric(10,2)
GENERATED ALWAYS AS (price * (1 + tax_rate)) VIRTUAL
);Unterschied:
| STORED | VIRTUAL | |
|---|---|---|
| Speicherung | Auf Disk | Nicht |
| Auswertung | Beim Schreiben | Beim Lesen |
| Storage-Kosten | Voll | Null |
| Read-Performance | Lesen direkt | Berechnung pro Read |
| Indizierbar | Ja | Eingeschränkt (PG 18) |
| Verfügbar seit | PG 12 | PG 18 |
VIRTUAL ist dann sinnvoll, wenn die Berechnung billig ist und die Spalte selten gelesen wird — kein verschwendeter Speicher. Seit PG 18 ist VIRTUAL der Default, wenn weder STORED noch VIRTUAL angegeben ist.
Indexing
STORED — vollwertig indizierbar
CREATE INDEX products_price_with_tax_idx ON products (price_with_tax);Wie ein normaler Index. Schnelle Range-Queries auf der berechneten Spalte.
VIRTUAL — nur Expression-Index als Alternative
VIRTUAL-Spalten lassen sich in PG 18 noch nicht direkt indizieren. Wer einen Index braucht: Expression-Index auf den zugrundeliegenden Ausdruck:
CREATE INDEX products_price_with_tax_idx
ON products ((price * (1 + tax_rate)));Generated vs. Trigger
| Anforderung | Lösung |
|---|---|
| Berechnung aus selben-Zeile-Spalten | Generated Column |
| Wert aus anderer Tabelle holen | Trigger (oder VIEW + JOIN) |
| Audit-Logging beim UPDATE | Trigger |
| Side-Effects (Notifications, Mailing) | Trigger |
Berechnung mit now(), random() | Trigger oder Default |
Pragmatisch: Generated Columns ersetzen die meisten BEFORE-INSERT/UPDATE-Trigger, die nur lokale Spalten kombinieren.
Interessantes
STORED schreibt zur Schreib-Zeit — Update kann teurer werden.
Bei jedem UPDATE, der die Generated Column betrifft (also eine ihrer Quell-Spalten), wird die Berechnung neu durchgeführt und auf Disk geschrieben. Bei aufwändigen Berechnungen ist VIRTUAL günstiger — sofern selten gelesen.
GENERATED ALWAYS blockiert manuelle Werte.
Versuch eines INSERT mit explizitem Wert für eine Generated Column → Fehler. Postgres unterscheidet hier nicht zwischen ALWAYS und BY DEFAULT (wie bei Identity) — bei Generated Columns immer ALWAYS.
Generated Columns können NOT NULL sein — automatisch.
Wenn der Ausdruck deterministisch nicht-NULL ist, übernimmt Postgres das. Manchmal hilfreich, manchmal eine Falle: bei firstname || ' ' || lastname darf weder firstname noch lastname NULL sein.
Beim DUMP wird der berechnete Wert NICHT geschrieben.
pg_dump exportiert Generated Columns als Schema-Definition, aber NICHT die berechneten Werte — beim Restore werden sie aus den Quell-Spalten neu berechnet. Das hält den Dump konsistent, kann aber bei Schema-Änderungen seltsam wirken.
STORED erstmal als Default — bei großen Tabellen wechseln.
Pragmatisch: STORED ist meist schnell genug und bringt indizierbare Spalten ohne Expression-Index. Wer Storage-knapp arbeitet oder die Spalte nur selten liest: VIRTUAL evaluieren — aber erst auf PG 18+.
Generated Columns ersetzen viele Trigger — sauberer.
Wer früher BEFORE-INSERT/UPDATE-Trigger geschrieben hat, um eine berechnete Spalte zu pflegen: Generated Column ist der modernere Weg. Weniger Code, kein Drift-Risiko (Trigger könnte vergessen werden), Constraint-Optimierungen greifen besser.
Weiterführende Ressourcen
Externe Quellen
- Generated Columns – PostgreSQL Documentation
- Release Notes 12 – Generated Columns
- Release Notes 18 – Virtual Generated Columns