Skip to main content
Test Double company logo
Services
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say Hello
Test Double logo
Menu
Services
BackGrid of dots icon
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
Cycle icon
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say hello
Developers
Developers
Developers
Accelerate quality software

Optimize Rails enums with Postgres: A guide

Struggling with Rails enums? Discover how PostgreSQL can simplify your enums and enhance performance. This guide covers setup, migration, and best practices.
Justin Searls
|
June 2, 2019
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Some people say “e-noom”. Others say “e-numb”. But for some reason, Rails calls its enums “integer”.

That decision—dating back to the feature’s introduction in Rails 4.1—has caused no end of problems for developers and teams that aren’t hyper-vigilant about managing the impedance mismatch between ActiveRecord objects that marshal enum attributes as strings and the underlying database schema that represents enum columns as integers.

If you’ve been using Rails enum attributes and have never had an issue with them, then feel free to stop reading and go about your day. But if you’ve ever run into a tricky gotcha when trying to populate an enum field in a test fixture or when submitting a form, then this post is for you. (Oh, and if you’ve heard horror stories about enums and thus avoided them until now, this post can be for you too.)

Postgres, the best database

As has been the case for several years now, the prescription to today’s ActiveRecord frustration is, yet again, a Postgres feature most people don’t know about.

Going back until at least version 8, Postgres has offered its own mechanism for declaring enumerated types as string constants:

create type rate_type as enum ('hourly', 'weekly');

When you set or read an enum column over a database adapter like ActiveRecord, "hourly" and "weekly" values are represented as strings. As luck would have it, shortly after enum’s initial release, Rails added support for enum attributes backed by strings. Therefore, there’s nothing preventing us from defining a Postgres enum column and declaring a matching ActiveRecord enum attribute by explicitly setting its string value counterparts.

The approach outlined below has several best-of-both-worlds-y benefits:

  • Postgres will store the values internally as constants, requiring just 4 bytes each
  • Postgres will validate that only valid enum values are set, and correctly error if you try to send it a string that doesn’t match a declared enum value
  • Postgres can compare values held in columns of the same enum type across multiple tables, which is quite neat
  • ActiveRecord will still provide convenience methods for enum attributes, like rate_types, weekly? and hourly!
  • Best of all, there will no longer be any awkward translation between strings and ordinal integers, so an entire category of gotchas can be safely erased from our collective memories

So, let’s dive into how to set up ActiveRecord enum attributes backed by Postgres enum columns, for each of a few different scenarios.

Adding an enum to an app using schema.rb

If your app’s migration tasks generate a db/schema.rb file (as is the default), you probably want them to continue to do so. There’s just one problem to deal with first.

See, Rails migrations don’t offer native support for creating enum types, so any enum declarations and any tables containing enum type columns present in your migrations will not be dumped without so much as an error or a warning. (That seemed suboptimal, so I opened this Rails issue for discussion.)

In fact, if you start using PG enums in your migrations, you’ll see entire tables in your schema.rb file replaced with:

# Could not dump table "projects" because of following StandardError
#   Unknown type 'rate_type' for column 'rate_type'

This is not ideal. And it might be weeks or months before anyone notices that the schema.rb is now broken.

Fear not! There is a solution! The activerecord-postgres_enum gem adds awareness of PG enums to Rails migrations, and therefore allows you to write tidier migrations while continuing to be able to rely on your schema.rb file.

With that gem installed, you can write a pretty straightforward migration like this:

class AddDefaultRateTypeToClients < ActiveRecord::Migration[5.2]
  def change
    create_enum :rate_type, ["hourly", "weekly"]

    change_table :clients do |t|
      t.enum :default_rate_type, enum_name: :rate_type, null: false, default: "hourly"
    end
  end
end

And a model attribute like this:

class Client < ActiveRecord::Base
  enum default_rate_type: {
    hourly: "hourly",
    weekly: "weekly",
  }
end

And you’re off to the races! As best as I can tell from a few days of working with them, everything basically just works, and all the edge cases I’d spent the last few years dancing around seem to have gone away.

Adding an enum to an app using structure.sql

Of course, not every app uses the default schema.rb file. If your app’s migrations do anything that ActiveRecord migrations don’t support, someone will have likely added a configuration like this to your config/application.rb at some point:

config.active_record.schema_format = :sql

Which tells Rails to persist a raw db/structure.sql dump instead of the more readable and portable db/schema.rb file.

If you’re already using structure.sql, adding an extra gem just for cuter migration semantics is probably not worth the expense. But that’s okay, because you can write a migration like this without it:

class AddDefaultRateTypeToClients < ActiveRecord::Migration[5.2]
  def change
    reversible do |migrate|
      migrate.up do
        execute "create type rate_type as enum ('hourly', 'weekly')"
      end
      migrate.down do
        execute "drop type rate_type"
      end
    end

    change_table :clients do |t|
      t.column :default_rate_type, :rate_type, null: false, default: "weekly"
    end
  end
end

(If you’re not familiar with reversible, check out the Rails guide on it.)

With this approach, apart from losing the convenience methods create_enum and t.enum, everything else is the same as if we’d used the activerecord-postgres_enum gem.

Converting existing columns from integers to enums

Now, the real fun comes when attempting to migrate your existing ActiveRecord enum attributes that are backed by integer columns to a Postgres enum type.

