Use when writing or reviewing PostgreSQL/PL-pgSQL, designing table schemas, writing functions and procedures, building migrations, defining domains, or architecting a Postgres application database. Also use when writing RAISE EXCEPTION patterns, BEFORE/AFTER triggers for cross-table constraints, base/subtype hierarchies, composite key designs, row-level security policies, or idempotent DDL scripts. If you are touching Postgres for an application database, use this skill. PostgreSQL-specific — examples will not run on other engines.
84
89%
Does it follow best practices?
Impact
100%
1.72xAverage score across 2 eval scenarios
Passed
No known issues
Generated columns are derived from other columns by an expression. Postgres computes them at write time, stores the result, and keeps them in sync automatically. They can be indexed like regular columns.
CREATE TABLE customer (
customer_no customer_no PRIMARY KEY,
first_name text NOT NULL,
last_name text NOT NULL,
full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED
);GENERATED ALWAYS AS (...) is the keyword. STORED means Postgres writes the computed value to disk on every insert/update.
Direct writes to a generated column are rejected:
INSERT INTO customer(customer_no, first_name, last_name, full_name)
VALUES (1, 'Alice', 'Smith', 'Alice S');
-- ERROR: cannot insert into column "full_name"You can use DEFAULT in INSERTs to make it explicit:
INSERT INTO customer(customer_no, first_name, last_name, full_name)
VALUES (1, 'Alice', 'Smith', DEFAULT);The expression must be deterministic and reference only:
clock_timestamp(), no random())Cannot reference:
clock_timestamp(), nextval(), etc.)IMMUTABLEPostgres enforces this — the column definition is checked at create time.
Postgres supports only STORED generated columns (as of Postgres 17). VIRTUAL (compute on read) is in some other databases but not Postgres yet. Implication: every generated column adds row width — be selective.
Concatenated full names:
full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) STOREDNormalized search columns:
email_normalized text GENERATED ALWAYS AS (lower(trim(email))) STOREDPair with an index on email_normalized for case-insensitive lookups.
Computed totals:
line_total numeric(18,4)
GENERATED ALWAYS AS (quantity * unit_price) STOREDSum across rows still requires SUM(line_total) at query time, but you avoid recomputing the multiplication every read.
Boolean flags from state:
is_terminal boolean
GENERATED ALWAYS AS (status IN ('done', 'failed', 'cancelled')) STOREDLets you index is_terminal directly — useful if many queries filter on it.
Age from date_of_birth:
-- This DOES NOT work — clock_timestamp() is volatile
age int GENERATED ALWAYS AS (DATE_PART('year', AGE(date_of_birth))) STORED;
-- ERROR: generation expression is not immutableCompute age in a view or at query time instead.
Index them like any column:
CREATE INDEX customer_full_name_idx
ON customer(full_name);
CREATE INDEX customer_email_normalized_idx
ON customer(email_normalized);This is the main reason to use generated columns over expression indexes: the column is materialized once and indexed once, and queries can reference the column directly without recomputing the expression.
Generated column for purely deterministic derivations of the same row's data — Postgres handles consistency, the column is type-checked, no DML in your codebase.
Trigger when the derivation depends on other tables, non-deterministic functions, or external state:
-- This MUST be a trigger — calls a volatile function
CREATE OR REPLACE FUNCTION tg_customer_search_text()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.search_text := lower(NEW.full_name || ' ' || NEW.email);
NEW.updated_at := clock_timestamp();
RETURN NEW;
END;
$$;
CREATE TRIGGER customer_search_text
BEFORE INSERT OR UPDATE ON customer
FOR EACH ROW EXECUTE FUNCTION tg_customer_search_text();If you can express the derivation as a STORED generated column, do — it's simpler, faster, and the constraint is documented inline. Reach for triggers only when the constraints don't fit.
-- Add the column (Postgres backfills the value for existing rows)
ALTER TABLE customer
ADD COLUMN full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED;
-- Index it
CREATE INDEX IF NOT EXISTS customer_full_name_idx ON customer(full_name);For large tables, the backfill rewrites the entire table — same cost as a CHECK on every row. Schedule for a maintenance window or use a multi-step migration (add column, backfill in batches, attach index CONCURRENTLY).
clock_timestamp(), random(), nextval(), current_userevals
scenario-1
scenario-2
references