Two Puma workers try to assign the same user to the same customer account.
One request comes from an administrator. The other comes from an invitation acceptance that reached another process at nearly the same time. Both use the ordinary Active Record lifecycle, and both records pass validation.
A later query finds two active seat assignments:
SeatAssignment.where(account_id: 42, user_id: 7).count
# => 2The model appears to prohibit that state:
class SeatAssignment < ApplicationRecord
belongs_to :account
belongs_to :user
validates :user_id, uniqueness: { scope: :account_id }
endNothing skipped validation. Neither write used update_all, insert_all, or raw SQL. Each worker asked the database whether the assignment existed, received no row, and continued to its insert.
The SQL log makes the failure visible only when entries from both connections are read together:
worker A SELECT 1 FROM "seat_assignments"
WHERE "account_id" = 42 AND "user_id" = 7 LIMIT 1
-> no row
worker B SELECT 1 FROM "seat_assignments"
WHERE "account_id" = 42 AND "user_id" = 7 LIMIT 1
-> no row
worker A INSERT INTO "seat_assignments" ("account_id", "user_id", ...)
VALUES (42, 7, ...)
-> commit
worker B INSERT INTO "seat_assignments" ("account_id", "user_id", ...)
VALUES (42, 7, ...)
-> commitBoth validation results were accurate when their queries ran. Neither result reserved the (42, 7) identity for the object that received it.
A uniqueness validation is a query
ActiveRecord::Validations::UniquenessValidator builds a relation from the attribute being validated. It applies the configured scope and conditions, excludes the current row during an update, and asks whether a conflicting record exists.1
For this model, the important path is:
user_id = 7, account_id = 42
->
build a seat_assignments relation
->
apply the account_id scope
->
SELECT whether a matching row exists
->
add "has already been taken" or let save continueThat SELECT gives the model useful feedback during the normal path. If the assignment already exists, Rails can attach an error to user_id before attempting an insert.
There is still time between the read and the write. Another connection can perform the same read during that interval. save wraps its validation and write in a transaction, but that transaction belongs to one database connection. It does not combine separate connections into one decision.
This race is sometimes described as a rare timing problem. I find that framing unhelpful in systems with multiple Puma threads, background workers, webhook retries, and more than one application host. The timing window may be short, but the system keeps offering it.
The unique index arbitrates the writes
The schema needs to express the identity that the application expects:
add_index :seat_assignments,
[:account_id, :user_id],
unique: true,
name: "index_seat_assignments_on_account_and_user"The scope is part of the rule. A unique index on user_id alone would allow each user into only one account. Separate non-unique indexes on account_id and user_id would speed up some queries without prohibiting the pair from appearing twice.
Replay the overlap with the composite unique index in place:
worker A uniqueness SELECT -> no row
worker B uniqueness SELECT -> no row
worker A INSERT (42, 7)
worker B INSERT (42, 7)Under PostgreSQL’s default READ COMMITTED isolation, each validation query sees rows committed before that statement began. A plain SELECT takes no lock that would stop another connection from inserting the same (account_id, user_id) value.
The unique index changes what happens during the inserts. If worker A’s insert remains uncommitted when worker B reaches the same indexed value, PostgreSQL may make worker B wait for that transaction to finish. Worker A's commit makes worker B’s insert a uniqueness violation. A rollback from worker A allows worker B to proceed.
The bundled Active Record adapters translate a rejected duplicate into ActiveRecord::RecordNotUnique.23
SeatAssignment.create!(account_id: 42, user_id: 7)
# raises ActiveRecord::RecordNotUnique for the losing writeOn a contested identity, this exception can be an expected concurrent outcome. It shows that the constraint resolved two writes that had both passed application validation.
The validation answers whether this object should try. The constraint decides whether this write may become database state.
RailsRevelry traces Rails behavior across requests, objects, transactions, and database writes.
Subscribe free to receive the next source-backed Rails article.
Keep the validation when it improves the application path
A unique index rejects a statement. It cannot attach "has already been taken" to a model attribute for a form. Keeping the model validation gives ordinary application paths that feedback before Rails attempts to insert.
In code review, I read the validator and index as two implementations of one rule. Their columns and value semantics need to agree.
PostgreSQL treats NULL values as distinct for uniqueness by default. A unique index can therefore contain several rows whose indexed identity includes NULL. NOT NULL may be the right companion constraint. PostgreSQL also supports NULLS NOT DISTINCT when the identity treats null values as equal.4
Conditional uniqueness needs the same care. If Rails validates uniqueness only for active assignments, a full unique index can reject data the model accepts. A partial index with a different predicate can allow data the model rejects. Reading schema.rb or structure.sql beside the validator is part of reviewing the rule.
Decide what the losing caller should observe
RecordNotUnique proves the database protected the invariant. Application code still needs to define the result for the caller.
For a user choosing an unavailable handle, a conflict response and another choice may be correct. For an idempotent assignment operation, the losing caller may load the row created by the winner:
def assign_seat!(account:, user:)
SeatAssignment.transaction(requires_new: true) do
SeatAssignment.create!(account:, user:)
end
rescue ActiveRecord::RecordNotUnique
SeatAssignment.find_by!(account:, user:)
endThe requires_new block gives the create attempt its own rollback boundary. With no outer transaction, Rails opens a transaction. Inside an existing transaction, Rails uses a savepoint on databases such as PostgreSQL. The unique violation rolls back that inner boundary before the rescue queries for the winning row, leaving the caller’s outer transaction usable.5
This is the same connection boundary described in What Active Record Transactions Actually Protect: the exception belongs to the transaction that attempted the statement, and recovery begins after that transaction ends.
Keep the rescue close to the write whose constraint it understands. A table can have several unique indexes. A broad RecordNotUnique rescue can turn a violation of another key into an apparent success.
The method above handles the concurrent constraint race. A later call made after the row already exists can stop at the model validation and raise ActiveRecord::RecordInvalid, so it is not a complete create-or-find API.
Rails makes the same requires_new choice in create_or_find_by!, its constraint-backed create-first path:
SeatAssignment.create_or_find_by!(account:, user:)create_or_find_by! attempts the insert inside a requires_new transaction, rescues RecordNotUnique, and finds the existing row. It depends on a matching unique database constraint. The Rails 8.1.3 documentation for create_or_find_by! explicitly warns against defining uniqueness validations on columns covered by unique constraints because validation can stop create before its fallback lookup. The bang variant uses the same rescue path with create!: a validation failure raises RecordInvalid before an insert reaches the index, so no RecordNotUnique triggers find_by!.6
An interactive path that needs early availability feedback can perform that check in a form or service object. A persistence path built around create_or_find_by! can let the database own uniqueness for that operation.
Other constraints protect other kinds of stored truth
The Active Record APIs that skip the record lifecycle can bypass validations while still reaching the same table. Database constraints apply when their SQL executes.
change_column_null :seat_assignments, :account_id, false
add_foreign_key :seat_assignments, :accounts
add_check_constraint :seat_assignments,
"revoked_at IS NULL OR revoked_at >= created_at",
name: "seat_assignments_valid_revocation_time"NOT NULL rejects a SQL NULL account identifier. The foreign key rejects an identifier that has no account row. The check constraint evaluates the revocation timestamp against values in the row. Rails can issue the statement through save!, update_all, or a bulk insert; the database applies the constraint to each part.
Where the adapter can classify the database error, Active Record raises ActiveRecord::NotNullViolation, ActiveRecord::InvalidForeignKey, or ActiveRecord::CheckViolation for these failures.7
Model validations still cover behavior the database cannot express well. NOT NULL permits an empty string. A check constraint is suited to database-visible row state, while authorization and workflow-dependent rules usually need application context. PostgreSQL also treats a check expression that evaluates to NULL as satisfied, so nullable operands may need their own constraints.8
Ask which states the database must reject regardless of how the write reaches it.
Those states belong in constraints the database can enforce. Model validations can still make the expected application path easier to use and explain.
Debug the interleaving, then inspect the schema
A single request log shows a uniqueness query followed by a successful insert. That trace looks correct. The duplicate appears in the space between traces.
Start by adding a request, job, or trace identifier to the relevant SQL queries. Group the validation SELECT and write by connection, then place the connections on one timeline:
trace id
-> connection id
-> validation SELECT
-> INSERT or UPDATE
-> commit, rollback, or constraint exceptionInspect the database definition directly after reconstructing the overlap:
SeatAssignment.with_connection do |connection|
connection.indexes(SeatAssignment.table_name).map do |index|
[index.name, index.columns, index.unique, index.where]
end
endRails 8.1 soft-deprecates ActiveRecord::Base.connection; with_connection scopes the checkout to the block and returns it to the pool afterward.9
Compare the index columns and predicate with the validator’s attribute, scope, and conditions. For required values and references, also inspect column nullability and foreign keys.
A concurrency regression test needs separate connections and a barrier that lets both operations reach the contested boundary. Two threads started one after another may happen to run serially and prove nothing. A surrounding transactional test can serially hide committed visibility between the connections.
The losing result is part of the contract. Assert whether it raises a conflict, retries the complete transaction, or returns the row that won. “One row remained” is necessary, but callers also need a defined outcome.
Chapter 3 began with a relation that had not executed and followed Rails state through loaded objects, associations, pending changes, saves, transactions, commit callbacks, and direct writes. This final boundary belongs to the database because every writer eventually meets there.
When correctness depends on every writer and every connection agreeing, inspect the schema beside the model.
Which database invariant in your Rails app still depends only on a model validation? Reply or leave a comment with the validation and the schema rule you found beside it.
ActiveRecord::Validations::UniquenessValidator builds the comparison relation, applies the scope and conditions, and checks whether a conflicting row exists.
ActiveRecord::RecordNotUnique is raised when an insert or update violates a uniqueness constraint.
The Rails validation API documents the interleaving that makes application-level uniqueness checks race-prone and recommends a matching unique index.
The PostgreSQL constraint documentation defines check, not-null, unique, and foreign-key behavior, including NULL semantics.
The Active Record transaction guide warns against catching `ActiveRecord::StatementInvalid inside a transaction and continuing on PostgreSQL because the transaction remains unusable until restart.
Rails 8.1.3 documents the uniqueness-validation caveat under create_or_find_by. The tagged implementation uses a requires_new transaction, rescues RecordNotUnique, and applies the same control flow to create_or_find_by! with create!.
Active Record defines adapter-level subclasses for NotFullViolation, InvalidForeignKey, and CheckViolation.
The PostgreSQL constraint documentation defines check, not-null, unique, and foreign-key behavior, including NULL semantics.
Rails 8.1 ActiveRecord::ConnectionHandling documents connection as soft-deprecated and recommends using with_connection or lease_connection.

