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
endThat'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
endCritical 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
endCommon 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.