LaunchKit
← All posts
· 16 min read · by The LaunchKit team · 2 views

Rails enum in practice

The LaunchKit boilerplate has exactly one enum, and it is the smallest useful specimen there is:

# member: a regular customer. admin is data only - the /admin console uses HTTP Basic Auth.
enum :role, { member: 0, admin: 1 }, default: :member

Two values, an integer column, a default. app/models/user.rb, one line, sitting between normalizes :email_address and the validations. Everything below is that line taken apart against activerecord 8.1.3.1, plus the questions the autocomplete asks the moment you type enum: what does it actually generate, when do you need prefix, and integer or string.

What that one line generates

Four instance methods and four scopes, and you can count them. The module Active Record builds for User holds [:admin!, :admin?, :member!, :member?] and nothing more. The class gains User.member, User.admin, User.not_member, User.not_admin, and one class method User.roles, which returns {"member" => 0, "admin" => 1} as a HashWithIndifferentAccess.

The whole generator is one method on ActiveRecord::Enum::EnumMethods, comments included:

def define_enum_methods(name, value_method_name, value, scopes, instance_methods)
  if instance_methods
    # def active?() status_for_database == 0 end
    klass.send(:detect_enum_conflict!, name, "#{value_method_name}?")
    define_method("#{value_method_name}?") { public_send(:"#{name}_for_database") == value }

    # def active!() update!(status: 0) end
    klass.send(:detect_enum_conflict!, name, "#{value_method_name}!")
    define_method("#{value_method_name}!") { update!(name => value) }
  end

  if scopes
    # scope :active, -> { where(status: 0) }
    klass.send(:detect_enum_conflict!, name, value_method_name, true)
    klass.scope value_method_name, -> { where(name => value) }

    # scope :not_active, -> { where.not(status: 0) }
    klass.send(:detect_enum_conflict!, name, "not_#{value_method_name}", true)
    klass.scope "not_#{value_method_name}", -> { where.not(name => value) }
  end
end

Read the predicate body twice. admin? is not role == "admin", it is role_for_database == 1, which means the comparison happens on the integer side and never on the string you see in the console. The bang method is an update!, not an assignment, so user.admin! writes to the database immediately and raises on a validation failure. Nobody expects that from a method that looks like a setter, and it is the reason admin! in a callback is usually a bug.

The scopes produce exactly what you would write by hand. User.admin.to_sql is SELECT "users".* FROM "users" WHERE "users"."role" = 1, and User.not_admin.to_sql is the same with !=. No IN, no cast, no function around the column, so an index on role is usable.

One free consequence worth naming: predicate methods are RSpec matchers. The boilerplate's spec/requests/security_pentest_spec.rb posts role: "admin" to the registration endpoint and then asserts expect(User.find_by(email_address: "escalate@example.com")).to be_member, which reads as English only because member? exists.

The syntax most of the search results still show

Search "rails enum" today and a good half of the top results give you enum role: { member: 0, admin: 1 }, with _prefix and _default underscored. That form is not deprecated any more, it is removed. Rails 8.0's Active Record changelog carries one line for it: "Remove deprecated support for defining enum with keyword arguments." The positional form has been available since Rails 7.0, went through a deprecation warning in 7.2, and is now the only form.

What you get on 8.1.3.1 when you paste the old snippet is not a helpful message:

ArgumentError: wrong number of arguments (given 0, expected 1..2)

Because the signature is def enum(name, values = nil, **options) and Ruby's keyword separation routes the whole hash into options, leaving name unfilled. An error about argument count for a call with one visible argument is exactly the kind of removed-deprecation archaeology the Rails 7 to 8 upgrade is full of.

One inherited-looking form does still work, and it is not the old one:

enum :status, active: 0, archived: 1

Name positional, values as trailing keywords. The first two lines of enum handle it explicitly:

def enum(name, values = nil, **options)
  values, options = options, {} unless values
  _enum(name, values, **options)
end

When values is nil the options hash is promoted to be the values. Which means you cannot combine that form with prefix: or default:, since those keywords would land in the same hash and be treated as enum values. The boilerplate writes the braces, and the braces are the form to write.

