ruby 69 lines · 3 tabs

Safer “find or create” with Unique Constraint + Retry

Shared by codesnips Jan 2026
3 tabs
class AddUniqueIndexToTags < ActiveRecord::Migration[7.1]
  disable_ddl_transaction!

  def up
    execute <<~SQL
      UPDATE tags SET name = LOWER(TRIM(name)) WHERE name IS NOT NULL;
    SQL

    add_index :tags, :name,
              unique: true,
              algorithm: :concurrently,
              name: "index_tags_on_lower_name"
  end

  def down
    remove_index :tags, name: "index_tags_on_lower_name"
  end
end
3 files · ruby Explain with highlit

The classic find_or_create_by in ActiveRecord has a well-known gap: between the SELECT that checks for an existing row and the INSERT that creates it, another process can win the race and insert the same record. Under concurrency — parallel web requests, multiple background workers, retried jobs — this produces either duplicate rows or a RecordNotUnique exception surfacing to the user. The only durable fix is to let the database enforce uniqueness and then handle the collision gracefully.

The db/migrate/add_unique_index_to_tags.rb migration establishes that guarantee. It normalizes existing data with LOWER(name) before adding the index so the backfill does not fail, then creates a unique index using algorithm: :concurrently. Because concurrent index builds cannot run inside a transaction, disable_ddl_transaction! is required — a common pitfall when adding indexes to live Postgres tables without locking writes.

In Tag model, the retry logic lives in find_or_create_safely. It first attempts a normal find_or_create_by; if two callers race, the loser raises ActiveRecord::RecordNotUnique, which the rescue catches and re-reads the now-committed row with find_by. The bounded loop matters: on the rare occasion the row is deleted between the failed insert and the re-read, the method retries rather than returning nil or looping forever. Normalization happens in a before_validation callback so the value written matches exactly what the index enforces, avoiding case-sensitivity mismatches.

TagsController shows the caller. create simply delegates to Tag.find_or_create_safely, so the endpoint is naturally idempotent — repeated submissions converge on one row. It responds :created or :ok depending on whether a new record was actually inserted, giving clients honest semantics.

The trade-off is that this pattern relies on the exception path for correctness, and exceptions are comparatively expensive; under heavy contention it can also churn savepoints inside a surrounding transaction. For most workloads the collision is rare, so the cost is negligible, and the correctness guarantee — no duplicates, ever — comes from the index rather than from hopeful application logic.


Related snips

Share this code

Here's the card — post it anywhere.

Safer “find or create” with Unique Constraint + Retry — share card
Link copied