Validate Uniqueness Of A Combination Of Two Columns In Rails
A user can only be a member of an organisation once. A product can only have one price per currency. The rule is not “this column is unique” but “this pair is unique”, and it needs both a validation and a composite index.
Description
validates :column, uniqueness: { scope: :other_column } produces a friendly error message. It runs a SELECT before the INSERT, so two concurrent requests can both pass it — which is why the database constraint is not optional.
A composite unique index enforces it atomically. The column order in that index matters for query performance: put the column you filter by alone in the leading position.
Rescue ActiveRecord::RecordNotUnique where a race is realistic, so the user gets a validation error rather than a 500.
For a nullable column in the pair, use a partial index — SQL treats NULLs as distinct, so a plain unique index will not stop two rows with the same value and a NULL.
Sample input:
Membership.create!(user_id: 1, organisation_id: 7)
Membership.create!(user_id: 1, organisation_id: 7) # rejected
Sample Output:
# => ["User is already a member of this organisation"]
Answer
# Migration
class AddUniqueIndexToMemberships < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_index :memberships, [:organisation_id, :user_id],
unique: true,
algorithm: :concurrently,
name: "idx_memberships_org_user"
end
end
# Model
class Membership < ApplicationRecord
belongs_to :user
belongs_to :organisation
validates :user_id,
uniqueness: {
scope: :organisation_id,
message: "is already a member of this organisation"
}
end
# Handle the race the validation cannot cover
def create
@membership = Membership.new(membership_params)
if @membership.save
redirect_to @membership.organisation
else
render :new, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotUnique
@membership.errors.add(:user_id, "is already a member of this organisation")
render :new, status: :unprocessable_entity
end
# Nullable column in the pair — NULLs are distinct, so use a partial index
# add_index :prices, [:product_id, :currency],
# unique: true, where: "discarded_at IS NULL"
# Case-insensitive pair
# validates :name, uniqueness: { scope: :account_id, case_sensitive: false }
# add_index :teams, "account_id, LOWER(name)", unique: true
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