Six Amazing Ways to Create Enumerated Types in Rails and Postgres

Matthew • July 13, 2022

A line drawing of a smiley face holding up six fingers next to a computer monitor that's displaying the word "enum".

Here are six (amazing!) ways to implement enumerated types in Rails running on top of Postgres, at least one of which should suit you depending on your needs, current Rails version, and database of choice.

Let's get right to it, yeah?

Scenario: Order status

For this example, let's consider an Order class that keeps track of its status in a field called, uhh, "status." The status has the values: initializing, open, closed, or canceled.

Way 1: Build your own

In the olden days, when the first protons and neutrons were just forming, we had to implement an enumerated type ourselves (or maybe there was a gem to do that already.)

Basically, you'd declare an integer field in your model.

class CreateOrders < ActiveRecord::Migration[7.0]
  def change
    create_table :orders do |t|
      t.integer :status, null: false
    end
  end
end

Then you'd create a Hash constant and write a bunch of accessor methods that looked something like this:

class Order < ApplicationRecord

  STATUSES = {
    initializing: 0,
    open: 1,
    closed: 2,
    canceled: 3
  }


  def initializing!
    update!(status: STATUSES[:initializing])
  end


  def initializing?
    status == STATUSES[:initializing]
  end

  # And so on for each member of the enumeration...

end

Here's what the Order looks like from the Rails console:

> Order.first
#<Order:0x00007f7ba0b6db40 id: 1, status: 0> 

Note that we see our enumerated type's value as a somewhat cryptic integer, not our nice descriptive name as in the code. That's a bummer.

The same is true when we look at the data from the database console:

db=# select * from orders;
 id | status 
----+--------
  1 |      0 
  2 |      1 
  3 |      2 
  4 |      3 
  5 |      0 

And that's it. On a positive note, this code is straightforward: it's easy enough to write and read. It doesn't have any external dependencies. But, if you're creating new enumerations, adding values to existing ones, or adding new functionality, writing this code sure gets tiresome quickly.

So, maybe you'd do some metaprogramming and create a DSL instead of doing all that coding. (Good news! You'll learn that somebody else did that for you, so read on, friend.)

Way 2: Using Rails enum with integers

So, low and behold, in late 2013, Rails 4.1.0 introduced support for enumerated types. So, say goodbye to your boilerplate or homegrown DSL! You can replace you code with the enum DSL.

It looks like this in Rails 7:

class Order < ApplicationRecord
  enum :status, {
    initializing: 0,
    open: 1,
    closed: 2,
    canceled: 3
  }
end

The syntax is slightly different before Rails 7.0:

class Order < ApplicationRecord
  enum status: {
    initializing: 0,
    open: 1,
    closed: 2,
    canceled: 3
  }
end

(I've omitted the migration because it's the same as the one shown in Way 1.)

Now you don't have to do a bunch of metaprogramming because DHH did it for you! (And since then, a lot of other folks. Aside: looking at the source code with git blame is a lovely example of the community making a feature more robust over time.)

We get a bunch of features like predicates and setters for free. You can also customize how the generated method names and more. I'm only touching on some of it here, so make sure to check out the documentation.

> Order.first.respond_to?(:initializing!)
 => true                                                                                        
> Order.first.respond_to?(:initializing?)
 => true                                                                                        

Back to the data. If we create an Order and look at it from the Rails console, now this is what we see:

> Order.last
#<Order:0x00007fe7f77c6eb0 id: 24, status: "canceled">

Note that the status is displayed as a string! But didn't we store an integer? Friend, that's some Rails magic at work. In the database, we're still storing an integer.

# select id,status from orders;
 id | status                         
----+--------                         
  1 |   0
  2 |   1
  3 |   2

Now, that integer is compact and performant, but it sure is cryptic if you're accessing the data outside Active Record! What do I need to do to have the status represented by a descriptive name in the database?

Way 3: Using Rails enum with strings

It turns out we're able to use strings as well as integers as enumerated type values! And Rails will store those strings in the database. I believe this feature was available no later than 2015 in Rails 5.0, but it wasn't documented until Rails 7.0 in October 2021!

So, here's our example again, but this time using strings as values.

