Girders Blog
Notes on building internet applications

Field Guide to the Rails ActiveRecord "Enum"

Aug 2, 2021

The Rails enum allows you to use easy to remember or mnemonic names instead of integer values for a table attribute.

Historical Note: The term enum comes from the C family of languages that performs a similar technique. It is not related to the Ruby Enumerable mixin.

Assume we have an Account model with an integer status attribute from the table. Each value corresponds to a given state of the record. Declaring an enum on status allows the code to use a name of the state instead of the actual integer value.

The simple use case involves a sequential set of integers starting from zero.

class Account
  # ...
  enum status: [:signup, :active, :closed]
  # ...
end

This maps values from the table to the model:

0 => "signup"
1 => "active"
2 => "closed"

Now your model works with the mnemonic name instead of the integer value. Rails handles the translation for you when you select or update your record. Also, it will protect you from assigning incorrect values to the attribute. To test the values, methods for each value are created on your model.

account.status       #=> "active"
account.signup?      #=> false
account.active?      #=> true
account.closed?      #=> false

We can see what the mapping is with this class method:

Account.statuses           #=> {"signup"=>0, "active"=>1, "closed"=>2}
Account.statuses[:active]  #=> 1
Account.statuses["active"] #=> 1 (Hash with indifferent access)
Account.statuses.key(1)    #=> "active"
Account.statuses.has_key?(1) #=> true
Account.statuses.has_value?("active") #=> true

Let’s dive into the specifics.

Getting and Setting Values

NOTE: For brevity, I will concentrate on the “active” name and it’s uses. The “signup” and “closed” names work the same way.

From the account instance, we can get the value of the status as a string. ActiveRecord automatically converts the number into the string name for us.

account.status                   #=> "active"
Account.statuses[account.status] #=> 1

We can set the value using a string, symbol or integer, or nil. Attempting to set an invalid value will raise and error. This will not trigger a save to the database.

account.status = "active"  # Change the value by name as string
account.status = :active   # Also takes the symbol
account.status = 1         # and also by the integer value
account.status = nil       # We can also set it to nothing
account.status = 99        #=> ArgumentError: '99' is not a valid status
account.attributes = {status: :active} # set from a hash

Conditions

To check the value of the status, use a <name>? method such as active?. While you can check against the string value, it may stop working if the enum is updated. Using the method will raise an exception if the definition changes, which will be easier to find and fix.

account.active?            #=> true, same as account.status == "active"
account.status == "active" # true, but use active? instead
account.status.nil?        # Checking for nil
account.status == :active  # false --> This does not work!
account.status == 1        # false --> This does not work!

Since each mnemonic name creates instance methods, you can have naming collisions between two enum declarations. Also, the names shouldn’t match any ActiveRecord or other defined instance method. Rails will throw an error if you have naming collisions.

In this case, try another word (maybe a synonym) or use the _prefix or _suffix options to prefix/suffix the method names. It takes either true (using the attribute name) or any other string.

enum status: [:signup, :active, :closed], _suffix: true
enum billing_status: [:none, :active, :expired], _prefix: :billing

You only may need to adjust one of the declarations, not both. This creates the following family of methods for the “active” name:

account.active_status?     # <name>_<attribute>?  (suffix format)
account.billing_active?    # <prefix>_<name>?     (prefix format)

Type Cast Helpers

The “attribute_before_type_cast” method and “read_value_before_type_cast” methods return the original value of the attribute. However, they don’t work after the value is changed, instead returning the string or symbol you used to set the value. I prefer to use the “Account.statuses” hash instead.

account.status_before_type_cast                #=> 1
account.read_value_before_type_cast("status")  #=> 1
account.status = "closed"                      #=> "closed"
account.status_before_type_cast                #=> "closed" (no longer 1)
account.status_was                             #=> "active"
Account.statuses[account.status]               #=> 2

Updating

Update the record with the <name>! method such as (active!). The update! method can also take a hash with the status value as a string, symbol, integer, or nil. Again, it will raise an error if you pass in invalid value.

account.active!                     # Updates with validations like below
account.update!(status: "active")
account.update!(status: :active)
account.update!(status: 1)
account.update!(status: nil)

Personal side note: for important attributes like status (or any record state), I like to carry a timestamp (status_at in this case) when that state was last changed. You can manage this in a lifecycle block:

before_save do
  self.status_at = Time.now if status_changed? || new_record?
end

Validations

As we saw, using the enum prevents us from setting a bad value. However, existing records may not have a valid or default value set. So it is a good idea to set up a valiation rule for the attibutes:

validates :status, inclusion: { in: statuses.keys }

As this is a class-level declaration, we don’t need to write out Account.statuses.

Scopes

Enum also creates scope query methods for each named value, such as:

Account.active             #=> Account.where(status: 1)
Account.not_active         #=> Account.where("status != ?", 1)

You can disable scope creation with the _scopes option:

enum status: [:signup, :active, :closed], _scopes: false

The mapping also works when you specify the status name in the hash format of where clauses. It does not work with string expressions, nor does it raise an error if you use an invalid value. Invalid values may result in type mismatches in the generated SQL and throw a database exception.

