The 3 kinds of Enum in Rails
Augusts Bautra

Augusts Bautra @epigene

About: Senior Rails developer from Latvia. Avid gamer. Longevity enthusiast. #keto-dude

Location:
Riga, Latvia
Joined:
Feb 22, 2017

The 3 kinds of Enum in Rails

Publish Date: Nov 8 '24
2 11

Enums are a very useful concept. It's like a locked list of choices where only a few specific values are allowed, and nothing else. Enums work well for any place where you need a limited, known list of values for something, like:

  • status (with values like "pending," "active," and "archived")
  • role (with values like "admin," "user," and "guest")
  • difficulty_level (with values like "easy," "medium," and "hard")

See docs on Rails' enum macro.

Enums allow more choices of values than booleans, and are more constrained than strings.

Using string columns for enum values is known to be too permissive. Eventually letter case and random whitespace problems creep into the dataset. Sidestep issues by using appropriate data type - enum!

Let me present three approaches to defining enums in Rails.

Integer-based Enums (easy)

Integer-based enums are easy to define, use and extend:

# in migration
create_table :jobs do |t|
  t.integer :status, null: false, default: 0
end

# in model
enum status: { pending: 0, completed: 1, errored: 2 }
Enter fullscreen mode Exit fullscreen mode

Adding a new possible status is easy - add new key-value pairs, but be sure not to change the existing mappings.

enum status: { pending: 0, completed: 1, errored: 2, processing: 3 }
Enter fullscreen mode Exit fullscreen mode

You can even skip some integers to have subgroups. Here we're placing errors in the 90s, and leaving integers 3-8 for possible additions.

enum status: {
  pending: 0, processing: 1, completed: 2,
  errored_hard: 91,
  errored_with_retry: 92  
}
Enter fullscreen mode Exit fullscreen mode

DB-level Enums (hard-er)

Postgres supports database-level enum definition. This approach is more easy to read (queries have human-readable values, not cryptic integers), but harder to maintain - changing values requires a database migration, not just code change.

# in migration
create_enum :job_status, ["pending", "completed", "errored"]

create_table :jobs do |t|
  t.enum :status, null: false, default: "pending", enum_type: "job_status"
end

# in model
enum status: { pending: "pending", completed: "completed", errored: "errored" }
Enter fullscreen mode Exit fullscreen mode

String Enums (discouraged)

If you need the flexibility of permitting new values without changes to code, such as user-defined types, and are OK with taking on the dataset pollution risk, and then string enums can be an option.
It's basically using just a string column, so very few native constraints on the database level for the values users can write. I recommend adding CHECK constraints, for example, allow only lowercase latin letters and underscores, to have some semblance of data integrity on the database level, and a dynamic validation in app code, so forms can show validation errors etc.

# in migration
create_table :jobs do |t|
  t.string :status, null: false, default: "pending"
end

# in model, just define a validation
validate :validate_status_in_supported_list

def validate_status_in_supported_list
  return unless status_changed?

  # here the dynamic source of allowed values can be anything - database, remote requests, file read etc.
  allowed_statuses = SomeSource.allowed_statuses

  return if allowed_statuses.include?(status)

  errors.add(:status, :inclusion)
end
Enter fullscreen mode Exit fullscreen mode