class Order < ApplicationRecord
  enum :status, {
    initializing: "initializing",
    open: "open",
    closed: "closed",
    canceled: "canceled"
  }
end

Remember that the syntax is slightly different prior to Rails 7. (See the previous Way for details.)

This is what the migration looks like:

class CreateOrders < ActiveRecord::Migration[7.0]
  def change
    create_table :orders do |t|
      t.string :status, null: false
    end
  end
end

And now, here's what the data looks like from the Rails console:

> Order.last
#<Order:0x00007fd76c7d1900 id: 12, status: "canceled">

Once again, we see that the status is displayed as a string, which should be no surprise...since we stored a string! So, we see strings in the data when accessing the database outside of Active Record:

# select id,status from orders;    
 id |  status                        
----+--------------
  1 | initializing
  2 | open
  3 | closed
  4 | canceled

I've come to prefer this approach if the approaches coming up later in this post are unavailable.

As I've built more applications, I've become more sensitive to data integrity and what I'll call "data explicability." That is, I want to make the database as understandable and self-documenting as possible without the context of the Rails application because the data is more important than the code. (That is a topic for another blog post.)

Does this approach use more storage? No doubt. Is it less performant than using integers? It must be! But I'd argue that those tradeoffs are offset by the improvement in understanding your data. If you're building a Google-sized something, those things might be a problem for you, but, friends, most of you aren't Google.

Make sure you've got this column indexed, and you'll be in fine shape! (Often, when taking this approach, I'll also set a check constraint on this column. I'll talk about check constraints in a future blog post.)

Way 4: Using Rails enum with Postgres Enumerated Types (pre-Rails 7)

But wait, there's more! If your app is backed by Postgres, you can use Postgres's enumerated types, too! Enumerated types in Postgres go back at least to 2008's version 8.3, which means they are available in all currently supported Postgres versions. So, you can use them with confidence.

On the Rails side of things, we define the enumerated type using strings as we did in the previous section.

Then we need to define our enumerated type in Postgres. We'll use a migration for that. Alas, we'll have to use some SQL here.

class CreateOrders < ActiveRecord::Migration[6.1]
  def up
    # Define our status type in Postgres
    execute <<-SQL
      CREATE TYPE order_status AS ENUM (
        'initializing', 'open', 'closed', 'canceled'
      );
    SQL

    # Use the type in the table definition
    create_table :orders do |t|
      t.column :status, :order_status, null: false, index: true
    end
  end

  def down
    drop_table :orders

    execute <<-SQL
      DROP TYPE order_status;
    SQL
  end
end

And that's it. It Just Works! Here's what data looks like from the Rails console:

> Order.last
 => #<Order:0x00007f8af6301190 id: 12, status: "canceled">

Nice! We see the string representation of the status. Even though in the database, it's stored in a way that looks a lot like an integer.

When we look at the data in the database console, we see our type's value names instead of integers:

# select id, status from orders;
 id |  status   
----+--------------
  1 | initializing
  2 | open
  3 | closed
  4 | canceled
  5 | initializing
  6 | open

Oh, friend, that's just awesome!

There are a couple of things to keep in mind with this approach, though. Because of the ENUM SQL, you'll need to use the structure.sql file to store your schema, which is kind of a pain if you're not using that already. (If this really bugs you, read on to the next section! Have I got good news for you!)

Also, you'll only be able to add values to the enumerated type. You can't reorder or remove values. These are limitations imposed by Postgres. This has never been a problem for me, but it's something to understand ahead of time.

Way 5: Using Rails enum with Postgres Enumerated Types (post-Rails 7)

Rails 7.0, I was pleased to discover, adds support for defining enumerated types in Postgres! As a result, you can (mostly) avoid SQL in your migrations, and you don't need to use structure.sql.

We'll define the enumerated type using strings as before. See the example above, and remember that the syntax changed slightly in Rails 7.0

And here's our migration, including the star of our show, create_enum.

class CreateOrders < ActiveRecord::Migration[7.0]

  def up
    # Define our status type in Postgres
    create_enum "order_status", %w[ initializing open closed canceled ]

   # Use the type in the table definition
    create_table :orders do |t|
      t.column :status, :order_status, null: false, index: true
    end
  end


  def down
    drop_table :orders

    # Still gotta use SQL to drop it though :(
    execute <<-SQL
      DROP TYPE order_status;
    SQL
  end