The prefix exists for the second enum

Add a second enum to a model and the first value name they share raises at class definition time, before any request:

ArgumentError: You tried to define an enum named "visibility" on the model "Account", but this
will generate an instance method "admin?", which is already defined by another enum.

That is detect_enum_conflict!, and the enum prefix option is the answer. prefix: true prepends the attribute name, so declaring both enums with it produces role_member?, role_admin?, visibility_internal?, visibility_admin?, and the matching role_admin! bangs and role_admin scopes. A custom value works too: prefix: :vis gives vis_admin?. suffix: does the same on the other end, which reads better for adjectives, so enum :status, [:active, :archived], suffix: true gives active_status?.

Rails checks three other collisions in the same method. A value named public raises before it ever reaches another enum, because public is already a class method, and the message names it: this will generate a class method "public", which is already defined by Active Record. A value that collides with an attribute method raises as an instance conflict. And a value literally named not_active alongside one named active only logs, since the generated not_active scope and the auto-generated negative scope not_active are the same name:

logger.warn "Enum element '#{potential_not}' in #{self.name} uses the prefix 'not_'." \
  " This has caused a conflict with auto generated negative scopes." \
  " Avoid using enum elements starting with 'not' where the positive form is also an element."

A warning in a log nobody reads, for a scope that now means the opposite of what the reader thinks. Do not name an enum value not_anything.

The enum default is two defaults

default: :member and t.integer :role, default: 0, null: false are both present in the boilerplate, in app/models/user.rb and in db/migrate/20260109112946_create_users.rb, and they are not the same mechanism. The enum default is an Active Record attribute default: it applies to User.new, so User.new.role is "member" before anything touches the database. The column default applies to any INSERT that omits the column, including a raw INSERT, a db/seeds.rb written in SQL, and a fixture loaded outside the model.

Declare one and skip the other and the two paths disagree. Only the column default, and User.new.role is nil until save, so a view calling user.admin? on an unsaved record gets false for a reason that has nothing to do with roles. Only the enum default, and anything inserting without the model writes NULL into a column your code assumes is populated.

Write both, keep them equal, and keep null: false on the column so the disagreement becomes an error instead of a nil.

Integer backing against string backing, measured

Take the position first: back an enum with an integer column, which is what enum :role, { member: 0, admin: 1 } does. Then throw away the usual reason for it, because the usual reason does not hold up.

The storage argument says an integer column is smaller and its index is smaller. On PostgreSQL 17.7, a million rows, ninety percent one value and ten percent the other, one table with role integer and one with role varchar holding 'member' and 'admin':

   what    | heap  |   idx
-----------+-------+---------
 int table | 42 MB | 6792 kB
 str table | 42 MB | 6792 kB

Identical, down to 5406 heap pages and 849 index pages each. pg_column_size on the rows explains it: 36 bytes for the integer row against 39 for the string row, and PostgreSQL aligns tuples to 8 bytes, so 36 and 39 both round up to 40. The index entries round the same way, and even with deduplicate_items = off on both indexes the two come out at 21 MB each.

The difference appears when the labels get long. Swap in 'pending_verification' and 'administrator' and the row grows from 36 bytes to 53, which is a 56 byte tuple after alignment, and the heap goes from 42 MB to 57 MB. The index moves far less, 6792 kB to 6960 kB, because the deduplication that makes a two-value index cheap does not care how long the value is.

So the honest version of the storage argument is narrow: short labels cost nothing extra, long ones cost heap and only marginally cost index, and a low-cardinality btree deduplicates so well that the index is close to free either way. As with the plans in pagination without a gem, page counts reproduce on your machine and a number from mine proves nothing about your row width, so measure your own labels before quoting either.

What an enum string column actually costs is elsewhere, and the next section is the whole of it. What it buys is that SELECT role FROM users is readable in psql, which matters more than people admit when you are debugging production at midnight with no Rails console.

What an inserted value does to an integer column

