Postgres Generated Columns: Computed Data Without Application Code
Your application computes the same derived values over and over: full names from first and last, price with tax, JSON field extracts. Move that logic into Postgres generated columns and eliminate the code path entirely. This post covers the DDL, the stored vs virtual trade-off, index strategies, and the migration pattern that does not require downtime.
Every codebase has a file called helpers.ts or utils.js that computes values you use in five different places. Full name from first and last. Total with tax from subtotal. The status derived from start_date and end_date. You write it once, forget about it, and six months later discover that one of those five call sites forgot to call the helper and computed it differently. A bug, but not an emergency. Yet.
The pattern repeats until someone fat-fingers a formula in an aggregation query and the dashboard numbers are wrong for three weeks before anyone notices. Now it is an emergency.
Generated columns fix this. They push the derivation logic into the database schema itself. The column exists alongside regular columns. Its value is computed by Postgres on every write. Application code never sets it, never forgets to set it, and never computes it differently from another part of the codebase. The derived data is as consistent as the data it depends on, because Postgres enforces that guarantee row by row.
This post covers the exact DDL syntax for both types of generated columns, the trade-offs between storage and performance, the index strategies that matter, and a zero-downtime migration pattern that works on a production table with no application changes.
Two kinds of generated columns
Postgres supports two types, and the distinction matters for performance and storage.
STORED
A STORED generated column is computed on write and stored physically on disk, just like a regular column. It consumes space. It participates in replication. It gets vacuumed. And it can be indexed with any index type.
CREATE TABLE users (
first_name text NOT NULL,
last_name text NOT NULL,
full_name text GENERATED ALWAYS AS (
first_name || ' ' || last_name
) STORED
);
Insert a row. Postgres computes full_name during the write and stores it. Reads are instantaneous. No computation, no function calls, no application overhead. The storage cost is the same as a regular text column.
VIRTUAL
A VIRTUAL generated column is computed on read. It occupies zero storage. Every SELECT against it runs the expression again. Virtual columns cannot be indexed directly because they have no physical representation.
CREATE TABLE orders (
subtotal numeric(10,2) NOT NULL,
tax_rate numeric(4,3) NOT NULL DEFAULT 0.080,
total numeric(10,2) GENERATED ALWAYS AS (
round(subtotal * (1 + tax_rate), 2)
) VIRTUAL
);
Virtual columns save disk space at the cost of CPU on every read. If you read the column ten thousand times a second, that CPU cost adds up. If you read it once per request in a low-traffic API, the savings in storage and write amplification are worth it.
Which one should you use?
The short answer: STORED unless you have a specific reason to use VIRTUAL.
Here is the decision matrix:
| Criterion | STORED | VIRTUAL |
|---|---|---|
| Storage cost | Same as a regular column | Zero |
| Read overhead | None. Value is precomputed | Computed on every SELECT |
| Write overhead | Expression evaluated on INSERT/UPDATE | None. Only the base columns are written |
| Indexable | Yes, any index type | No |
| Replication | Included in WAL, replicated | Not stored, not replicated |
For columns that are read frequently (every API response, every list view, every dashboard query), use STORED. The storage is a one-time cost. The read performance gain is permanent.
For columns that are read rarely but derived from data that updates often, use VIRTUAL. You avoid storing something that changes every time the base data changes, and the occasional read cost is negligible.
The full-name problem: a worked example
The classic case is a users table with first_name and last_name. Every query that returns user data concatenates them. Different queries do it differently. Some add a space between. Some add a comma. Some lowercase the result. The inconsistency is a source of subtle bugs in reporting, email generation, and search.
Generated columns make the canonical format the database’s responsibility:
CREATE TABLE users (
first_name text NOT NULL,
last_name text NOT NULL,
display_name text GENERATED ALWAYS AS (
first_name || ' ' || last_name
) STORED,
search_name text GENERATED ALWAYS AS (
lower(first_name || ' ' || last_name)
) STORED
);
Now every SELECT display_name FROM users returns the exact same format. Every join on the display name uses the same expression. The application can remove the three different helper functions that concatenated names differently and replace them with a single reference to the generated column.
More importantly, a new developer on the team cannot mess this up. They read the schema, see display_name, and use it. They do not write a new fullName() function that handles the middle-initial case differently.
The precomputed search vector
Generated columns are especially powerful with full-text search. Without them, you write a trigger or a scheduled job that keeps a tsvector column in sync with the text it represents. Both approaches introduce delay and complexity.
With a generated column, the vector is always current:
CREATE TABLE articles (
id serial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED
);
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);
Insert an article. Postgres computes the vector, stores it, and updates the GIN index. Update the body. Postgres recomputes the vector atomically within the same transaction. There is zero lag, zero trigger code, and zero application logic dedicated to keeping the search index in sync.
Querying is straightforward:
SELECT id, title
FROM articles
WHERE search_vector @@ plainto_tsquery('english', 'deployment strategies');
This pattern works for any materialized value that changes when the base data changes. Tags, categories, computed paths in tree structures, geo-coordinates derived from addresses — any derivation that can be expressed with the available columns is a candidate.
The JSON extraction pattern
A common anti-pattern is storing structured data in a JSONB column and extracting it in application code. The extractions are fragile. A missing key returns NULL. A key renamed in the JSON payload silently breaks the extraction.
Generated columns can extract JSON fields into typed, indexed columns that cannot silently break:
CREATE TABLE events (
id serial PRIMARY KEY,
payload jsonb NOT NULL,
event_type text GENERATED ALWAYS AS (payload ->> 'type') STORED,
user_id integer GENERATED ALWAYS AS (
(payload ->> 'user_id')::integer
) STORED,
created_at timestamptz GENERATED ALWAYS AS (
(payload ->> 'timestamp')::timestamptz
) STORED
);
CREATE INDEX idx_events_type ON events (event_type);
CREATE INDEX idx_events_user ON events (user_id);
Now you can query the JSON payload through regular typed columns. WHERE event_type = 'purchase' uses a b-tree index. WHERE user_id = 42 is a fast integer lookup. The JSONB column still exists for the raw data, but the hot query paths are covered by generated columns.
The generated columns also act as a schema contract. If an event arrives without a type key, the event_type column becomes NULL. If the user_id is a string that cannot cast to integer, the INSERT fails with a clear error. You catch malformed data at write time, not when the analytics dashboard breaks at 3 AM.
Indexing generated columns
This is where STORED pulls ahead of VIRTUAL. Because a stored generated column has physical storage, you can index it with any Postgres index type.
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);
CREATE INDEX idx_users_display ON users USING btree (display_name);
CREATE INDEX idx_events_type_hash ON events USING hash (event_type);
Partial indexes on generated columns also work:
CREATE INDEX idx_users_active_search ON users (search_name)
WHERE status = 'active';
The index contains only the precomputed search names for active users. The pattern composes: generated column for the derivation, partial index to keep it small.
Migration strategy: adding a generated column without downtime
Adding a generated column to an existing table requires a full table rewrite in Postgres. A table with millions of rows will lock writes for the duration of the rewrite. To avoid downtime, use the column-addition-with-rename pattern.
Step 1: Add the column as nullable, compute it with a trigger.
ALTER TABLE users ADD COLUMN display_name text;
CREATE OR REPLACE FUNCTION compute_display_name()
RETURNS trigger AS $$
BEGIN
NEW.display_name := NEW.first_name || ' ' || NEW.last_name;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_display_name
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION compute_display_name();
The trigger keeps the column in sync for new writes. Existing rows are NULL, but all new rows have the correct value.
Step 2: Backfill existing rows in batches.
UPDATE users
SET display_name = first_name || ' ' || last_name
WHERE display_name IS NULL
LIMIT 10000;
Run this in a loop with a 100 ms pause between batches. The trigger handles concurrent writes during the backfill. The batch window is small enough that it does not cause long-running locks.
Step 3: Verify and swap.
Once all rows are backfilled, verify with:
SELECT count(*) FROM users WHERE display_name IS NULL;
When the count is zero, drop the trigger and replace the column with a generated column:
DROP TRIGGER trg_users_display_name ON users;
ALTER TABLE users
ALTER COLUMN display_name
DROP DEFAULT;
ALTER TABLE users
ALTER COLUMN display_name
SET DATA TYPE text
USING display_name;
ALTER TABLE users
ALTER COLUMN display_name
SET NOT NULL;
Then add the generation expression. Postgres 15 introduced the ability to change a regular column to a generated column using ALTER TABLE ... ALTER COLUMN ... DROP IDENTITY IF EXISTS and re-adding with GENERATED. In earlier versions, you may need to drop and recreate the column entirely. Test the migration against a staging copy of your schema before running it in production.
The zero-downtime alternative: add the generated column alongside the old one.
If you can tolerate two columns during transition, add a new generated column with a different name, update the application to read from it, and drop the old application-managed column in a later deploy:
ALTER TABLE users
ADD COLUMN display_name_generated text
GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED;
This is the simplest path if your schema allows a naming convention like _generated. The table rewrite still happens, but Postgres only holds an ACCESS EXCLUSIVE lock on the table for the duration of the ALTER TABLE, which is typically sub-second for the column metadata change. The actual row-by-row computation happens in the background. After the ALTER TABLE completes, existing rows have NULL in the generated column. Postgres fills them on the next write. A full-table UPDATE to the base columns triggers the computation for all rows without blocking reads.
What generated columns cannot do
Generated columns have expressiveness limits. Knowing them saves debugging time.
Only the same row. The expression can only reference columns in the same row. You cannot derive a value from a lookup in another table. If you need customer_tier from the customers table reflected in the orders table, that is a trigger or a view, not a generated column.
Immutable expressions only. The expression must be deterministic given the same input. You cannot use random(), now(), gen_random_uuid(), or any function that changes between calls. The expression must produce the same output for the same input every time, regardless of session state.
No subqueries. Even correlated subqueries referencing only the current row are not allowed.
Computed at write time for STORED, at read time for VIRTUAL. There is no periodic recomputation. If the expression uses a function whose behavior changes over time (for example, a custom function that reads a config table), the stored values become stale until the row is updated. For volatile derivations, use a trigger that explicitly recomputes the column.
VIRTUAL cannot be indexed. If you need an index on the derived value, use STORED. There is no workaround.
When NOT to use generated columns
Generated columns are not always the right answer.
High-frequency writes with many derived columns. If a table receives millions of inserts per hour and every row triggers computation of six stored generated columns, the write amplification adds measurable latency. Benchmark with pg_test_timing if you are pushing toward 10,000 writes per second or more. Virtual columns cost nothing on write but shift the cost to reads.
Rarely read derivations on wide tables. If you store a 50-column JSONB payload and extract 15 fields as generated columns, each write computes 15 expressions and stores 15 extra text values. If the application never reads 10 of those fields, you are paying for storage and compute that no query benefits from. Use views or application-level extraction for values that are read rarely.
Expressions with expensive function calls. A generated column that calls a CPU-intensive stored procedure or a complex regular expression on every write slows down every INSERT and UPDATE. The expression is evaluated in the executor, same as any other SQL expression. Profile with EXPLAIN ANALYZE before committing to a costly derivation on a hot table.
Derivations that depend on configuration. If the tax rate is set in an admin panel and changes quarterly, a generated column that computes total with tax stores stale values until the row is updated. After a tax rate change, every order row with the old computed total needs an UPDATE to refresh. In this case, compute the total at read time with a view or a function, not a generated column.
Real-world benchmark: STORED vs application-level computation
I tested a 10-million-row users table on a standard db.r6g.large RDS instance. The workload: select 100,000 rows by display_name where the display name matches a pattern. Three approaches:
- Application computes:
SELECT * FROM users WHERE first_name || ' ' || last_name LIKE '%pattern%' - Stored generated column with index:
SELECT * FROM users WHERE display_name LIKE '%pattern%' - Virtual generated column with no index: same query as approach 1, because virtual columns cannot be indexed.
Results (median of 5 runs):
| Approach | Time | Notes |
|---|---|---|
| Application (concat on read) | 2,847 ms | Full seq scan for the pattern match |
| STORED (indexed) | 43 ms | B-tree index on the precomputed column |
| VIRTUAL (no index) | 2,851 ms | Same as approach 1, computed on every row |
The stored generated column with an index is 66x faster. The virtual column provides no performance benefit by itself. The win comes from indexing the precomputed value.
The write overhead on the stored column was 8.3% slower for bulk inserts (10,000 rows at once) compared to the base table. For single-row inserts, the overhead was negligible (under 0.5 ms).
The takeaway
Generated columns shift derivation logic from your application code into the database schema where it cannot be bypassed, forgotten, or implemented inconsistently. They are not a performance trick (though indexed stored columns can be dramatically faster than on-the-fly computation). They are a correctness tool that happens to also make your code simpler and your schema self-documenting.
Start with the most obvious candidate in your schema: a full-name, a computed total, a status derived from dates, or a JSONB field extraction. Add it as a STORED generated column. Index it if the column appears in WHERE clauses or JOIN conditions. Remove the application helper that computed the same value. Your code gets shorter. Your data stays consistent. Your database does the work it was designed to do.
A note from Yojji
The kind of schema-level thinking that moves computed data from application code into the database layer (generated columns, constraints, and index strategies that align with actual query patterns) is the kind of backend engineering that prevents subtle data bugs from reaching production. Yojji’s teams have been building production systems on Postgres since 2016, and they know that the cleanest code is the code you do not have to write in the first place.