Why do relational engines require the expression behind a generated column to be deterministic (immutable), and what kinds of expressions get rejected as a result?
answer
- same inputs, same output, forever
- heap + index + replica + restore must agree
- no now(), random(), current_user
- IMMUTABLE required; STABLE is not enough
- created_at wants DEFAULT, not GENERATED
basics
~20 sBecause the stored bytes, any index on them, and any replica or restored dump must all agree. If the expression could return different values at different times, recomputation would disagree with what is on disk. So clock, random, session, and cross-row expressions are rejected.
solid answer
~50 sA generated column's value may be materialized on disk, copied into indexes, replicated, and recomputed later during a restore, an index rebuild, or a table rewrite. All of those recomputations must produce the identical result, otherwise the heap, the index, and the replica silently diverge and lookups start missing rows. So the expression must be a pure function of the current row. Rejected categories: - **Clock and session state** - `now()`, `current_timestamp`, `current_user`, session time zone. - **Non-determinism** - `random()`, sequence access, UUID generators. - **Cross-row or cross-table** - subqueries, joins, aggregates, window functions. - **Volatile or merely stable user functions** - in PostgreSQL a function must be marked IMMUTABLE; STABLE is not enough. - **Locale/setting-dependent conversions** - casting a timestamp to text under the session time zone, or a collation-dependent transformation the engine cannot guarantee. The practical trap: teams try to add `created_at` or `updated_at` as a generated column and are surprised it is refused. Those need a DEFAULT, a trigger, or application code.
code
sql · 11 lines-- rejected: clock is not part of the row
ALTER TABLE events
ADD COLUMN created_at timestamptz GENERATED ALWAYS AS (now()) STORED;
-- accepted: pure function of the same row
ALTER TABLE events
ADD COLUMN day date GENERATED ALWAYS AS ((occurred_at AT TIME ZONE 'UTC')::date) STORED;
-- the right tool for insert-time capture
ALTER TABLE events
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now();go deeper
Recall that the expression must depend only on the current row and must not use the clock or random values, and that created_at needs a DEFAULT instead.
Explain the mechanism: the value is persisted and indexed, so recomputation during rebuild, restore, or replication must match, and name the rejected categories.
Lead with the failure mode - heap and index divergence that surfaces as plan-dependent results - and mention volatility mislabelling as the realistic production cause.
Position determinism as the contract that makes derived data safe to materialize anywhere, and connect it to collation upgrades, logical replication, and dump/restore guarantees.
## What determinism means here An expression is deterministic (PostgreSQL's term is IMMUTABLE) when it returns the same output for the same inputs, forever, on any node, with any session settings. `unit_price * qty` qualifies. `now()` does not, because the input clock is invisible in the row. `random()` does not. `lower(email)` qualifies under a fixed collation but becomes questionable if the transformation depends on a locale the engine may not treat as fixed. ## Why the engine insists on it The value of a generated column is not computed once and forgotten. It gets recomputed, or the previously computed bytes get trusted, in several places: 1. **Write path** - evaluated at insert/update and, for STORED columns, written into the row. 2. **Index maintenance** - if the column is indexed, the same value is copied into the index entry. An index build later recomputes it for existing rows. 3. **Table rewrites** - full-table maintenance operations, ALTER TABLE rewrites, and online schema-change tools recompute or copy the value. 4. **Backup and restore** - a logical dump reloads rows and the target recomputes generated values. 5. **Replication and failover** - the replica must arrive at the same value as the primary, whether it receives physical bytes or logical row images. If the expression drifted, each of these would produce a different answer. The disaster case is quiet: an index entry says the value is one thing while the row now recomputes to another. Index-only scans return the stale value, index scans miss rows that a sequential scan would find, and the same query returns different results depending on the plan the optimizer chose. That is corruption, not a bug you can log. A UNIQUE constraint on such a column would be enforcing a rule that no longer holds. Determinism is what lets the engine treat a computed value as data. ## The rejected categories, with the reasoning - **Clock functions.** `now()`, `current_timestamp`, `localtime`. The tempting use case - an auto-filled `created_at` - is exactly the one that breaks: the value must be captured once, not re-derived. Use a column DEFAULT, which fires once at insert and then is ordinary data. - **Session and environment.** `current_user`, configuration lookups, session time zone. Two sessions would compute different values for the same row. - **Random and sequence-like sources.** `random()`, UUID generators, sequence access. These have side effects or no repeatability at all. - **Anything cross-row.** Subqueries, joins, aggregates, and window functions are all refused because a generated column is evaluated with only the current row in scope, and a change to some other row could not trigger re-evaluation. - **User-defined functions with the wrong volatility.** PostgreSQL requires IMMUTABLE. A function marked STABLE - constant within one statement but allowed to change between statements, typically because it reads tables or settings - is not enough for something written to disk. Developers routinely mislabel a function IMMUTABLE to get past the error; that does not make it immutable, it just moves the failure to the next restore or index build. - **Text conversions that depend on settings.** Formatting a timestamp-with-time-zone as text uses the session time zone; casting under a collation that could be upgraded is similarly fragile. Some engines reject these outright; where they do not, the risk is real - a collation library upgrade can change ordering and case rules, which is why collation changes force index rebuilds. ## Related restrictions worth naming Beyond determinism, engines commonly forbid a generated column from being part of a primary key in some products, restrict whether one generated column may reference another (PostgreSQL forbids it; MySQL allows a reference to one defined earlier), and forbid direct writes entirely. Foreign keys referencing or originating from generated columns are allowed in some engines and not others, so check before designing around it. ## What to do instead When you need a non-deterministic derived value, name the right tool. Insert-time capture of the clock: a DEFAULT. Update-time capture: a BEFORE UPDATE trigger, or set it in the application's write path. Values derived from other rows or tables: a view, a materialized view, or explicit maintenance logic. Trying to force any of these into a generated column is the single most common mistake on this topic, and interviewers ask precisely because it separates people who have used the feature from people who have read the syntax.
- A developer marks a function IMMUTABLE only so the database will accept it in a generated column, but the function reads a lookup table. What breaks?The label is a promise the planner and storage layer act on, not a check. Values are computed with today's lookup data and written to disk and to indexes; when the lookup table changes, existing rows keep the old value while a rebuild, restore, or new insert produces the new one. You end up with an index that disagrees with the heap and query results that vary by plan - corruption that no error message announces.
- How would you store a value derived from another table, such as an order's customer tier at the time of purchase?Not as a generated column - it cannot see other tables. If the value should be a point-in-time snapshot, write it explicitly in the transaction that creates the order (or via a trigger) and treat it as ordinary data. If it should always reflect the current tier, do not store it at all: expose it through a join or a view, and materialize only if the read cost justifies a refresh strategy.
saying these in an interview costs you the question
- Trying to use now() or current_timestamp as a generated expression for created_at
- Marking a function IMMUTABLE just to get past the error, without checking that it is
- Assuming STABLE volatility is good enough for a stored value
- Thinking a subquery or aggregate is allowed if it is 'fast enough'
- Explaining determinism only as an optimization rather than a correctness requirement for indexes, replicas, and restores