find_or_initialize_by vs create_or_find_by
Why RecordNotUnique happens โ the order of find and create
Press the same button in two tabs at nearly the same time and ActiveRecord::RecordNotUnique blows up. If that error shows up in your error tracker, the culprit is almost always find_or_initialize_by or find_or_create_by.
Why it blows up
find_or_initialize_by runs a SELECT first, and if nothing is found, it only calls new in memory. The INSERT happens later, when you call save. The problem is the gap between finding and saving.
Request A runs the SELECT. Nothing there. Request B runs the SELECT too โ A has not INSERTed yet, so B also sees nothing. A INSERTs successfully. B INSERTs and dies on the unique index.
find_or_create_by has the exact same find โ create order, so it cannot close this gap either. Adding a bang does not change the order.
create_or_find_by flips the order
No SELECT โ it just tries the INSERT. If the row already exists and the unique constraint fires, it rescues that RecordNotUnique internally and SELECTs the existing record instead.
The existence check moves from your application SELECT to the database unique constraint. Under concurrent requests, one side wins the INSERT and the other gets the existing record. No error escapes.
One precondition
The lookup columns need a unique index. Without one there is no conflict to catch, and duplicate rows pile up silently. create_or_find_by without a unique index is just create.
The rule of thumb is simple: need an unsaved object to render a form? find_or_initialize_by. Need to safely grab a record that concurrent requests might create? create_or_find_by.
The three methods compared
| Aspect | find_or_initialize_by | find_or_create_by | create_or_find_by |
|---|---|---|---|
| Order | SELECT โ new | SELECT โ INSERT | INSERT โ SELECT on conflict |
| Returned state | unsaved new_record if missing | persisted | persisted |
| Under concurrency | RecordNotUnique at save | RecordNotUnique at create | returns existing record, no error |
| Precondition | โ | โ | unique index required |
Race condition timeline
Tab A: SELECT โ nothing
Tab B: SELECT โ nothing (A has not INSERTed yet, so B sees nothing)
Tab A: INSERT โ success
Tab B: INSERT โ unique index violation โ RecordNotUnique
What create_or_find_by looks like inside
def create_or_find_by(attrs)
transaction(requires_new: true) { create(attrs) }
rescue ActiveRecord::RecordNotUnique
find_by!(attrs)
end
requires_new: true creates a savepoint, so a failed INSERT does not poison an outer transaction. This matters a lot on PostgreSQL.
โ Pointless without a unique index
No conflict means no rescue. Put a unique index on the lookup columns first. Also remember it INSERTs immediately with only the attributes you pass โ if other columns have required validations, it fails right there.
Key Points
find_or_initialize_by: SELECT, then new if missing โ the INSERT runs at save time
Concurrent requests both see nothing and both INSERT โ the slower one dies with RecordNotUnique
find_or_create_by(!) has the same find โ create order, so it fails the same way
create_or_find_by INSERTs first โ on unique violation it rescues and SELECTs the existing record
Precondition: a unique index on the lookup columns โ without it duplicates pile up silently
It uses a transaction(requires_new: true) savepoint, so it is safe inside an outer transaction
Pros
- ✓ Existence check is delegated to the DB unique constraint, so no error escapes under concurrency
- ✓ The returned record is always persisted
- ✓ Uses a savepoint (requires_new: true), so it is safe inside an outer transaction
Cons
- ✗ Without a unique index it is just create โ duplicates pile up silently
- ✗ INSERTs immediately with only the given attributes, so other required-column validations can fail
- ✗ Rare case: if the winning transaction rolls back right after the conflict, find_by! finds nothing