Rails 8 Support for Postgresql 18 Virtual Columns
What are Postgresql 18 virtual columns and how can you use them in your Rails 8 apps.
PostgreSQL 18 adds virtual generated columns (computed on read, not stored on disk) and makes them the default for generated columns. Rails 8 supports them directly in migrations — just set stored: false (or omit stored: when you want the default virtual behavior). https://www.postgresql.org/docs/current/release-18.html
What you’ll need
- PostgreSQL 18 (server + client libs).
- Rails 8 (Active Record with the PostgreSQL adapter).
- The
pggem compiled against a libpq that can talk to Postgres 18.
Why PG 18? Virtual columns are new in 18 and are the default kind of generated column there.
Virtual vs. Stored (quick refresher)
| Type | When computed | Stored on disk? | Indexable?* | Logical replication |
|---|---|---|---|---|
| Virtual | On read | No | No | Not supported |
| Stored | On write (INSERT/UPDATE) | Yes | Yes | Supported |
*You cannot create an index on a virtual generated column in PG 18 (there’s active work to support it in later releases). Use an expression index instead — examples below. More info
⚙️ How to create virtual generated columns in Rails 8
Rails 8 migrations can define virtual columns using t.virtual (or add_column ... :virtual) with an SQL expression via as:. For PostgreSQL 18+, set stored: false (or omit it) to get virtual behavior. More info
Example 1 — Full name (text concatenation)
class AddFullNameToUsers < ActiveRecord::Migration[8.0]
def change
change_table :users do |t|
t.string :first_name, null: false
t.string :last_name, null: false
# Virtual by default in PG18 (computed on read)
t.virtual :full_name, type: :text, as: "first_name || ' ' || last_name"
# equivalently: t.virtual :full_name, type: :text, as: "...", stored: false
end
end
end
Example 2 — Numeric calculation
class AddTotalPriceToOrders < ActiveRecord::Migration[8.0]
def change
change_table :orders do |t|
t.integer :quantity, null: false, default: 1
t.numeric :unit_price, null: false
t.virtual :total_price, type: :numeric, as: "quantity * unit_price"
end
end
end
Example 3 — JSONB projection
class AddUsernameProjection < ActiveRecord::Migration[8.0]
def change
change_table :user_profiles do |t|
t.jsonb :settings, null: false, default: {}
t.virtual :username, type: :text, as: "(settings ->> 'username')"
end
end
end
Remember: in PG18 virtual columns can’t be indexed, so if you need to search/order by username, add an expression index (see “Indexing” below).
Querying & ordering
Virtual columns behave like normal attributes in SELECT, WHERE, ORDER BY, etc.
# Include the virtual column in the projection for eager access
scope :with_full_name, -> { select(:id, :first_name, :last_name, :full_name) }
User.with_full_name.order(:full_name)
User.where(full_name: "Jane Doe")
⚡ Indexing strategies (important!)
Because PG18 won’t index virtual columns directly, index the expression itself:
class IndexUsersOnFullNameExpr < ActiveRecord::Migration[8.0]
def change
# Matches the generation expression exactly:
add_index :users, "first_name || ' ' || last_name",
name: "index_users_on_full_name_expr"
end
end
This gives the same query performance benefits as indexing a stored column while keeping your column virtual.
If you truly need a conventional index on the column name (e.g., for simple syntax or unique constraints), switch to a stored generated column:
t.virtual :search_vector, type: :tsvector,
as: "to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(body,''))",
stored: true
add_index :posts, :search_vector, using: :gin
PG18’s limitation is specific to virtual columns; stored generated columns are indexable and can be replicated.
🚫 Restrictions & gotchas you should know (PostgreSQL 18)
- Virtual expressions must use built-in types/functions only; no user-defined types/functions. 
- Generation expressions must be immutable and row-local (no subqueries, no cross-row references, no volatile functions like now(), random(), clock_timestamp(), or age() with current time). 
- You can’t reference another generated column in the expression. 
- Generated columns can’t be part of a partition key. 
- Logical replication currently supports stored generated columns (not virtual). 
- Table rewrites: adding a virtual column does not rewrite the table; adding a stored one does. This matters on large tables. 
🔄 Changing or dropping the expression later
PostgreSQL supports altering the expression without dropping the column:
def up
execute <<~SQL
ALTER TABLE users
ALTER COLUMN full_name SET EXPRESSION (first_name || ' ' || last_name);
SQL
end
def down
execute <<~SQL
ALTER TABLE users
ALTER COLUMN full_name DROP EXPRESSION;
SQL
end
ALTER COLUMN … SET/DROP EXPRESSION is the supported way to modify/remove a generated expression.
🧪 Testing tips
- When you change base attributes in specs, call record.reload or query via pluck to observe the recomputed value.
- Avoid stubbing database functions used by generated columns — compute expectations from the same SQL where possible.
🧰 Schema dumps & structure
Rails can round-trip most generated columns via schema.rb, but if you rely on advanced database features (expression indexes with operator classes, custom extensions, etc.), prefer structure.sql:
# config/database.yml
production:
schema_format: sql
Rails’ guides recommend switching to :sql format when you depend on DB constructs the Ruby schema dumper can’t represent.
🧭 Version guards (optional but handy)
If you ship to multiple environments, you can guard migrations so virtual columns are only used on PG 18+:
class SafeVirtualColumn < ActiveRecord::Migration[8.0]
def up
version = ActiveRecord::Base.connection.select_value("SHOW server_version_num").to_i
if version < 180000
# Fallback: make it STORED on older PG to keep behavior similar
add_column :users, :full_name, :virtual,
type: :text,
as: "first_name || ' ' || last_name",
stored: true
else
add_column :users, :full_name, :virtual,
type: :text,
as: "first_name || ' ' || last_name" # virtual by default
end
end
def down
remove_column :users, :full_name
end
end
🧩 Design guidance
- Choose virtual when:
- You want derived values without disk/storage overhead.
- You don’t need to index the column name itself (use an expression index instead).
- Choose stored when:
- You must place an index/unique constraint directly on the generated column (e.g., full-text tsvectors, case-insensitive uniques).
- You need logical replication of the column.
📚 Further reading
- PostgreSQL 18 release notes — virtual generated columns, virtual as default. More info 
- PostgreSQL docs — Generated Columns (kind, default, restrictions). More info
- Rails “This Week in Rails: Virtual columns” — example of stored: false in migrations for PG 18+. More info
- Indexing alternatives & limitations (community write-ups and docs). More info
✅ TL;DR
- In PG 18, generated columns are virtual by default (computed on read). 
- Rails 8 supports them via t.virtual ... as:
...
(use stored: false for clarity).  - You can’t index a virtual column directly in PG 18 — use an expression index or switch to stored if you need indexing or replication.