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
endThe 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
endThe 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
endComputed 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
endFor 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
endValidation 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
endCommon 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.