Comments 11 total

  • Paweł Świątkowski
    Paweł ŚwiątkowskiNov 8, 2024

    I usually encourage using string-based enums unless there's very good reason not to. I really don't like integer-based ones, because they are useless to reason without having the application code at hand (for example, and usually, when querying database directly).

    DB-based are a nice middle ground, but require a migration every time you add a new value to enum (which I don't like) and down migrations are especially complicated.

    • Pimp My Ruby
      Pimp My RubyNov 8, 2024

      I completely agree with you
      The only application for integer based is when I really want to order things. Difficulty is a good example of it. I would like to sort by difficulty using db, then it’s okay

      But in real life my first intent will to make a string based enum and say that the order of my hash is the source of truth

      • Paweł Świątkowski
        Paweł ŚwiątkowskiNov 8, 2024

        Difficulty is a nice counterpoint. Although what would you do if you now need to introduce a new enum not at the end? Lets say you have:

        { easy: 1, medium: 2, hard: 3 }
        
        Enter fullscreen mode Exit fullscreen mode

        ... but now you need novice between easy and medium. Do you migrate all the data to have

        { easy: 1, novice: 2, medium: 3, hard: 4 }
        
        Enter fullscreen mode Exit fullscreen mode

        or perhaps you use the old trick? ;)

        { easy: 10, novice: 15, medium: 20, hard: 30 }
        
        Enter fullscreen mode Exit fullscreen mode
        • Pimp My Ruby
          Pimp My RubyNov 9, 2024

          I took the example of Difficulty but of course if it has to change I’ll definitively go the string based !

          • Pelle ten Cate
            Pelle ten CateJan 26, 2025

            Difficulty is a very poor example for an enum, and using strings is probably a poor alternative as well. Reason: there is numerical significance in its value, you'd expect to be able to rely on Task.order(:difficulty) or rely on Task.difficulties[:easy] > Task.difficulties[:super_easy]. These are things that you should not do with an enum. It'll be a pain to manage if the integer values are weightless and arbitrary

            Better examples.

            • Example 1: stuff that has doesn't compare and has no weight, such as "isle in your supermarket"
            • Example 2: stuff that could be considered comparable, but where you don't need to rely on it all the time, such as state flow logic. To order those, don't bother with the field value, but instead use Rails' own in_order_of. in order to present them in whatever order you desire.
            # Example 1
            enum :isle, %i[produce poultry fish meat dairy]
            
            # Example 2:
            enum :status, %i[draft requested in_review approved rejected]
            
            Enter fullscreen mode Exit fullscreen mode

            In your case, I'd simply add an integer field, and a helper to translate it to text. In this example you can set difficulty to ANY number between 0 and 15, and it'll tell you if it's easy, medium, hard, or impossible if it's higher.

            module DifficultyHelper
              def difficulty_for(task)
                return if task&.difficulty.nil?
            
                key = case task.difficulty
                when ..5 then :easy
                when ..10 then :medium
                when ..15 then :hard
                else :impossible
                end
            
                t("common.difficulty.#{key}")
              end
            end
            
            Enter fullscreen mode Exit fullscreen mode
      • Augusts Bautra
        Augusts BautraNov 11, 2024

        Nono, sorting integer enums is a hack. I believe semantic ordering comes into play here, I believe I covered it in dev.to/epigene/til-custom-order-wi...

    • Augusts Bautra
      Augusts BautraNov 11, 2024

      I've been burnt too many times by fat-fingered devlopers/users managing to get bad data into the DB with update_all(state: "oopsie"), haha. Integer enums somewhat limit that.

      • Paweł Świątkowski
        Paweł ŚwiątkowskiNov 11, 2024

        Do they, really? What prevents these devs from doing update_all(state: 12) when you only have 5 valid states? I would even argue that when they update to complted it's somewhat easier to guess what they had in mind than with a pure wrong number.

        • Augusts Bautra
          Augusts BautraNov 12, 2024

          I disagree. I'm convinced update_all(state: 12) for no such enum defined will raise an ArgumentError, whereas update_all(state: "word") opens the possibility of a developer misremembering something. Basically, the opaqueness of the integer enum becomes a sort of feature in that you have to open the code and see what values are defined, rather than winging it. :)

    • Rails Designer
      Rails DesignerNov 11, 2024

      Same here (also read Rails Core Team regret integer-based enums; but don't quote me on that).

      I like defining them like this too enum :status, %w[enabled disabled].index_by(&:itself), default: "enabled"

  • robert webb
    robert webbNov 9, 2024

    If you ever want to change or up your university grades, contact Cybergolden Hacker; he'll get it done and show proof of work done before payment. He's efficient, reliable, and affordable. He can also perform all sorts of hacks, including text, WhatsApp, password decryption, hacking any mobile phone, escaping bankruptcy, deleting criminal records, and the rest.

    Email: cybergoldenhacker at gmail dot com

Add comment