Rails 8 Virtual Attributes Done Right

Sometimes the data a form needs doesn't map cleanly to database columns. A confirmation checkbox, a combined date-time picker, or a computed display value—these all require attributes that exist in Ruby but not in MySQL. Virtual attributes solve this elegantly, but implementing them poorly leads to validation headaches and confusing code.

What Virtual Attributes Actually Are

A virtual attribute is an accessor method on a model that behaves like a database column but stores no data persistently. Rails treats it like any other attribute for forms and validations, making it seamless to work with in views and controllers.

Common use cases include:

  • Form fields that don't persist (confirmation checkboxes, terms acceptance)
  • Computed values derived from real columns (full name from first + last)
  • Data transformation during input (splitting or combining fields)
  • Temporary state during complex workflows

The attribute API: Beyond Basic Accessors

While attr_accessor works for simple cases, Rails 8's attribute API provides type casting, defaults, and integration with Active Model features. This matters when forms submit string values that need conversion.

# app/models/event.rb
class Event < ApplicationRecord
  # Database columns: starts_at (datetime), ends_at (datetime), name (string)
  
  # Virtual attributes with proper type casting
  attribute :start_date, :date
  attribute :start_time, :string
  attribute :duration_hours, :integer, default: 1
  attribute :notify_attendees, :boolean, default: false
  
  before_validation :combine_start_datetime
  
  validates :start_date, presence: true, if: -> { starts_at.blank? }
  validates :duration_hours, numericality: { greater_than: 0, less_than: 25 }
  
  private
  
  def combine_start_datetime
    return if start_date.blank?
    
    time = start_time.present? ? Time.parse(start_time) : Time.parse("09:00")
    self.starts_at = start_date.to_datetime.change(
      hour: time.hour,
      min: time.min
    )
    self.ends_at = starts_at + duration_hours.hours
  end
end

The attribute declaration handles type coercion automatically. When a form submits "true" as a string, the boolean type converts it properly. The integer type ensures "3" becomes 3.

Populating Virtual Attributes from Database Values

Virtual attributes need initialization when editing existing records. Without this, forms appear blank even when the underlying data exists.

# app/models/event.rb
class Event < ApplicationRecord
  attribute :start_date, :date
  attribute :start_time, :string
  attribute :duration_hours, :integer, default: 1
  
  after_initialize :populate_virtual_attributes, if: :persisted?
  
  private
  
  def populate_virtual_attributes
    return unless starts_at.present?
    
    self.start_date = starts_at.to_date
    self.start_time = starts_at.strftime("%H:%M")
    self.duration_hours = ((ends_at - starts_at) / 1.hour).to_i if ends_at.present?
  end
  
  def combine_start_datetime
    return if start_date.blank?
    
    time = start_time.present? ? Time.parse(start_time) : Time.parse("09:00")
    self.starts_at = start_date.to_datetime.change(
      hour: time.hour,
      min: time.min
    )
    self.ends_at = starts_at + duration_hours.hours
  end
end

The after_initialize callback runs when loading records from the database. The if: :persisted? condition prevents it from running on new records where these values should come from form input or defaults.

Virtual Attributes in Forms

Form helpers work identically with virtual attributes. Rails doesn't distinguish between database-backed and virtual attributes in views.

# app/views/events/_form.html.erb
<%= form_with model: @event do |f| %>
  
<%= f.label :name %> <%= f.text_field :name %>
<%= f.label :start_date %> <%= f.date_field :start_date %>
<%= f.label :start_time %> <%= f.time_field :start_time %>
<%= f.label :duration_hours %> <%= f.number_field :duration_hours, min: 1, max: 24 %>
<%= f.label :notify_attendees %> <%= f.check_box :notify_attendees %>
<%= f.submit %> <% end %>

Remember to permit these attributes in the controller:

# app/controllers/events_controller.rb
class EventsController < ApplicationController
  def create
    @event = Event.new(event_params)
    
    if @event.save
      notify_attendees(@event) if @event.notify_attendees
      redirect_to @event
    else
      render :new, status: :unprocessable_entity
    end
  end
  
  private
  
  def event_params
    params.require(:event).permit(
      :name,
      :start_date,      # virtual
      :start_time,      # virtual  
      :duration_hours,  # virtual
      :notify_attendees # virtual
    )
  end
  
  def notify_attendees(event)
    EventNotificationJob.perform_later(event.id)
  end
end

Computed Virtual Attributes for Display

Read-only virtual attributes work well for display logic that doesn't belong in views. These don't need the attribute declaration—simple methods suffice.

# app/models/user.rb
class User < ApplicationRecord
  # Database columns: first_name, last_name, email, created_at
  
  def full_name
    [first_name, last_name].compact_blank.join(" ").presence || email.split("@").first
  end
  
  def initials
    full_name.split.map { |part| part[0].upcase }.join
  end
  
  def member_since
    created_at.strftime("%B %Y")
  end
  
  def account_age_days
    (Date.current - created_at.to_date).to_i
  end
end

For writable computed attributes like full_name, add a setter that parses the input:

# app/models/user.rb
class User < ApplicationRecord
  def full_name
    [first_name, last_name].compact_blank.join(" ")
  end
  
  def full_name=(value)
    parts = value.to_s.split(/\s+/, 2)
    self.first_name = parts.first
    self.last_name = parts.second
  end
end

Validation with Virtual Attributes

Virtual attributes integrate fully with Active Record validations. Conditional validations handle cases where attributes should only validate in certain contexts.

# app/models/registration.rb
class Registration < ApplicationRecord
  attribute :terms_accepted, :boolean, default: false
  attribute :age_confirmed, :boolean, default: false
  attribute :password_confirmation, :string
  
  validates :terms_accepted, acceptance: { accept: true, message: "must be accepted" }
  validates :age_confirmed, acceptance: { accept: true }, if: :age_restricted_event?
  validates :password_confirmation, presence: true, on: :create
  validate :passwords_must_match, if: -> { password_confirmation.present? }
  
  private
  
  def age_restricted_event?
    event&.age_restricted?
  end
  
  def passwords_must_match
    return if password == password_confirmation
    errors.add(:password_confirmation, "doesn't match password")
  end
end

Common Mistakes to Avoid

Forgetting to permit virtual attributes: Strong parameters block attributes not explicitly permitted. Virtual attributes need permitting just like database columns.

Not handling nil values: Virtual attribute setters receive nil when forms submit blank fields. Use to_s, presence, or explicit nil checks.

Overusing after_initialize: This callback runs on every instantiation, including when building objects in loops. Keep the logic lightweight or use lazy initialization.

Mixing concerns: If a virtual attribute requires complex business logic, consider whether a Form Object (covered in a previous article) provides better separation.

When to Use Virtual Attributes vs Alternatives

Virtual attributes work best for simple transformations and form conveniences. For complex multi-model forms, Form Objects provide better structure. For heavy computation, consider caching results in real database columns updated via callbacks.

The deciding factor: if the logic stays simple and scoped to a single model, virtual attributes keep code consolidated and maintainable. When complexity grows, extract to dedicated objects.

10 claps
← Back to Blog