The array form is where the integer column bites, and the mechanism is that the mapping is positional. enum :state, [:open, :closed] means open is 0 and closed is 1, derived from the order they appear.

Three ticket rows holding the integers 0, 1 and 1, read under two enum declarations. Under the array [:open, :closed] they read open, closed, closed, and after :pending is inserted in the middle of the array the same untouched rows read open, pending, pending.

Three rows, holding 0, 1 and 1. Under the declaration above they read as ["open", "closed", "closed"] and Ticket.closed.count is 2. Add a state in the middle, the way you would if you were thinking about the order the states occur in rather than the integers under them:

enum :state, [:open, :pending, :closed]

The same three rows now read as ["open", "pending", "pending"]. Ticket.closed.count is 0. Ticket.pending.count is 2. Nothing raised, no migration ran, and every closed ticket in the database is now pending. The mapping is {"open" => 0, "pending" => 1, "closed" => 2}, and the rows never moved.

Active Record's own documentation says this plainly, and it is the argument for never using the array form on a persisted column: "once a value is added to the enum array, its position in the array must be maintained, and new values should only be added to the end of the array. To remove unused values, the explicit hash syntax should be used."

Write the hash. { member: 0, admin: 1 } can grow anywhere, in any order, and a new value takes the next unused integer whatever line it is written on.

Renaming a value while the database holds the old one

Renames are where the two backings genuinely diverge, and the divergence runs the opposite way to the storage argument. Change the Ruby label and leave the data alone.

Integer backing, member: 0 becomes customer: 0:

enum :role, { customer: 0, admin: 1 }, default: :customer

Every existing row holding 0 now reads as "customer", customer? is true, and no migration ran. The label was never in the database, so renaming it is a rename in Ruby and nothing else.

String backing, draft: "draft" becomes unpublished: "unpublished":

enum :state, { unpublished: "unpublished", published: "published" }, default: :unpublished

Every existing row holding 'draft' now reads as nil. unpublished? is false. published? is false. The rows are still there and Flag.count still counts them, and they belong to no state at all until you run the UPDATE.

Which is the real verdict on integer against string. Integer backing costs you readability in psql and a lookup table in your head; string backing costs you a data migration every time a word changes. Vocabulary changes more often than schemas do.

The intermediate position is worth naming: a string column with the label decoupled from the stored value, enum :state, { unpublished: "draft", published: "live" }. Readable in SQL, renameable in Ruby, and now there are two names for every state and a reader has to hold both. I would not, but the reasoning is sound and I would not argue with a codebase that did.

Unknown values: one path raises and one goes quiet

Assignment is strict, and EnumType#assert_valid_value is where it happens:

def assert_valid_value(value)
  return unless @_raise_on_invalid_values

  unless value.blank? || mapping.has_key?(value) || mapping.has_value?(value)
    raise ArgumentError, "'#{value}' is not a valid #{name}"
  end
end

ticket.state = "archived", Ticket.new(state: "archived") and ticket.update!(state: "archived") all raise ArgumentError: 'archived' is not a valid state. Loud, immediate, and it means a typo in a controller is a 500 rather than a wrong row. Note value.blank? in that guard: nil and "" sail through, so an enum column without null: false accepts nil silently.

Querying is not strict, and this is the one that costs an afternoon:

Ticket.where(state: "archived").to_sql
# => SELECT "tickets".* FROM "tickets" WHERE "tickets"."state" IS NULL

Zero rows, no exception, no log line. EnumType#serialize is subtype.serialize(mapping.fetch(value, value)), so an unmapped value falls through to the integer subtype, which casts "archived" to nil, and nil in a where becomes IS NULL. A scope built from a params value you did not whitelist returns an empty page forever and looks like a data problem.

The third shape is a row the mapping does not cover, which is what a removed enum value leaves behind. Insert a 7 into a column mapped to 0 and 1, and the record reads back as role=nil, admin?=false, member?=false, and even role_for_database is nil, because deserialize is mapping.key(subtype.deserialize(value)) and key finds nothing. That row is invisible to User.member and invisible to User.admin while User.count still counts it. If you ever delete a value from an enum, the UPDATE that clears the old integers is part of the same deploy, not a follow-up.

