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:
- Request A runs the check — no duplicate found.
- Request B runs the check — no duplicate found either.
- Request A inserts.
- 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.
| Layer | Job | Wins at |
|---|---|---|
validates ..., uniqueness: true | Friendly, per-field error message before save | User experience |
| Database unique index | Atomic guarantee under concurrency | Correctness |
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
- Rescue the exception where it matters. When the index fires,
ActiveRecord::RecordNotUniquebubbles up as a raw 500 unless you handle it. Rails ships atomic helpers built for exactly this:create_or_find_byattempts the insert, and if the unique constraint rejects it, catches the exception and falls back tofind_by!— no check-then-act gap, because the database does the checking. upsert_allrespects your indexes. By default it dedupes by every unique index on the table; passunique_by:to target one. If a row collides on a different unique index, it still raisesRecordNotUnique— so bulk paths need the same indexes in place.- Make it a habit, not a heroic. Any field a validation marks
uniqueness: trueand any business invariant that must hold under load deserves a matching database constraint. If the reel's rule is "validate for UX, constrain for truth," the audit is: for every uniqueness validation, is there an index behind it?
Resources
- Uniqueness validation and its race-condition caveat — Rails Guides
add_index ... unique: true— Active Record migration referencecreate_or_find_by— Rails APIupsert_allandunique_by— Rails API- One row, many threads: avoiding database duplicates in Rails — Evil Martians
Shipping Rails under real load? Yeda AI designs, audits, and hardens production systems.