Enum attributes seem simple until they cause production incidents. A misplaced integer, a renamed status, or a forgotten validation can cascade into data corruption that's painful to fix. This guide covers enum patterns that remain maintainable as applications grow.
The Basic Enum Setup
Rails 8 enums map symbolic values to integers stored in the database. The implementation requires careful attention to column types and default values.
# db/migrate/20260207120000_create_orders.rb
class CreateOrders < ActiveRecord::Migration[8.0]
def change
create_table :orders do |t|
t.references :user, null: false, foreign_key: true
t.integer :status, default: 0, null: false
t.integer :payment_state, default: 0, null: false
t.decimal :total, precision: 10, scale: 2
t.timestamps
end
add_index :orders, :status
add_index :orders, [:user_id, :status]
end
end
The model definition uses the modern Rails 8 enum syntax with explicit integer mappings. Never rely on implicit array indexing—explicit hashes prevent disasters when adding new values.
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :user
enum :status, {
pending: 0,
confirmed: 1,
processing: 2,
shipped: 3,
delivered: 4,
cancelled: 5,
refunded: 6
}, validate: true, default: :pending
enum :payment_state, {
unpaid: 0,
authorized: 1,
captured: 2,
partially_refunded: 3,
fully_refunded: 4,
failed: 5
}, validate: { allow_nil: false }, default: :unpaid, prefix: true
end
The Prefix and Suffix Options
When models have multiple enums, method name collisions become likely. The prefix and suffix options namespace the generated methods.
# app/models/shipment.rb
class Shipment < ApplicationRecord
belongs_to :order
# Generates: carrier_usps?, carrier_fedex?, carrier_ups?
enum :carrier, {
usps: 0,
fedex: 1,
ups: 2,
dhl: 3
}, prefix: :carrier
# Generates: delivery_standard?, delivery_express?, delivery_overnight?
enum :service_level, {
standard: 0,
express: 1,
overnight: 2
}, prefix: :delivery
# Custom prefix with different name
# Generates: via_ground?, via_air?, via_sea?
enum :transport_mode, {
ground: 0,
air: 1,
sea: 2
}, prefix: :via
end
# Usage examples:
# shipment.carrier_fedex!
# shipment.delivery_express?
# Shipment.carrier_usps.delivery_standard
The suffix option works similarly but appends rather than prepends, useful when the enum name itself is descriptive enough.
Validation Patterns That Prevent Bad Data
The validate: true option added in Rails 7.1 prevents invalid values from being assigned. Without it, assigning an unknown value raises an ArgumentError—which sounds safe until realizing this happens at assignment time, not save time, making it harder to handle gracefully.
# app/models/ticket.rb
class Ticket < ApplicationRecord
enum :priority, {
low: 0,
medium: 1,
high: 2,
critical: 3
}, validate: true
enum :category, {
bug: 0,
feature: 1,
question: 2,
documentation: 3
}, validate: { allow_nil: true }
# Additional business logic validations
validate :critical_requires_assignee
validate :closed_requires_resolution
private
def critical_requires_assignee
if critical? && assignee_id.blank?
errors.add(:assignee_id, "is required for critical tickets")
end
end
def closed_requires_resolution
if closed? && resolution.blank?
errors.add(:resolution, "must be provided when closing ticket")
end
end
end
With validate: true, invalid assignments add to the errors collection instead of raising exceptions, allowing standard form error handling to work.
Scopes and Query Optimization
Enums automatically generate scopes matching each value. For complex queries, combine these with custom scopes that leverage MySQL indexes effectively.
# app/models/order.rb
class Order < ApplicationRecord
enum :status, {
pending: 0,
confirmed: 1,
processing: 2,
shipped: 3,
delivered: 4,
cancelled: 5,
refunded: 6
}, validate: true
# Composite scopes for common queries
scope :active, -> { where(status: [:pending, :confirmed, :processing, :shipped]) }
scope :completed, -> { where(status: [:delivered, :cancelled, :refunded]) }
scope :requires_attention, -> { where(status: [:pending, :processing]) }
# Negation scope - useful for filtering
scope :not_cancelled, -> { where.not(status: :cancelled) }
# Combined with date ranges for reporting
scope :shipped_between, ->(start_date, end_date) {
shipped.where(updated_at: start_date.beginning_of_day..end_date.end_of_day)
}
# For MySQL, this generates: WHERE status IN (0, 1, 2, 3)
# Ensure composite index exists: add_index :orders, [:status, :updated_at]
end
# Controller usage:
# app/controllers/admin/orders_controller.rb
class Admin::OrdersController < AdminController
def index
@orders = Order.includes(:user)
.then { |scope| filter_by_status(scope) }
.order(created_at: :desc)
.page(params[:page])
end
private
def filter_by_status(scope)
return scope if params[:status].blank?
return scope.active if params[:status] == "active"
return scope.completed if params[:status] == "completed"
scope.where(status: params[:status])
end
end
Safe Transitions with State Guards
Raw enum assignments allow any transition. For business logic that requires specific state flows, add transition guards without reaching for external gems.
# app/models/order.rb
class Order < ApplicationRecord
enum :status, {
pending: 0,
confirmed: 1,
processing: 2,
shipped: 3,
delivered: 4,
cancelled: 5,
refunded: 6
}, validate: true
ALLOWED_TRANSITIONS = {
pending: [:confirmed, :cancelled],
confirmed: [:processing, :cancelled],
processing: [:shipped, :cancelled],
shipped: [:delivered],
delivered: [:refunded],
cancelled: [],
refunded: []
}.freeze
def transition_to(new_status)
new_status = new_status.to_sym
unless can_transition_to?(new_status)
errors.add(:status, "cannot transition from #{status} to #{new_status}")
return false
end
update(status: new_status)
end
def can_transition_to?(new_status)
ALLOWED_TRANSITIONS.fetch(status.to_sym, []).include?(new_status.to_sym)
end
def available_transitions
ALLOWED_TRANSITIONS.fetch(status.to_sym, [])
end
# Convenience methods with business logic
def confirm!
return false unless transition_to(:confirmed)
OrderMailer.confirmed(self).deliver_later
true
end
def cancel!(reason:)
transaction do
return false unless transition_to(:cancelled)
update!(cancellation_reason: reason, cancelled_at: Time.current)
refund_payment if payment_state_captured?
true
end
end
end
Human-Readable Display Values
Enum values need formatting for user interfaces. Rails 8 integrates cleanly with I18n for localized display names.
# config/locales/en.yml
en:
activerecord:
attributes:
order:
statuses:
pending: "Awaiting Confirmation"
confirmed: "Order Confirmed"
processing: "Being Prepared"
shipped: "On Its Way"
delivered: "Delivered"
cancelled: "Cancelled"
refunded: "Refunded"
# app/models/order.rb
class Order < ApplicationRecord
def status_label
I18n.t("activerecord.attributes.order.statuses.#{status}")
end
def self.status_options_for_select
statuses.keys.map do |status|
[I18n.t("activerecord.attributes.order.statuses.#{status}"), status]
end
end
end
# app/views/orders/_status_badge.html.erb
<%= order.status_label %>
MySQL Storage Considerations
MySQL's TINYINT uses only 1 byte for values 0-127, making it ideal for enums with fewer options. For enums that might exceed this range, explicitly use INTEGER.
# db/migrate/20260207130000_optimize_enum_columns.rb
class OptimizeEnumColumns < ActiveRecord::Migration[8.0]
def change
# TINYINT for small enums (1 byte, -128 to 127)
change_column :orders, :status, :integer, limit: 1, default: 0, null: false
change_column :orders, :payment_state, :integer, limit: 1, default: 0, null: false
# Keep as regular INTEGER if enum might grow significantly
# change_column :events, :event_type, :integer, default: 0, null: false
end
end
The limit: 1 option creates a TINYINT column in MySQL, reducing storage and improving index performance for high-volume tables.
Common Mistakes to Avoid
Several patterns cause maintenance headaches with enums:
- Array syntax for enum values — Adding or removing values shifts all subsequent integers, corrupting existing data
- Missing NOT NULL constraint — NULL enum values bypass validations and break scopes
- No default value — Forces explicit assignment everywhere, increasing code verbosity
- Skipping the index — Enum columns used in WHERE clauses need indexes for query performance
- Changing integer mappings — Once deployed, the integer-to-symbol mapping is permanent; add new values, never reassign existing integers
Summary
Enum attributes provide a clean interface for status fields when implemented with explicit integer mappings, proper validations, and thoughtful transition guards. The Rails 8 validate: true option catches invalid assignments at the model layer. Combined with prefix/suffix options for method namespacing and I18n for display values, enums remain maintainable as applications scale. Always use explicit hash syntax for mappings, add database indexes for query performance, and consider TINYINT columns in MySQL for storage optimization.