The validate option, and what it trades away

Rails 7.1 added validate:, and the changelog entry is one line, "Add validation option for enum", with the behaviour spelled out underneath. What it does is flip the raise into an error on the model:

enum :role, { member: 0, admin: 1 }, validate: true

Now user.role = "owner" assigns. user.role is "owner". user.valid? is false and user.errors.full_messages is ["Role is not included in the list"]. The implementation is two lines apart in _enum: the type is built with raise_on_invalid_values: !validate, and then validates_inclusion_of name, in: enum_values.keys, **validate runs.

Two details that catch people. nil is invalid under validate: true, so a nullable enum column needs validate: { allow_nil: true }, which is the documented form. And the invalid value is still sitting on the attribute, so anything reading user.role between assignment and validation gets "owner" back.

Take validate: true when the value comes straight from a form and you want a field error rather than a 500. Keep the default raise everywhere else, because an ArgumentError at the point of assignment names the line that was wrong, and a validation error names the attribute three layers later.

Where the boilerplate deliberately does not use an enum

One enum in the whole application is a deliberate number, and the two columns that look like enums and are not say more than the one that is.

Subscription#status holds Stripe's vocabulary, and the model says why it is a plain string:

# `status` mirrors Stripe's own subscription status. We store it as a plain string
# (not a Rails enum) on purpose: Stripe owns this vocabulary and may introduce new
# values, and an enum would raise on anything it doesn't know about. The statuses that
# mean "the customer currently has access" are listed here.
LIVE_STATUSES = %w[trialing active].freeze

Which is the previous section read as a design rule. A webhook carrying a status Stripe added last week would hit assert_valid_value and raise inside the webhook handler, and the handler would retry, and retry. A %w[] constant and a live scope give up the predicates and keep the ingestion working.

Message#role is the second one: a string column with an index on it, holding user, assistant and whatever else the LLM layer writes, no enum declared. Same reason, different vendor.

So the scope statement, plainly: the boilerplate does not demonstrate prefixes, suffixes, string backing, validate: or a multi-value enum, because it has one enum with two values and it did not need any of them. Everything above them in this post comes from active_record/enum.rb and from throwaway models run against PostgreSQL 17.7.

Is enumerize still worth installing

Yes, enumerize is alive: version 3.0.0 shipped on 2026-08-04, after 2.8.1 in March 2024, which is a two year gap followed by a major release rather than an abandonment. Around 32 million cumulative downloads. Naming a dead gem is the usual failure mode of a post like this one, and this is not one.

What it offers over enum is a short list, and none of it is the enum itself. Translated labels through I18n with a _text reader, so user.role_text renders in the user's locale instead of you writing a helper. Support for Mongoid, Sequel and plain objects that include ActiveModel::Naming, which matters if the enumerated attribute is not on an Active Record model at all. A multiple: true mode for an attribute holding several values. Form helper integration with SimpleForm and Formtastic that infers the input collection.

The honest verdict: Rails' own enum is enough now, and it has been since the :validate option in 7.1 closed the last real gap. Install enumerize when you need labels in several languages or enums on non-ActiveRecord objects, which is a real need and a narrow one. Do not install it for scopes and predicates you already have.

What would change that: an enum feature Rails will not take, and label i18n is the obvious candidate, since it belongs to the view layer and the framework has declined to guess at it for a decade.

What this post does not cover

PostgreSQL's own enum type is absent, and it is a different thing with the same name. create_enum :role, ["member", "admin"] exists in the PostgreSQL adapter and is dumped into schema.rb, giving you a database-level constraint rather than a Ruby-level one, at the cost of a ALTER TYPE migration every time the vocabulary changes. Worth a post of its own and not a paragraph in this one.

Also absent: MySQL's ENUM column type, which is a third unrelated thing; defined_enums and reflecting over enums at runtime, which mostly serves form builders; and any benchmark comparing integer and string comparison speed in a WHERE clause, because the difference at the cardinalities an application enum actually has is below the noise of everything else in the query.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.