The first step is understanding which numeric values need to be converted to which strings. If your enum attribute is already defined by setting explicit integers like this:

class Project < ApplicationRecord
  enum rate_type: {
    hourly: 0,
    weekly: 1,
  }
end

Then this will be easy. You know that hourly and weekly will have been stored as 0 and 1, respectively, across every environment.

However, if the enum is defined using a simple array, the ordinal values will be derived implicitly by ActiveRecord:

class Project < ApplicationRecord
  enum rate_type: [:hourly, :weekly]
end

And you may want to be a little more careful. Though I’m 99% sure there aren’t any versions of Rails or any Postgres adapters that would transliterate this to anything other than the same 0 and 1, it’s easy enough to ask Rails for the value when converting the column that we can just as well do that in our migration.

In either case, your migration will need to drop down into executing SQL, even if you’ve adopted the aforementioned enum gem. Below, we’ll also conservatively ask ActiveRecord for the enum’s integer values rather than hard-code any magic numbers. Finally, we’ll locally redefine the Project model in order to future-proof the migration against changes in app/ later. (If you haven’t added good_migrations to your Gemfile yet, you really should!)

class EnumerateProjectRateType < ActiveRecord::Migration[5.2]
  # 1.) locally define the model you're working with
  class Project < ActiveRecord::Base
    enum rate_type: [:hourly, :weekly]
  end
  def change
    # 2.) create the enum type if it hasn't been created
    reversible do |migrate|
      migrate.up do
        execute "create type rate_type as enum ('hourly', 'weekly')"
      end
      migrate.down do
        execute "drop type rate_type"
      end
    end

    # 3.) ask Rails for the integer equivalents of our two enum values
    hourly_int = Project.rate_types[:hourly]
    weekly_int = Project.rate_types[:weekly]

    # 4.) change the column type and migrate its data up and down
    reversible do |migrate|
      migrate.up do
        execute <<~SQL
          alter table projects
            alter column rate_type drop default,
            alter column rate_type set data type rate_type using case
              when rate_type = #{hourly_int} then 'hourly'::rate_type
              when rate_type = #{weekly_int} then 'weekly'::rate_type
            end,
            alter column rate_type set default 'hourly';
        SQL
      end
      migrate.down do
        execute <<~SQL
          alter table projects
            alter column rate_type drop default,
            alter column rate_type set data type integer using case
              when rate_type = 'hourly' then #{hourly_int}
              when rate_type = 'weekly' then #{weekly_int}
            end,
            alter column rate_type set default #{hourly_int};
        SQL
      end
    end
  end
end

And then, finally, update the model:

class Project < ActiveRecord::Base
  enum rate_type: {
    hourly: "hourly",
    weekly: "weekly",
  }
end

And that’s pretty much all you should need to do, unless you’re hard-coding integer values somewhere or otherwise have application code that depended on the old integer values. (A good idea might be to grep for any methods that would have returned those integers, like Project.rate_types).

Conclusion

This post didn’t cover what enums are, whether they’re worth using, or why you should care, but hopefully it presents a useful path forward for anyone on Postgres already using (or hoping to use) ActiveRecord enum attributes, but not yet familiar with Postgres enum types.

Happy migrating!

Related Insights

🔗
How to fake time in a Postgres database
🔗
How to back up Heroku Postgres databases: a complete guide
🔗
Handling Heroku's "heroku_ext" schema for Postgres extensions
🔗
How to achieve natural sorting in Postgres using custom collations

Explore our insights

See all insights
Leadership
Leadership
Leadership
The business of AI: Solve real problems for real people

After participating in the Perplexity AI Business Fellowship, one thing became clear: the AI hype cycle is missing the business fundamentals. Here are 3 evidence-based insights from practitioners actually building or investing in AI solutions that solve real problems.

by
Cathy Colliver
Leadership
Leadership
Leadership
Pragmatic approaches to agentic coding for engineering leaders

Discover essential practices for AI agentic coding to enhance your team’s AI development learning and adoption, while avoiding common pitfalls of vibe coding.

by
A.J. Hekman
by
Aaron Gough
by
Alex Martin
by
Dave Mosher
by
David Lewis
Developers
Developers
Developers
16 things software developers believe, per a Justin Searls survey

Ruby on Rails developer Justin Searls made a personality quiz, and more than 7,000 software developers filled it out. Here's what it revealed.

by
Justin Searls
Letter art spelling out NEAT

Join the conversation

Technology is a means to an end: answers to very human questions. That’s why we created a community for developers and product managers.

Explore the community
Test Double Executive Leadership Team

Learn about our team

Like what we have to say about building great software and great teams?

Get to know us
Test Double company logo
Improving the way the world builds software.
What we do
Services OverviewSoftware DeliveryProduct ManagementLegacy ModernizationDevOpsUpgrade RailsTechnical RecruitmentTechnical Assessments
Who WE ARE
About UsCulture & CareersGreat CausesEDIOur TeamContact UsNews & AwardsN.E.A.T.
Resources
Case StudiesAll InsightsLeadership InsightsDeveloper InsightsProduct InsightsPairing & Office Hours
NEWSLETTER
Sign up hear about our latest innovations.
Your email has been added!
Oops! Something went wrong while submitting the form.
Standard Ruby badge
614.349.4279hello@testdouble.com
Privacy Policy
© 2020 Test Double. All Rights Reserved.