Postgres generated columns compute values automatically — here's when to use them
I had a products table with price_cents (integer) and a computed price_display (formatted string like "$29.99") that I maintained in application code, remembering to update it whenever price changed. I discovered generated columns in Postgres 12 and replaced that application logic with two lines of SQL. The column is always in sync, indexable, and requires zero application code.
The syntax
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
price_cents INTEGER NOT NULL,
tax_rate NUMERIC(4,2) NOT NULL DEFAULT 0.18,
-- Generated column: computed from price_cents, always in sync
price_display TEXT GENERATED ALWAYS AS (
'$' || ROUND(price_cents / 100.0, 2)::TEXT
) STORED,
-- Price with tax, rounded to nearest cent
price_with_tax_cents INTEGER GENERATED ALWAYS AS (
ROUND(price_cents * (1 + tax_rate))::INTEGER
) STORED
);
The STORED keyword is required in Postgres — computed values are stored on disk (unlike virtual columns in some other databases). This means they can be indexed.
Full-text search column
The most powerful use case: a generated tsvector for full-text search:
CREATE TABLE articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
body TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
-- Generated tsvector for full-text search
-- Automatically updates when title/body/tags change
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(body, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(array_to_string(tags, ' '), '')), 'C')
) STORED
);
-- Index the generated column for fast search
CREATE INDEX idx_articles_search ON articles USING gin(search_vector);
-- Search query
SELECT title, ts_rank(search_vector, query) AS rank
FROM articles, plainto_tsquery('english', 'postgres performance') AS query
WHERE search_vector @@ query
ORDER BY rank DESC;
Normalized data derived from raw input
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
first_name TEXT,
last_name TEXT,
-- Always lowercase email for case-insensitive lookups
email_normalized TEXT GENERATED ALWAYS AS (LOWER(email)) STORED,
-- Full name for display
full_name TEXT GENERATED ALWAYS AS (
TRIM(COALESCE(first_name, '') || ' ' || COALESCE(last_name, ''))
) STORED
);
CREATE UNIQUE INDEX idx_users_email ON users (email_normalized);
-- Now email lookups are always case-insensitive without application logic
SELECT * FROM users WHERE email_normalized = LOWER($1);
Age and duration calculations
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
-- Duration in days — useful for analytics
duration_days INTEGER GENERATED ALWAYS AS (
EXTRACT(EPOCH FROM (expires_at - started_at)) / 86400
)::INTEGER STORED
);
-- Query subscriptions by duration tier
SELECT user_id, duration_days,
CASE
WHEN duration_days <= 30 THEN 'monthly'
WHEN duration_days <= 93 THEN 'quarterly'
ELSE 'annual'
END AS tier
FROM subscriptions
WHERE expires_at > NOW();
Limitations to know
- Expression must be deterministic — no
NOW(), no random functions - Cannot reference other generated columns
- Cannot reference other tables (no subqueries)
- Adds storage overhead — each stored column takes space
- You cannot write to a generated column —
UPDATE ... SET generated_col = ...will error
Generated column vs trigger
Before generated columns existed, I used triggers to maintain derived data. Generated columns are better in every way: no trigger function to maintain, no risk of forgetting to fire the trigger on some code path, and the expression is right next to the column definition where it is immediately visible.
If your expression is too complex for a generated column (needs subqueries or other tables), a trigger is still the right tool. For everything else, use generated columns.