Rails Enums: A Practical Guide
Learn how Rails enums work: define, map, validate, query, and evolve them safely with practical Active Record examples.
By Jean Emmanuel Cadet • 21 min read
Almost every Rails application has a column that can only hold a handful of known values: an order that is pending or shipped, a user who is an admin or a member, a post that is a draft or published. You could store these as free-form strings and hope nobody makes a typo, or you could let Rails do the work for you. That is exactly what Rails enums are for.
In this guide, you will learn how Ruby on Rails enums work, how Active Record enums map readable names to stored database values, and how to use the methods and scopes Rails generates for you. We will also cover migrations, validation, forms, queries, testing, and the part that trips up many developers in production: changing an enum safely after real data exists.
Examples use modern Rails syntax (Rails 7 and 8). Wherever behavior depends on the Rails version, I will call it out so you do not copy outdated code by accident.
What Are Rails Enums?
A Rails enum is an Active Record feature that maps a fixed set of readable names to values stored in a database column. In your code you work with names like pending and shipped. In your database, Rails usually stores small integers like 0 and 2.
The idea is simple: instead of scattering raw values and magic numbers across your application, you declare the allowed values once, in the model, and Rails builds a small API around them.
Here is what an enum gives you:
- A named list of allowed values for an attribute
- Predicate methods such as
order.pending? - Bang methods such as
order.shipped! - Query scopes such as
Order.pending - A class-level hash of every available value
An enum is not a separate database type that Rails invents. It is a layer on top of an ordinary column (usually an integer column), implemented in Active Record's attribute system.
Why Use Enums in Rails?
Enums are useful whenever an attribute can only be one of a small, application-defined set of values. A few common use cases:
- Order statuses:
pending,processing,shipped,delivered,cancelled - User roles:
member,editor,admin - Payment states:
unpaid,paid,refunded,failed - Publication states:
draft,published,archived
Compared to raw strings or integers, enums give you readable code, less typo risk, free helper methods, and one obvious place that documents the valid values. When a new developer joins your team, they can open the model and immediately see every state an order can be in.
Defining an Enum in Active Record
Let's start with a practical example, an Order model with a status:
class Order < ApplicationRecord
enum :status, {
pending: 0,
processing: 1,
shipped: 2,
delivered: 3,
cancelled: 4
}
end
Here is what each part does:
enumis the Active Record class method that declares the enum.:statusis the attribute name. It must match an existing column (or a declared attribute) on the model.- The hash maps each readable name (the key) to the value stored in the database (the integer).
With this single declaration, Rails reads the integer 2 from the database and gives you "shipped" in Ruby. When you assign :shipped, Rails writes 2 back to the column.
Enum syntax across Rails versions
Syntax has changed over the years, so check which Rails version you are on:
- Rails 7.0 and later: the positional form
enum :status, { ... }is supported. This is the form to use in new code. - Rails 7.2: the older keyword form
enum status: { ... }is deprecated. - Rails 8.0: the keyword form is removed, so only
enum :status, { ... }works.
If you are upgrading an older codebase, you will often see enum status: { pending: 0 }. Convert it to the positional form before moving to Rails 8.
Generated Methods and Scopes
Declaring an enum adds a set of methods and scopes. Let's walk through them with the Order model.
Checking the current value with predicates
order = Order.new
order.pending? # => true
order.shipped? # => false
order.status # => "pending"
Each value gets a predicate method ending in ?. They are handy in conditionals and views, and they read much better than order.status == "pending". Notice that order.status returns the string name, not the stored integer.
Changing the value with bang methods
order.shipped!
order.shipped? # => true
A bang method assigns the value and saves the record immediately, using update!. It raises an exception if validation fails or the save cannot complete. Because it goes through a normal save, validations and Active Record callbacks run as usual, so do not treat a bang method as a silent database poke.
Querying with generated scopes
Order.pending # all pending orders
Order.not_pending # all orders that are not pending
Every value gets a scope with its name, plus a negated not_ scope. In SQL, Order.pending produces something like:
SELECT "orders".* FROM "orders" WHERE "orders"."status" = 0;
You write readable Ruby, and Rails translates the name into the stored integer for you.
Listing the available values
Order.statuses
# => {"pending"=>0, "processing"=>1, "shipped"=>2, "delivered"=>3, "cancelled"=>4}
Order.statuses.keys
# => ["pending", "processing", "shipped", "delivered", "cancelled"]
The pluralized class method returns the full mapping. It is the right source for building select inputs, filters, and API documentation, because it always stays in sync with the model.
Avoiding name collisions with prefix and suffix
If two enums on the same model share a value name (for example, both status and payment_status have pending), the generated methods would collide, and Rails raises an error. Use prefix: or suffix: to keep them apart:
class Order < ApplicationRecord
enum :status, { pending: 0, processing: 1, shipped: 2, delivered: 3, cancelled: 4 }
enum :payment_status, { unpaid: 0, paid: 1, refunded: 2 }, prefix: :payment
end
order.payment_paid? # => false
Order.payment_refunded # scope for refunded payments
prefix: true uses the attribute name as the prefix (payment_status_paid?), while a symbol lets you choose something shorter, like :payment.
Assigning and Updating Enum Values
You can change an enum value in three ways:
order.status = :shipped # assign, then save later
order.status = "shipped" # strings work too
order.update(status: :shipped) # assign and save, returns true or false
order.shipped! # assign and save, raises on failure
Rails accepts symbols and strings, and even the underlying integer, but you should stick to names. Names make the intent obvious and keep your application code independent from stored values.
Which one should you pick? Use update when you want to handle failure yourself (for example, in a controller that re-renders a form). Use the bang method when a failure would be unexpected and an exception is acceptable, such as inside a background job.
Database Storage and Migrations
This is the part many tutorials gloss over, and it is where enum bugs usually begin.
How Rails stores the value
An integer-backed enum stores the integer from your mapping in an integer column. With the Order example, the table looks like this:
id | status |
|---|---|
1 | 0 |
2 | 2 |
3 | 4 |
Rows 1, 2, and 3 are a pending, a shipped, and a cancelled order, but the database only knows the numbers. The mapping between names and numbers lives in your Ruby code, not in the database. That detail matters a lot when you change an enum later.
Creating the column with a migration
Here is a realistic migration for adding a status enum column to an existing orders table:
class AddStatusToOrders < ActiveRecord::Migration[8.0]
def change
add_column :orders, :status, :integer, default: 0, null: false
add_index :orders, :status
end
end
Let's break it down:
:integermakes this an integer-backed enum, the most common choice.default: 0means new rows start aspending(the value mapped to0).null: falseguarantees every row has a status, so you never have to handle a missing state.add_indexspeeds up filtering by status. See database indexes in Rails for when an index is worth it and when it is not.
The column must exist before the model declares the enum. In newer Rails versions, defining an enum for an attribute that has no backing column (or declared attribute) raises an error, so run the migration first.
Model-level default vs database-level default
You can set a default in two places, and they are not the same thing.
A database-level default (default: 0 in the migration) is applied by the database itself, for any insert, whether it comes from Rails, a SQL console, or another service. Rails also reads the column default, so Order.new.status returns "pending" automatically.
A model-level default uses the default: option on the enum (Rails 7.0 and later):
class Order < ApplicationRecord
enum :status, {
pending: 0,
processing: 1,
shipped: 2,
delivered: 3,
cancelled: 4
}, default: :pending
end
This sets the value on new Active Record objects, using the readable name. It does not change the database schema, so inserts that bypass Rails will not get it.
For important columns, use both: a database default as the safety net and a model default so the intent is visible in the model. Just keep them pointing at the same value.
Arrays vs Hashes in Enum Definitions
Rails lets you define an enum with an array or with a hash. They look similar but behave very differently over time.
# Array definition
enum :status, [:pending, :published, :archived]
# Hash definition with explicit mapping
enum :status, {
pending: 0,
published: 1,
archived: 2
}
Both examples use the positional syntax that works in Rails 7.0 and later, and both produce the same mapping today. With an array, Rails assigns integers by position: the first item is 0, the second is 1, and so on. The mapping is implicit.
The risk is that the order of the array becomes your data format. Suppose you later reorder it:
enum :status, [:pending, :archived, :published]
Rows that were stored as 1 (published) now read as archived, and rows stored as 2 read as published. Nothing raises an error, and no data changed in the database. Your application simply reinterprets every existing row. Removing an item from the middle of an array causes the same silent shift.
Explicit hash mappings avoid this. Each name is tied to a specific number, so you can reorder lines, remove a value, or add a new one without changing the meaning of stored data. When your application will live for years and enum values will evolve, prefer explicit mappings.
Arrays are fine for small, stable lists on a new project, but if you have any doubt, use a hash. It costs you a few extra characters.
Querying Records by Enum Values
Enums work naturally with Active Record queries. Because the generated scopes and the where method both understand names, you rarely need the raw integers.
Finding records by value
Order.where(status: :shipped)
Order.where(status: [:pending, :processing])
Order.where.not(status: :cancelled)
The first query finds shipped orders, the second finds orders in either of two states, and the third excludes cancelled orders. Rails converts each name to its stored integer before building the SQL.
Combining enum conditions with other conditions
Generated scopes chain like any other scope:
Order.pending.where(created_at: 1.week.ago..)
Order.processing.where(user_id: current_user.id).order(created_at: :desc)
Reading these lines, you immediately understand the business meaning, which is the main reason to prefer scopes over hand-written SQL.
Counting records by status
Order.group(:status).count
# => {"pending"=>12, "processing"=>5, "shipped"=>31, "delivered"=>140, "cancelled"=>3}
Grouping by an enum attribute returns the readable names as keys, which makes dashboards and reports easy to build.
Filtering by a selected value
When a user picks a status from a filter dropdown, never pass the raw parameter straight into your code without checking it:
class OrdersController < ApplicationController
def index
@orders = Order.all
@orders = @orders.where(status: params[:status]) if Order.statuses.key?(params[:status])
end
end
Order.statuses.key? acts as an allowlist. Unknown values are ignored instead of causing surprising behavior.
Readable queries vs raw integers
You can query the underlying integer directly, but you should avoid it:
Order.where(status: 2) # works, but what does 2 mean?
Order.where("status = 2") # bypasses the enum entirely
Order.where(status: :shipped) # clear and maintainable
Raw integers couple your queries to the mapping, so renumbering a value would break them silently. Names keep the mapping in a single place.
On larger tables, an enum column with only a handful of values has low selectivity, so an index on status alone helps less than you might expect. Composite indexes such as [:status, :created_at] often match real query patterns better.
Enum Validations and Invalid Assignments
What happens when someone assigns a value that is not in the enum? The answer depends on your Rails version and options, so let's be precise.
Valid assignments
order.status = :shipped # fine
order.status = "shipped" # fine
Invalid assignments by default
In the default behavior, assigning a value that is not in the mapping raises an ArgumentError immediately:
order.status = :lost
# => ArgumentError: 'lost' is not a valid status
This happens at assignment time, before validations run. That means a standard validation like validates :status, inclusion: { in: Order.statuses.keys } will not catch it, because the exception is raised before the validation has a chance to execute.
The validate option (Rails 7.1 and later)
Rails 7.1 added a validate: option that turns invalid assignments into ordinary validation errors:
class Order < ApplicationRecord
enum :status, {
pending: 0,
processing: 1,
shipped: 2,
delivered: 3,
cancelled: 4
}, default: :pending, validate: true
end
Now the same assignment no longer raises:
order = Order.new(status: :lost)
order.valid? # => false
order.errors[:status] # => ["is not included in the list"]
The record fails validation, so save returns false and nothing is persisted. If the attribute is allowed to be nil, pass options to the validator: validate: { allow_nil: true }.
Handling invalid values on older Rails versions
If you are on a version before 7.1 (or you choose not to use validate: true), the exception is your signal. You have two common options: check the value before assigning it, or rescue the error where user input enters your application.
def update
if Order.statuses.key?(params.dig(:order, :status))
# safe to assign
else
@order.errors.add(:status, "is not a valid status")
end
end
Do not assume every Rails version treats invalid enum values the same way. Check your version, and prefer validate: true where it is available, since it keeps the failure inside Rails' normal validation flow.
Presence validation
Enums are also a good place for a presence rule if null: false is not enforced in the database:
validates :status, presence: true
Using Enums in Forms
Enums fit nicely into forms, because Order.statuses already contains the list of options. The goal is to show friendly labels to users while submitting stable values.
Building a select input
First, add a small label layer to the model so the view does not depend on internal names:
class Order < ApplicationRecord
enum :status, {
pending: 0,
processing: 1,
shipped: 2,
delivered: 3,
cancelled: 4
}, default: :pending, validate: true
def self.status_options
statuses.keys.map { |key| [human_status(key), key] }
end
def self.human_status(key)
I18n.t("activerecord.attributes.order.statuses.#{key}")
end
def status_label
self.class.human_status(status)
end
end
Then add the labels to your locale file. This is also what makes translation possible later:
en:
activerecord:
attributes:
order:
statuses:
pending: "Pending"
processing: "Being prepared"
shipped: "On its way"
delivered: "Delivered"
cancelled: "Cancelled"
Now the form:
<%= form_with model: @order do |form| %>
<div>
<%= form.label :status %>
<%= form.select :status, Order.status_options %>
<% @order.errors.full_messages_for(:status).each do |message| %>
<p class="field-error"><%= message %></p>
<% end %>
</div>
<%= form.submit %>
<% end %>
Here is how it works:
- Human-readable labels:
status_optionsreturns pairs like["Being prepared", "processing"]. The user sees the label. - Submitted value: the form submits the key (
"processing"), not the integer. Rails converts it when assigning. - Validation errors:
errors.full_messages_for(:status)displays problems next to the field, which works well withvalidate: true. - Hidden internals: users never see the integers, and your view never mentions them.
In the controller, permit the attribute like any other:
def order_params
params.expect(order: [:status])
end
params.expect is the Rails 8 style. On earlier versions, use params.require(:order).permit(:status).
A note on security: only permit status in actions where the current user is allowed to change it. A customer should not be able to mark their own order as delivered just by editing the form data. If your form also edits associated records, the same permit rules apply there, and nested attributes are worth understanding before you add them.
Changing Enums Safely
Adding the first version of an enum is easy. Evolving it once production data exists is where care is required. The central fact to remember: the database stores numbers, and your code defines what those numbers mean. If the code and the data disagree, Rails will not warn you.
Adding a new value
With an explicit hash, adding a value is the safest change. Pick the next unused integer:
enum :status, {
pending: 0,
processing: 1,
shipped: 2,
delivered: 3,
cancelled: 4,
refunded: 5
}, default: :pending, validate: true
No migration is needed for an integer column, because 5 fits as-is. But think about deployment order. During a rolling deploy, an older server may read a row with status = 5 that its code does not know about, and the attribute comes back as nil. To avoid this, deploy the code that knows about refunded first, and only afterward enable the feature that writes it.
Renaming a value
With integer-backed enums, a rename is mostly a code change, because the stored integer stays the same:
# before
processing: 1
# after
packing: 1
The data does not change, but every reference does: predicates (order.processing?), scopes (Order.processing), bang methods, locale keys, API clients, background job arguments, and any saved filters. Search for all of them. If outside systems depend on the old name, support both names for a transition period before removing the old one.
Removing a value
Removing a value that records still use leaves rows that Rails cannot map. The safe order is:
- Stop writing the old value in the application.
- Move existing rows to a replacement value.
- Deploy the change that removes the key from the enum.
A data migration for step 2 can be a plain SQL update, which avoids depending on the model in the migration:
class MoveReturnedOrdersToRefunded < ActiveRecord::Migration[8.0]
def up
execute "UPDATE orders SET status = 5 WHERE status = 6"
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
Never reuse the removed integer for a different meaning, because old rows, backups, and exports may still contain it. If a backfill touches several tables and must succeed or fail together, wrap it in a database transaction.
Changing integer mappings
Changing the integer attached to an existing name is the dangerous one. If shipped: 2 becomes shipped: 3, every existing shipped order is now read as delivered, and every delivered order as something else. The data is still "valid", but its meaning changed, and no error appears anywhere.
If you truly must renumber, treat it as a data migration: add a new column, backfill it with converted values, switch the model, and drop the old column later. Never edit the mapping and hope for the best.
A practical safe-change checklist
- Use explicit hash mappings from day one
- Add new values at the end with new integers
- Deploy code that understands a value before any code writes it
- Migrate data before removing a value
- Never recycle old integers
- Search the whole codebase for names before renaming
Rails Enums vs Alternatives
Enums are a great tool, but not for every situation. Here is how they compare with other options.
Plain string columns. Strings are readable in the database, but nothing restricts them, so typos and inconsistent values creep in. You can combine a string column with an enum (pending: "pending") to get readability and Rails' checks, at the cost of slightly more storage and larger indexes. PostgreSQL also offers native enum types through migrations, which move the allowed-value check into the database.
Integer columns without enum behavior. You get compact storage but none of the helpers, and the meaning of each number lives only in people's heads. An enum is almost always better.
Boolean columns. If a state is genuinely binary and will stay that way (active, verified), a boolean is simpler. If you suspect a third state will appear, an enum is safer.
Lookup tables. If users or admins can create and manage the categories (product types, tags, departments), use a separate table with a foreign key. An enum suits a relatively fixed set of values that developers define and release with code. Anything that changes at runtime belongs in the database as rows.
State machines. Enums store the current state, but they do not control how it changes. Nothing stops code from moving an order straight from cancelled to delivered. When you have complex transitions, guards, or side effects, a state machine library (such as AASM, state_machines, or Statesman) can sit on top of an enum-like column and enforce the workflow.
Testing Enums
You do not need to test that Rails itself works, but you should test the parts of your enum that your application depends on: the mapping, defaults, validation, and the business rules attached to states. Here is a realistic RSpec example for the Order model. The ideas translate easily to Minitest or any other framework.
RSpec.describe Order, type: :model do
describe "statuses" do
it "defines the expected mapping" do
expect(Order.statuses.to_h).to eq(
"pending" => 0,
"processing" => 1,
"shipped" => 2,
"delivered" => 3,
"cancelled" => 4
)
end
it "defaults to pending" do
expect(Order.new.status).to eq("pending")
end
end
describe "generated methods" do
it "responds to predicates" do
order = build(:order, status: :shipped)
expect(order).to be_shipped
expect(order).not_to be_pending
end
it "updates the record with bang methods" do
order = create(:order, status: :pending)
order.cancelled!
expect(order.reload).to be_cancelled
end
end
describe "scopes" do
it "returns only matching records" do
pending_order = create(:order, status: :pending)
create(:order, status: :shipped)
expect(Order.pending).to contain_exactly(pending_order)
end
end
describe "validation" do
it "is invalid with an unknown status" do
order = build(:order, status: "lost")
expect(order).not_to be_valid
expect(order.errors[:status]).to be_present
end
end
end
Testing the mapping explicitly is valuable: if someone reorders or edits the hash, the test fails and forces a conversation. The invalid assignment test assumes validate: true (Rails 7.1 and later). Without that option, assert the exception instead:
it "raises on an unknown status" do
expect { build(:order, status: "lost") }.to raise_error(ArgumentError)
end
If you use the shoulda-matchers gem, you can also write it { is_expected.to define_enum_for(:status).with_values(pending: 0, processing: 1, shipped: 2, delivered: 3, cancelled: 4) }.
Testing business rules tied to states
Enums do not enforce workflows by themselves, so if your application has a rule, write the rule and test it. For example, only a processing order can be shipped:
class Order < ApplicationRecord
class InvalidTransition < StandardError; end
def ship!
raise InvalidTransition, "Only processing orders can be shipped" unless processing?
shipped!
end
end
describe "#ship!" do
it "ships a processing order" do
order = create(:order, status: :processing)
order.ship!
expect(order).to be_shipped
end
it "refuses to ship a cancelled order" do
order = create(:order, status: :cancelled)
expect { order.ship! }.to raise_error(Order::InvalidTransition)
end
end
This is the kind of test that protects real behavior: the rule lives in one place and is verified.
Common Mistakes and Best Practices
Here are the mistakes I see most often, and how to avoid them.
Reordering array-based enum values. Order is your data format. Use explicit hash mappings so reordering cannot rewrite history.
Changing integer mappings without a migration plan. A changed number silently changes the meaning of every stored row. Treat it as a data migration, never a quick edit.
Treating enum values as authorization. A role enum records what a user is. It does not decide what they can do. Enforce permissions with a proper authorization layer and check them on every request.
Assuming enums enforce complex business rules. Enums do not prevent invalid transitions. Add explicit transition methods, or a state machine, when the workflow matters.
Using enums for frequently changing, user-defined categories. If non-developers need to add or rename values, a lookup table is the right tool.
Unclear or inconsistent names. Prefer names that read well as predicates (order.shipped?) and stay consistent across models. Avoid vague values like other or status_2.
Ignoring backward compatibility during deployments. Old and new code run side by side during a rolling deploy. Deploy code that understands a new value before anything writes it.
Relying on internal integers throughout application code. Use names everywhere, including queries and fixtures. Integers should appear in one place only: the enum definition.
Forgetting to test important behavior. At minimum, test the mapping, defaults, validation, and any rule attached to a state.
Keeping enums maintainable
- Use hash mappings with explicit integers
- Keep the definition in one place, near the top of the model
- Add
null: falseand a database default for the column - Use
validate: trueon Rails 7.1 and later - Use
prefix:orsuffix:when several enums share names - Keep labels in locale files, not scattered through views
- Index the column only when your queries justify it
Performance considerations
Enums are cheap. The mapping is a small in-memory hash, the stored values are compact integers, and scopes compile to ordinary WHERE clauses. The real performance concerns are the usual database ones: indexing columns you filter on frequently, avoiding queries on very large tables without supporting indexes, and counting with group instead of loading records. Enum syntax itself will rarely be your bottleneck.
Conclusion
Rails enums give you a clean, convenient way to represent a fixed set of values in an Active Record model. With one declaration, you get readable predicates, bang methods, scopes, and a reliable list of valid values for forms and queries.
The convenience comes with one responsibility: the database stores numbers, and your code decides what they mean. Choose explicit mappings, validate assignments (using validate: true where your Rails version supports it), and change values deliberately, with a plan for existing data and deployments. Do that, and Rails enums will keep your models readable and your data trustworthy as your application grows.