Tutorials / Rails 8 Support for Postgresql 18 Virtual Columns

Rails 8 Support for Postgresql 18 Virtual Columns

Intermediate ⏱ 10 min 14 sections

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

1.

What you’ll need

  • PostgreSQL 18 (server + client libs).
  • Rails 8 (Active Record with the PostgreSQL adapter).
  • The pg gem 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.

2.

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
3.

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
4.

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).

5.

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")
6.

⚡ 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.

7.

🚫 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. 
8.

🔄 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.

9.

🧪 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.
10.

🧰 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.

11.

🧭 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
12.

🧩 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.
13.

📚 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
14.

✅ 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.