Rails 8 Optimistic Locking Guide

When two users edit the same record at the same time, someone loses their work. The second save silently overwrites the first. Optimistic locking solves this by detecting conflicts before they cause data loss.

The Problem: Lost Updates

Consider a support ticket system where multiple agents can update tickets. Agent A opens ticket #42, spends five minutes writing detailed notes, and clicks save. Meanwhile, Agent B opened the same ticket, quickly changed the status, and saved. Agent A's save succeeds—but it just erased Agent B's status change because Agent A's form had the old status value.

This is the "lost update" problem, and it happens constantly in multi-user applications. Without protection, the last write wins, regardless of whether it should.

How Optimistic Locking Works

Optimistic locking adds a version counter to each record. Every update increments the counter. When saving, Rails checks if the version in the database matches the version the user loaded. If someone else changed the record, the versions won't match, and Rails raises an exception instead of overwriting.

The key insight: conflicts are rare in most applications. Rather than locking records when users open them (pessimistic locking), optimistic locking assumes success and handles the occasional conflict gracefully.

Adding Optimistic Locking

Enable optimistic locking by adding a lock_version column. Rails detects this column automatically and enables the behavior.

# db/migrate/20260124000001_add_lock_version_to_tickets.rb
class AddLockVersionToTickets < ActiveRecord::Migration[8.0]
  def change
    add_column :tickets, :lock_version, :integer, default: 0, null: false
  end
end

That's the entire setup. No model configuration needed. Rails sees lock_version and activates optimistic locking automatically. The default value of 0 ensures existing records work immediately.

Handling Conflicts in Controllers

When a conflict occurs, Rails raises ActiveRecord::StaleObjectError. The controller must catch this and respond appropriately—typically by re-rendering the form with an error message.

# app/controllers/tickets_controller.rb
class TicketsController < ApplicationController
  def update
    @ticket = Ticket.find(params[:id])
    
    if @ticket.update(ticket_params)
      redirect_to @ticket, notice: "Ticket updated successfully."
    else
      render :edit, status: :unprocessable_entity
    end
  rescue ActiveRecord::StaleObjectError
    @ticket.reload
    @conflict = true
    flash.now[:alert] = "This ticket was modified by another user. Your changes were not saved."
    render :edit, status: :conflict
  end

  private

  def ticket_params
    params.require(:ticket).permit(:subject, :description, :status, :lock_version)
  end
end

Critical detail: lock_version must be included in permitted params. The form sends back the version the user loaded, and Rails compares it against the current database version. Without this, optimistic locking won't function.

Form Integration

The form must include the lock version as a hidden field. Rails form helpers handle this automatically when the attribute is accessible.

# app/views/tickets/_form.html.erb
<%= form_with model: @ticket do |form| %>
  <%= form.hidden_field :lock_version %>
  
  <% if @conflict %>
    

Conflict detected. Another user modified this ticket.

Current values are shown below. Review and resubmit your changes.

<% end %>
<%= form.label :subject %> <%= form.text_field :subject %>
<%= form.label :description %> <%= form.text_area :description, rows: 10 %>
<%= form.label :status %> <%= form.select :status, Ticket::STATUSES %>
<%= form.submit %> <% end %>

When a conflict occurs, the controller reloads the ticket with fresh data. The form displays current database values, allowing the user to see what changed and decide how to proceed.

Better Conflict Resolution with Turbo

For a smoother experience, show users exactly what changed. This Turbo Stream response highlights the conflict without a full page reload.

# app/controllers/tickets_controller.rb
def update
  @ticket = Ticket.find(params[:id])
  @original_attributes = ticket_params.to_h
  
  if @ticket.update(ticket_params)
    respond_to do |format|
      format.html { redirect_to @ticket, notice: "Ticket updated." }
      format.turbo_stream { render turbo_stream: turbo_stream.replace(@ticket) }
    end
  else
    render :edit, status: :unprocessable_entity
  end
rescue ActiveRecord::StaleObjectError
  @current_ticket = @ticket.reload
  @attempted_changes = @original_attributes
  
  respond_to do |format|
    format.html { render :conflict, status: :conflict }
    format.turbo_stream { render :conflict, status: :conflict }
  end
end
# app/views/tickets/conflict.turbo_stream.erb
<%= turbo_stream.replace "ticket_form" do %>
  

Update Conflict

Another user modified this ticket while you were editing.

Current Database Values

Status
<%= @current_ticket.status %>
Description
<%= truncate(@current_ticket.description, length: 200) %>

Your Attempted Changes

Status
<%= @attempted_changes[:status] || "(unchanged)" %>
Description
<%= truncate(@attempted_changes[:description], length: 200) if @attempted_changes[:description] %>
<%= render "form", ticket: @current_ticket, conflict: true %>
<% end %>

This approach shows users both versions side-by-side, making it clear what happened and what they need to review before resubmitting.

Custom Lock Column Names

Some databases or legacy schemas use different column names. Configure the locking column in the model.

# app/models/document.rb
class Document < ApplicationRecord
  self.locking_column = :version_number
end

Common Mistakes

Forgetting to permit lock_version: The most common issue. Without the parameter, every update uses version 0, and locking never triggers.

Not handling the exception: An unhandled StaleObjectError crashes with a 500 error. Always rescue it in controllers that update lockable records.

Reloading before save: Calling @ticket.reload before update defeats the purpose by fetching the latest version, making conflicts impossible to detect.

Using with accepts_nested_attributes: Nested forms need lock versions for each nested record. This gets complex quickly—consider whether nested records truly need conflict detection.

When to Skip Optimistic Locking

Not every model needs it. Good candidates have:

  • Multiple users editing the same records
  • Complex forms where re-entering data is painful
  • Business-critical data where silent overwrites cause real problems

Skip it for append-only models (logs, events), single-user resources (user profiles), or simple status toggles where last-write-wins is acceptable.

Summary

Optimistic locking prevents silent data loss with minimal overhead. Add a lock_version column, permit it in strong parameters, include it as a hidden form field, and handle StaleObjectError gracefully. Users see conflicts instead of losing work, and the solution scales without the performance cost of pessimistic locking.

10 claps
← Back to Blog