Post

Avoiding Duplicate Record Errors in Rails: Handling Concurrent Requests Gracefully

Avoiding Duplicate Record Errors in Rails: Handling Concurrent Requests Gracefully

Handling duplicate record errors in a Ruby on Rails application is a common challenge, especially when dealing with concurrent client requests. This article walks through the problem, its root causes, and best practices for writing safe, idempotent Rails controller actions that avoid unnecessary exceptions and race conditions.

The Problem: Duplicate Requests, Duplicate Keys

Imagine you’re building a feature where a client creates a LessonSession by sending a POST request with a uuid. Your controller might look something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def create
  if LessonSession.exists?(uuid: permitted_params[:uuid])
    head :no_content
    return
  end

  lesson_session = Current.course.lesson_sessions.new(permitted_params.merge(user: current_user))
  lesson_session.deactivate

  respond_to do |format|
    if lesson_session.save
      lesson_session.create_study_time!
      format.json { head :no_content }
    else
      format.json { render json: lesson_session.errors, status: :unprocessable_entity }
    end
  end
rescue ActiveRecord::RecordNotUnique => e
  Bugsnag.notify(e)
  head :no_content
end

This code seems fine at first glance, but it is not atomic. Two simultaneous requests with the same uuid can both pass the exists? check and try to insert a row — only one succeeds, and the other raises a PG::UniqueViolation, which Rails wraps as ActiveRecord::RecordNotUnique.

Why Two Exceptions?

You may notice two errors in your logs or error tracker:

  1. PG::UniqueViolation: This is the low-level PostgreSQL error.
  2. ActiveRecord::RecordNotUnique: This is the Rails-wrapped version of the above.
1
2
ActiveRecord::RecordNotUnique
PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_lesson_sessions_on_uuid"

This nesting is intentional. Rails translates low-level DB errors into higher-level, more meaningful exceptions.


Best Practices for Avoiding This Problem

1. Use a Unique Client-Generated Token

Clients should generate a unique uuid for every session creation request. This allows the server to safely detect duplicates.

2. Keep Transactions Small

Only wrap the minimal code needed to insert or find the record in a transaction. Do not include logic like sending emails, enqueuing jobs, or making API calls inside it.

3. Avoid exists? + create! Pattern

This is prone to race conditions. Instead, use atomic operations like find_or_create_by or rely on the database’s unique constraint directly.

4. Use Idempotent Create Patterns

Here is a safer pattern using find_by and save! inside a transaction:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def create
  lesson_session = LessonSession.find_by(uuid: permitted_params[:uuid])
  if lesson_session
    head :no_content
    return
  end

  lesson_session = LessonSession.new(permitted_params.merge(user: current_user, course: Current.course))
  lesson_session.deactivate

  LessonSession.transaction(requires_new: true) do
    lesson_session.save!
    lesson_session.create_study_time!
  end

  head :no_content
rescue ActiveRecord::RecordNotUnique => e
  Bugsnag.notify(e)
  head :no_content
end

5. Understand Exception Nesting

You can inspect exceptions like this:

1
2
3
4
rescue ActiveRecord::RecordNotUnique => e
  puts e.class.name        # => "ActiveRecord::RecordNotUnique"
  puts e.cause.class.name  # => "PG::UniqueViolation"
end

This helps if your error tracker shows both errors — which is expected and normal.


Summary

Best PracticeReason
Use a client-provided UUIDEnsures idempotency
Keep transactions smallReduces lock time and contention
Avoid non-atomic patternsPrevents race conditions
Catch ActiveRecord::RecordNotUniqueHandles DB-level duplicates gracefully

Final Thoughts

Rails provides robust tools for building safe, concurrent web applications — but it’s essential to write your controller logic with concurrent clients in mind. Trust your database constraints, write atomic code, and use idempotency tokens from the client to keep your APIs fast, safe, and resilient.

This post is licensed under CC BY 4.0 by the author.