Postgres generated columns compute values automatically — here's when to use them
← Back
April 4, 2026Database5 min read

Postgres generated columns compute values automatically — here's when to use them

Published April 4, 20265 min read

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

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

sql
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

sql
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

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

Share this
← All Posts5 min read