Skip to content

Build1 publisher3 min readPublished

Indexing a PostgreSQL 18 generated column requires writing STORED

PostgreSQL 18 makes generated columns virtual unless the definition says STORED, and a virtual column cannot be indexed directly. Tests on 18.3 by the Schemity ERD tool's maker still favour them over triggers for same-row values, if indexed ones are written STORED.

The Engineer · Build desk

Illustration accompanying Indexing a PostgreSQL 18 generated column requires writing STORED

What happened

  • In the post's million-row insert test, a stored generated column added about 16% to insert time and a row-level PL/pgSQL trigger added about 72%.
  • The virtual generated column added no measurable insert time in the same test, because it writes nothing.
  • PostgreSQL 18.3 refused to create generated columns that call now(), use a subquery, or read another generated column, each with a named error.
  • A trigger scoped to price and quantity updates let a direct UPDATE leave line_total at 5.00 on a row priced 9.99 with quantity 3.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • decision Every new generated column on PostgreSQL 18 forces a per-column choice: leave STORED off for no insert cost, or write it so the column can take a direct index.
  • exposure Derived values kept by triggers can drift whenever a statement writes the column directly, and nothing in the table definition tells a later maintainer the column is derived.
  • constraint Timestamps, cross-table totals and values copied from a parent row cannot move to generated columns, so the trigger bypass risk stays on exactly those columns.
  • cost Closing the UPDATE OF gap puts a function call on every update to the table, including updates that never touch the trigger's inputs.

The post's example is one line of DDL: `line_total numeric(12,2) GENERATED ALWAYS AS (unit_price * quantity) STORED` [16]. Delete the last word and PostgreSQL 18 makes the column virtual [1]. It comes from the blog of Schemity, a desktop ERD tool its author builds, and every statement in it ran on PostgreSQL 18.3 in a throwaway container [2][3]. On its insert figures, the trigger's overhead is about 4.5 times the stored column's [1].

Those figures are the average of three runs of one INSERT ... SELECT over generate_series(1, 1000000) on a laptop, and the author says to read them as ratios [6]. For the ratios to transfer, your writes would have to resemble that batch, where a per-row function call is a large share of each row's work. I'd expect single-row inserts into a table with several indexes to show smaller percentages, since more of each insert's time goes to other work. The timings are all for inserts. A virtual column writes nothing, so it is computed when a query reads it [5].

In my view the index rule decides most cases. The post's own list of good generated columns includes lower(email), a tsvector from to_tsvector('english', body), and a field pulled out of a jsonb document [7]. In my experience, columns like those exist to be searched. On 18, each one needs STORED if the index is to sit on the column itself, because a virtual column cannot be indexed directly [1]. The post tested definitions written on 18.3 and does not cover what an upgrade does to columns created on earlier versions [3]. After an upgrade, the columns I would check are the ones in migrations written since, where a missing STORED produces a virtual column [1].

PostgreSQL enforces the same-row, immutable-only rule when the column is created [7][9]. I like this design. A bad expression fails in the migration while its author is still at the keyboard. The check is strict: the one-argument to_tsvector(body) is refused because its result depends on a server setting [8]. The rejection of one generated column reading another has an easy fix: repeat the other column's expression inline [10].

Triggers keep the cases a generated column cannot express: an updated_at set to now(), an orders.total summed from order_lines, and a customer's name stamped onto an invoice [11]. They fail without an error. In the bypass test, the trigger fired only on updates OF unit_price and quantity, and a statement setting line_total touched neither column [12]. Removing UPDATE OF closes that gap and adds a function call to updates that never touch the price [13]. A later maintainer then inherits a column that looks exactly like one the application writes [14]. "If a generated column can express the value, the trigger buys nothing but risk," the author wrote [15].

I think that holds on PostgreSQL 18 with one amendment. I would write STORED on every generated column that will carry an index and leave the rest virtual, where the post measured no insert cost [5].

What to watch

  • Whether in-place upgrades or dump-and-restore to PostgreSQL 18 keep existing generated columns STORED; the post tested only definitions written on 18.3.
  • A read-side benchmark of virtual generated columns, since the post's timings cover inserts only.
  • Whether a later PostgreSQL release allows direct indexes on virtual generated columns; that would remove the main reason to write STORED.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories