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

SQL
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:

SQL
  name  | price  | tax_rate | price_with_tax
--------+--------+----------+----------------
 Widget | 100.00 |     0.19 |         119.00

price_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

SQL
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

SQL
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

SQL
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.

SQL Verbotene Patterns
-- 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 expression

Faustregel: 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.

SQL
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:

STOREDVIRTUAL
SpeicherungAuf DiskNicht
AuswertungBeim SchreibenBeim Lesen
Storage-KostenVollNull
Read-PerformanceLesen direktBerechnung pro Read
IndizierbarJaEingeschränkt (PG 18)
Verfügbar seitPG 12PG 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

SQL
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:

SQL
CREATE INDEX products_price_with_tax_idx
ON products ((price * (1 + tax_rate)));

Generated vs. Trigger

AnforderungLösung
Berechnung aus selben-Zeile-SpaltenGenerated Column
Wert aus anderer Tabelle holenTrigger (oder VIEW + JOIN)
Audit-Logging beim UPDATETrigger
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

/ Weiter

Zurück zu Constraints & Schema-Design

Zur Übersicht