[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-postgres-18-generated-columns-virtual-default":3},{"content":4,"lastModified":5},"\u003Cp>A column I added in August caused two failed deploys before I understood what Postgres was telling me. It had been live for three weeks, search returned the right rows, and staging never complained. The failure came from the index my teammate opened later: \u003Ccode>ERROR: indexes on virtual generated columns are not supported\u003C\u002Fcode>. I had created a virtual generated column by accident and did not know what it was.\u003C\u002Fp>\n\n\u003Cfigure>\n  \u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1509228468518-180dd4864904?auto=format&fit=crop&w=1200&q=80\" alt=\"A close-up of a bracketed system of four printed linear equations, each line relating several variables\" loading=\"lazy\" \u002F>\n\u003C\u002Ffigure>\n\n\u003Cp>The column, more or less as it shipped:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">ALTER TABLE documents\n  ADD COLUMN search_vector tsvector\n    GENERATED ALWAYS AS (to_tsvector('english', body));\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Look at what is missing. No \u003Ccode>STORED\u003C\u002Fcode> at the end, and on PostgreSQL 18 that is optional now. Before 18 the identical statement is a syntax error, because those versions had one kind of generated column and made you say which. The 18 docs spell the clause \u003Ccode>[ STORED | VIRTUAL ]\u003C\u002Fcode> and then tell you the part that bit me: \u003Ccode>VIRTUAL\u003C\u002Fcode> is the default.\u003C\u002Fp>\n\n\u003Cp>A schema change that was impossible to get wrong became easy to get wrong, and it fails quietly. A virtual generated column is computed when read rather than written, so every \u003Ccode>SELECT\u003C\u002Fcode> returns correct values. Search worked. The column was real. The index was not, and by the time anyone tried to build one, the migration behind it had been merged a month earlier.\u003C\u002Fp>\n\n\u003Ch2>Why I reached for a column at all\u003C\u002Fh2>\n\n\u003Cp>The full text search chapter recommends this shape: a \u003Ccode>tsvector\u003C\u002Fcode> column kept current by a stored generated column, with a GIN index on top. The reasons it gives are real. Queries do not have to repeat the text search configuration to use the index, and matches need no fresh \u003Ccode>to_tsvector\u003C\u002Fcode> call to verify them. The expression route uses less disk, but the column reads faster. I stopped at the semicolon.\u003C\u002Fp>\n\n\u003Cp>Here is the joke. The docs' own example writes the keyword. Mine did not, because I copied the clause shape from the syntax summary at the top of the page, not the example below it.\u003C\u002Fp>\n\n\u003Ch2>What a virtual column cannot do\u003C\u002Fh2>\n\n\u003Cp>The restrictions come from the commit that added virtual columns, and the list runs longer than the index failure suggests. You cannot index it, or reference it from an index expression, which also rules out a unique constraint. Extended statistics are out. Foreign keys are out. So is \u003Ccode>NOT NULL\u003C\u002Fcode>, though \u003Ccode>CHECK\u003C\u002Fcode> is allowed. A virtual column cannot have a domain type, and logical replication will not publish its values.\u003C\u002Fp>\n\n\u003Cp>Two more traps. \u003Ccode>ANALYZE\u003C\u002Fcode> skips virtual columns, so the planner has no statistics for them. And a generated column cannot be part of a partition key, stored or virtual, so making it \u003Ccode>STORED\u003C\u002Fcode> does not unlock partitioning.\u003C\u002Fp>\n\n\u003Cp>\"Occupies no storage\" is narrower than it sounds, since the column is still stored in the tuple as a null. You save the computed value, not the slot.\u003C\u002Fp>\n\n\u003Ch2>Finding them after an upgrade\u003C\u002Fh2>\n\n\u003Cp>The catalog tells you which kind each column is. \u003Ccode>attgenerated\u003C\u002Fcode> is empty for a normal column, \u003Ccode>s\u003C\u002Fcode> for stored, \u003Ccode>v\u003C\u002Fcode> for virtual. This query lists every virtual column in a schema with its expression. It found three more nobody had noticed:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">SELECT c.relname  AS table_name,\n       a.attname  AS column_name,\n       a.attgenerated,\n       pg_get_expr(d.adbin, d.adrelid) AS expression\nFROM pg_attribute a\nJOIN pg_class c     ON c.oid = a.attrelid\nJOIN pg_namespace n ON n.oid = c.relnamespace\nLEFT JOIN pg_attrdef d\n       ON d.adrelid = a.attrelid AND d.adnum = a.attnum\nWHERE a.attgenerated = 'v'\n  AND a.attnum &gt; 0\n  AND NOT a.attisdropped\n  AND n.nspname = 'public'\nORDER BY c.relname, a.attnum;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Run it after an upgrade to 18, and whenever a migration adds a generated column. A column and its index usually land in separate pull requests.\u003C\u002Fp>\n\n\u003Ch2>Repairing one\u003C\u002Fh2>\n\n\u003Cp>The obvious fix does not work. \u003Ccode>ALTER TABLE ... SET EXPRESSION AS (...)\u003C\u002Fcode> replaces the generation expression and leaves the kind alone. Useful when the formula is wrong, useless when the kind is. \u003Ccode>DROP EXPRESSION\u003C\u002Fcode> looks promising until the note that it only works on stored columns.\u003C\u002Fp>\n\n\u003Cp>Nothing flips virtual to stored. You drop the column and add it back with the keyword:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">ALTER TABLE documents DROP COLUMN search_vector;\n\nALTER TABLE documents\n  ADD COLUMN search_vector tsvector\n    GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Cheap on a small table. Ours was not: adding a stored generated column rewrites the table and every index on it. The docs warn that a rewrite can take a long time, needs as much as double the disk space while it runs, and is not MVCC safe: transactions holding a snapshot from before it see the table as empty. That was the real cost, and nobody mentioned it in review.\u003C\u002Fp>\n\n\u003Cp>So I indexed the expression instead and left the column virtual:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">CREATE INDEX documents_search_idx ON documents\n  USING gin (to_tsvector('english', body));\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>That is not free either, and I skipped the detail the first time. A write-up from DB Gorilla measured 500,000 rows on 18.6. The expression index cost about the same insert time as a stored column with its own index, 2.9 seconds against 2.9, and used 36 MB against 40 MB. So it is not cheaper on writes. What it buys is no column and no rewrite; what it costs is naming the configuration in every query.\u003C\u002Fp>\n\n\u003Cp>One oddity before you drop anything. A bug fixed in March 2026 made this index check read a garbage byte for \u003Ccode>attgenerated\u003C\u002Fcode> and raise the error spuriously, which could hit any expression index. It was backpatched to 18, so if one fails this way on an early 18.x, upgrade the minor version before rewriting your schema.\u003C\u002Fp>\n\n\u003Ch2>What I do now\u003C\u002Fh2>\n\n\u003Cp>Every generated column definition I write names its kind, even when the shorthand would parse. A version-dependent default in a migration file is a bug with a delivery date, and this one took three weeks.\u003C\u002Fp>\n\n\u003Cp>The column and its index go in the same migration. If the index cannot be built, I want the deploy to fail while the person who wrote the column still remembers why.\u003C\u002Fp>\n\n\u003Cp>I keep the audit query in my \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> library next to the plan-reading checklist from \u003Ca href=\"\u002Fposts\u002Fpostgres-not-using-my-index-what-i-check\u002F\">the index post I wrote two weeks ago\u003C\u002Fa>. Both are about the same gap: the distance between a schema that works and one the planner can use. Worth running on any Postgres 18 database you inherit.\u003C\u002Fp>\n","2026-09-29",1791168103952]