Yeda AI Tips · #131

Español

Enforce Uniqueness in the Database, Not Just the Model

Rails validations lie under concurrent load. The database doesn't. A validates :email, uniqueness: true looks like it guarantees one row per address — until two requests arrive in the same millisecond and both slip through.

The race, step by step

The uniqueness validator runs a SELECT to check whether a duplicate already exists, and only then does the INSERT. That's two separate statements with a gap between them. Under concurrency:

  1. Request A runs the check — no duplicate found.
  2. Request B runs the check — no duplicate found either.
  3. Request A inserts.
  4. Request B inserts.

Both passed the check at the same instant, so both save, and you get the duplicate you thought you'd prevented. The Rails guide says it plainly: this validation "does not create a uniqueness constraint in the database, so a scenario can occur whereby two different database connections create two records with the same value for a column that you intended to be unique."

The fix: a unique index

Push the invariant down to the one layer that evaluates it atomically. Add a unique index in a migration:

class AddUniqueIndexToUsersEmail < ActiveRecord::Migration[7.1]
  def change
    add_index :users, :email, unique: true
  end
end

Now the second INSERT in the race can't win — the database rejects it and raises ActiveRecord::RecordNotUnique. Correctness is enforced regardless of how many connections race. For scoped uniqueness (uniqueness: { scope: :account_id }), the index has to cover both columns: add_index :memberships, [:account_id, :user_id], unique: true.

Keep both layers

The model validation isn't wrong — it's just not enough. Keep it for what it's good at.

LayerJobWins at
validates ..., uniqueness: trueFriendly, per-field error message before saveUser experience
Database unique indexAtomic guarantee under concurrencyCorrectness

The validation catches ~99% of duplicates cheaply and hands the user a clean "email already taken" message. The index catches the rare race the validation can't see. Two layers, one invariant.

Power moves

Resources

Shipping Rails under real load? Yeda AI designs, audits, and hardens production systems.

Talk to us · Read the blog