Use Postgres Enum Type in Rails


Rails provides enum for ActiveRecord which is quite handy but I recently found out that we could add Postgres enum type to guarantee data consistency at database level.

Enum in Rails has few weaknesses:

  • If we use number for enum values, SQL queries look meaningless, we have to check the code to know the meaning of each enum value.
  • ActiveRecord models will validate enum values, however at database level, we could still insert unsupported values which most of the time is not a problem but in some cases it can introduce data inconsistencies.

Postgres enum type addresses those issues however it has its own problems:

  • Rails doesn't support this natively, we have to execute SQL queries regarding create/update/remove enum types.
  • Cannot work with db/schema.rb. We have to use SQL schema format in order to use this feature.
  • Changing enum types in Postgres is painful.

With those trade offs, Rails enum will be fine most of the time, but in case you need to use Postgres enum, this is the guide for it.

First we need to config ActiveRecord schema format to sql

config.active_record.schema_format = :sql

To create a Postgres enum type:

class AddStatusToProducts < ActiveRecord::Migration[6.1]
  def up
    execute <<-SQL
      CREATE TYPE product_statuses AS ENUM ('on_shelf', 'shipped');
    SQL

    add_column :products, :status, :product_statuses
  end

  def down
    remove_column :products, :status

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

In Product model:

class Product < ApplicationRecord
  enum status: { on_shelf: 'on_shelf', shipped: 'shipped' }
  validates :status, inclusion: { in: statuses.keys }
end

After running the migration, we could see the following lines in db/structure.sql

CREATE TYPE public.product_statuses AS ENUM (
    'on_shelf',
    'shipped'
);

This is simple enough, but what if we want to modify enum values ? You will encounter full of surprises.

Suppose we want to add a new enum value named return. Here is the code that works:

class AddReturnedToProductStatusesEnumType < ActiveRecord::Migration[6.1]
  disable_ddl_transaction!

  def up
    execute <<-SQL
      ALTER TYPE product_statuses ADD VALUE 'returned';
    SQL
  end

  def down
    execute <<-SQL
      ALTER TYPE product_statuses RENAME TO product_statuses_old;
      CREATE TYPE product_statuses AS ENUM ('on_shelf', 'shipped');

      ALTER TABLE products
      ALTER COLUMN status
      TYPE product_statuses
      USING status::text::product_statuses;

      DROP TYPE product_statuses_old;
    SQL
  end
end

The SQL queries for adding/removing enum values doesn't inside transaction hence we need this line in the migration:

disable_ddl_transaction!

And while adding a new enum value is simple, removing a enum value is not. In fact it is not feasible to remove a value from an enum type. Instead we have to rename the current enum, create a new enum type with the current enum original name with all the values except the one we want to remove, then alter related tables to use this new enum and finally drop old enum.

Please note that only Postgres 9.6+ supports enumerated types and the SQL queries depend on Postgres version. You could read more about Postgres enum type from their documentation at https://www.postgresql.org/docs/13/datatype-enum.html