Account.where(status: "active")
Account.where(status: :active)
Account.where(status: [:signup, "active"]) # Use the "status in set" with values
Account.where("status = ?", Account.statuses[:active]) # When you interpolate the value

The following do not work. It passes your bad value into the generated SQL which will fail. It could just not match anything, but it could raise a SQL error.

Account.where(status: :badvalue) # SQL is: "where status = 'badvalue'" which will fail
Account.where("status = ?", "active") # Fails similarly

Default Value

Specify a default value with the _default option:

enum status: [:signup, :active, :closed], _default: "signup"

Using Non-Sequential Numbers

By specifying a hash of name/value pairs, we can override the starting point (zero) and match existing table data.

enum status: {signup: 10, active: 20, closed: 30}

We left “space” between these values so whenever new states or sub-states are introduced, they could be inserted between these values. Imagine if we need a new “suspended” state and it fits between the active and closed state in the account life-cycle. This is easy to insert:

enum status: {signup: 10, active: 20, suspended: 25, closed: 30}

Also, this technique allows us to use “ranges” in well-sorted numerations.

I suggest always using as hash for values like this as it gives you greater flexibility in the future!

Non-Numeric Values

Even if our status column was a text column, we could still use the enum to control it. Remember, the format is {internal_name: "database value"},

enum status: {signup: "new", active: "active", closed: "closed"}

Here, we demonstrate mapping the “new” value in the table to the “signup” name in our application, which helps avoid the conflict with the “new” method in the class as well as gives it a better name. The active and closed values are unchanged, but we now have conditional methods and scopes for this column.

account.signup?
account.active!
Account.closed
account.status = "inactive" # Raises and error on an invalid value

PostgreSQL Enumerated Types

If you are using PostgreSQL, an alternative to Rail’s enum is to define a PostgreSQL enumerated type. We need to do this with raw SQL before the table is defined, which we can run in a migration.

create type account_status_type as enum ('signup', 'active', 'closed');

Next, we can create the column using the type, for example in SQL:

create table accounts (..., status account_status_type not null default 'signup');

Or in a migration to add the column:

add_column :accounts, :status, :account_status_type

Now we add the enum to the model. As shown previouly, we map the enum as strings to have rails enforce the enumeration. The actual internal values are an implementation detail of Postgres. It returns the string value on select, and takes the string value on insert or update.

enum status: {signup: "signup", active: "active", closed: "closed"}

To go all in on this, check out the activerecord-postgres_enum gem. It provides the feature to add create_enum in migrations, use those enums in create_table, and manage values within the enum in your database. Pretty sweet!

Multiple names for a value

While you likely wouldn’t intend to do so, you could give the same value to multiple names:

enum status: {signup: 0, active: 1, closed: 2, delinquent: 2}

Consider this an alias or use to document a legacy name used in other applications. Both names can be used interchangeably, but Rails will only return one of them, and you can’t be sure which one. It may change if the enum pairs are reordered. If you do this, never check or pass the string value around, but any other use will still work.

account.status             #=> "closed" or "delinquent"?
account.closed?            # Good
Account.active             # Good
account.status == "closed" # Bad. Use account.closed?
variable = account.status  # Bad. The returned name string could change
other_account.status = account.status # Good

Comparisons

NOTE: The comparisons sections show more advanced considerations that you probably won’t need and shouldn’t implement unless you do.

Enum only supports equality comparisons such as:

account.active?
account.status == other_account.status

When your mapping is ordered in a way that supports less-than and greater-than values, we can create comparisons to support these tests and ranges.

Never compare with another value string. It compares as the string values instead of the underlying value.

account.status > "signup"    # Don't do this. Same as testing "active" > "signup"

Instead we need to reach for the values hash.

Account.statuses[account.status] > Account.statuses["signup"]

For ruby, that’s not very readable. Nor does it convey the intent of the condition. Instead, create boolean helper methods.

def open?
  Account.statuses[status] < Account.statuses["closed"]
end

account.open?

Enhance Your Enum

We can go even further. Leverage ruby’s metaprogramming to create an enhanced enum declaration. To try this out, add this inside of app/models/application_record.rb:

# An enum variation for ordered values. Defines <attr>_between?(first, last)
def self.ordered_enum(definitions)
  enum(definitions)
  definitions.each do |name, _|
    define_method("#{name}_between?") do |first, last|
      values = self.class.send(name.to_s.pluralize)
      values[first] <= values[self[name]] && values[self[name]] <= values[last]
    end
  end
end

Then in your model, use “ordered_enum” instead of “enum” and you get the “_between?” method as well.

ordered_enum status: {signup: 0, active: 1, closed: 2}

account.status_between?(:signup, :active)  #=> true or false

Reference

The syntax of the enum declaration is formally:

enum attribute: values, attribute: values, ...
     _default: "name",
     _scopes: boolean,
     _prefix: true|"name",
     _suffix: true|"name"

Where values is an array of names, or hash of names and values. A name can be a symbol or string. While enum can take multiple attributes in a single declaration, it is better to use only one per call to apply the options correctly.