end

Note that we still need to write some SQL to drop the type if we roll back the migration. This bit doesn't require us to use structure.sql, so while a little ugly, it's not painful. (Hopefully, some lovely person adds drop_enum sometime soon.)

Way 6: Using a table for the enumerated type

Some folks will argue that the way to implement enumerated types in a relational database is to use a table for the type. Each value of the type is a row in this table.

I'm not a DBA, but I can appreciate this approach. It will let you remove and add values, change the name, and store other data alongside each type's values. You can't do that with Postgres's enumerated types.

But for a Rails app, it's definitely more complicated and more work, so I think I would only use this solution if I had a compelling reason. For example, I might consider this approach if the set of values in the type changed regularly. Or maybe your database doesn't offer enumerated type support. (If you're a database-centric person with more scenarios in which you'd take this approach, I'd love to hear them!)

Anyway, here's the code. First, you'll need to create two tables:

class CreateOrders < ActiveRecord::Migration[7.0]
  def change

    create_table :order_statuses do |t|
      t.string :name, null: false
    end

    create_table :orders do |t|
        t.references :status, foreign_key: { to_table: :order_statuses }, null: false
    end
  end
end

You'll also be creating two models that have an association between them. And with this approach, since we're no longer using the enum DSL, we're back to writing the code to manipulate getting and setting the status value. Here's my naive implementation of that:

# app/models/order.rb
class Order < ApplicationRecord

  belongs_to :status, class_name: "OrderStatus"


  def initializing?
    status == "initializing"
  end


  def canceled?
    status == "canceled"
  end


  def initializing!
    status = OrderStatus.find_by_name("initializing")
  end

 # ... and so on ...
end


# app/models/order_status.rb
class OrderStatus < ApplicationRecord

  has_many :orders, foreign_key: :status_id


  def self.find_or_create_by_name!(name)
    find_or_create_by!(name: name)
  end


  def <=>(other)
    if other.is_a?(String)
      self.name <=> other
    else
      super
    end
  end
end

And when we look at the data in the Rails console, we're back to displaying the status value as an integer. We've lost the intention-revealing name the Postgres enum gave us.

 > Order.last
 => #<Order:0x00007faa6cc1a048 id: 12, status_id: 4>         

And, of course, the same is true when we look at the data in the database via a simple query.

=# select id, status_id from orders;
 id | status_id 
----+-----------
  1 |         1
  2 |         2
  3 |         3

In this case, we can write a slightly more complex query and get easily readable status names.

=# select orders.id, order_statuses.name as status from orders join order_statuses on orders.status_id = order_statuses.id;
 id |    status    
----+--------------
  1 | initializing
  2 | open
  3 | closed
  4 | canceled
  5 | initializing
  6 | open

In closing

Well, there you have it, friend. Those are all the ways I know to model enumerated types in Rails!

Which one you should use will depend on your circumstance: the version of Rails you're using, the database you're using, whether you're dealing with legacy data, and so on.

I think if you're using Rails 7 on top of Postgres, the choice is pretty straightforward: use Postgres enums unless you've got a compelling reason not to. They'll keep your data cleaner, and the intention-revealing names will simplify your life.

Let me know which approach you prefer and why. Drop me an email or hit me up at @mjbellantoni on Twitter. Thanks for reading, and happy enumerating, friends! (If you liked this post, consider signing up for my weekly email!)

Get the latest sent to your inbox once a week!

Receive a weekly update of technical tips, Rails job market analysis, Rails job listings and more!

We'll never share your email address. See our Privacy & Data Policies for more details.


Matthew Bellantoni is a seasoned technology manager experienced growing teams from 0 people to under 100 people, from $0 to $100M in revenue, at companies in SaaS, eCommerce, marketplace, and enterprise software. His time has typically been at fast-growing VC-backed companies, mainly using Ruby on Rails, where he's been senior management usually reporting to the CEO. (He's also written a Ruby on Rails gem with over a million downloads.) You can contact him at matthew@rubyjobboard.com or at @mjbellantoni.