ruby 78 lines · 3 tabs

Schema-Backed Enums (DB Constraint + Rails enum)

Shared by codesnips Jan 2026
3 tabs
class CreateSubscriptions < ActiveRecord::Migration[7.1]
  STATUSES = %w[pending active past_due canceled].freeze

  def change
    create_table :subscriptions do |t|
      t.references :account, null: false, foreign_key: true
      t.string :status, null: false, default: "pending"
      t.datetime :current_period_end
      t.timestamps
    end

    quoted = STATUSES.map { |s| ActiveRecord::Base.connection.quote(s) }.join(", ")
    add_check_constraint :subscriptions,
                         "status IN (#{quoted})",
                         name: "subscriptions_status_check"

    add_index :subscriptions, :account_id,
              unique: true,
              where: "status = 'active'",
              name: "index_active_subscription_per_account"
  end
end
3 files · ruby Explain with highlit

Rails enum maps a symbolic name like :pending to a stored value, but by default nothing at the database level prevents an out-of-range integer or an unexpected string from landing in the column. That gap matters when data arrives from bulk imports, raw SQL, other services, or a rollback that leaves an old code path writing values the current app no longer understands. This snippet closes the gap by backing the Rails enum with a Postgres CHECK constraint so the database itself rejects invalid states.

The CreateSubscriptions migration stores status as a string rather than an integer. String-backed enums keep the raw column readable in psql and in dumps, and they survive reordering of the enum declaration — an integer enum silently changes meaning if someone inserts a value in the middle of the list. The migration adds check_constraint with an explicit status IN (...) list and names it subscriptions_status_check so it can be found and altered later. A partial index on active rows and a null: false default round out the schema-level guarantees.

In Subscription model, the same set of statuses is declared as STATUSES and passed to enum status:, keeping the Ruby-side and database-side definitions driven from one array. Using validate: true (via validates :status, inclusion:) gives a friendly ActiveRecord validation error before the row ever reaches the database, while the CHECK constraint remains the last line of defense for writes that bypass validations, such as update_column or insert_all. The _prefix: true option namespaces the generated scopes and predicate methods to avoid collisions with other columns.

The SubscriptionsController shows the two failure modes working together: normal create and update surface validation errors as 422s, while rescue_from ActiveRecord::StatementInvalid catches a constraint violation that slipped past validation and reports it explicitly. This layered approach — application validation for UX, database constraint for correctness — means the invariant holds no matter which code path writes the row. The trade-off is that the allowed list now lives in two places and must be changed together through a migration, which is a deliberate cost paid for a truly enforceable enum.


Related snips

Share this code

Here's the card — post it anywhere.

Schema-Backed Enums (DB Constraint + Rails enum) — share card
Link copied