Optimistic locking, and the row lock it is not
Two browser tabs open on the same admin form is not an exotic failure. It is a Tuesday, and the default behaviour of Rails is that the second Save silently overwrites the first with a form that was rendered before it existed. Active Record ships two answers to this and they are not variants of each other: one detects the collision after the fact and makes you handle it, the other prevents the collision by making somebody wait. Everything below was run against activerecord 8.1.3.1 on Ruby 4.0.5 and PostgreSQL 17.7, and the SQL is copied out of the log.
The column that rewrites every UPDATE
Rails optimistic locking has no enable call. Add an integer column named lock_version with a
default of 0 and null: false, and locking_enabled? starts returning truthy for the model:
def locking_enabled?
lock_optimistically && columns_hash[locking_column]
end
That is active_record/locking/optimistic.rb:167, and columns_hash[...] is the whole test. No
declaration in the model, nothing to include. A migration turns it on, which is worth knowing
because it also means a migration turns it on for code you did not write.
What changes is the shape of the UPDATE. _update_row increments the attribute in Ruby, appends the
locking column to the list of attributes being written, and merges the old value into the query
constraints. The statement Active Record emits for post.update!(title: "A wins") on a row at
version 0:
UPDATE "posts" SET "title" = $1, "updated_at" = $2, "lock_version" = $3
WHERE "posts"."id" = $4 AND "posts"."lock_version" = $5
-- [["title", "A wins"], ["lock_version", 1], ["id", 1], ["lock_version", 0]]
Two bindings for the same column, the new one in SET and the old one in WHERE. If some other
connection already moved the row to version 1, the WHERE matches nothing, PostgreSQL reports zero
affected rows, and optimistic.rb:118 turns that into an exception. The database is doing no work
it would not otherwise do. A compare-and-set is an ordinary UPDATE with one more predicate, which is
why this costs nothing and why it works on any engine that reports an affected-row count.
Give the column default: 0, null: false when you add it to an existing table. Rails will survive
the alternative: add it with no default, existing rows get NULL, and LockingType at
optimistic.rb:213 coerces NULL to 0 in both directions. I set a column to NULL directly in SQL and
the next save read it as 0 and wrote 1 with no error, which means the documented guard three lines
above the increment, "For optimistic locking, locking_column ('lock_version') can't be nil", is
one I could not reach through a NULL column at all. Relying on that coercion is still a worse bet
than a constraint.
What StaleObjectError is, exactly
ActiveRecord::StaleObjectError is defined in errors.rb:378 and carries two things worth reading
in a rescue block:
def initialize(record = nil, attempted_action = nil)
if record && attempted_action
@record = record
@attempted_action = attempted_action
super("Attempted to #{attempted_action} a stale object: #{record.class.name}.")
record is the in-memory object, still holding everything the user typed, which is the whole reason
the rescue is useful. attempted_action is a string, and it is not always "update". Active Record
raises the class from exactly two places in optimistic.rb, lines 119 and 135, but three action
strings reach them: "update" from _update_record, "touch" from persistence.rb:912, and
"destroy" from destroy_row. The message text is the only thing that distinguishes them.
Rails also maps it to an HTTP status without being asked. active_record/railtie.rb:25:
config.action_dispatch.rescue_responses.merge!(
"ActiveRecord::RecordNotFound" => :not_found,
"ActiveRecord::StaleObjectError" => :conflict,
So an unrescued stale save is a 409 in production rather than a 500, which is the correct status and
a bad page. The admin editor on this site rescues it and re-renders the form with status: :conflict
and the submitted values intact, because the recovery a human wants is their own paragraph back, not
a stack trace.
Four writers, and what the version actually buys
Run the naive increment concurrently and watch it lose. Four forked processes, each loading the same
row, sleeping 200ms so they overlap, then writing views_count + 1 through save!. On a table with
no lock_version column:
4 concurrent +1 on an unlocked row => views_count = 1
Four writes, three of them gone, no error anywhere. Add the column, change nothing else, and run the identical script:
writer 3: ok
writer 0: ActiveRecord::StaleObjectError
writer 1: ActiveRecord::StaleObjectError
writer 2: ActiveRecord::StaleObjectError
=> views_count = 1, lock_version = 1
The count is still 1. This is the part the phrase "optimistic locking" oversells, and it is worth being blunt about: the column did not protect the data, it reported the loss. Three writes are still missing. What you gained is that three processes now know, and can retry, merge, or tell a person.
That difference is only valuable when there is somebody to tell. For a view counter it is worse than useless, which is why the quiz page on this site increments with a raw SQL string rather than anything that builds a Hash; the reasoning and the statement are in Counter caches by hand. For a form a human spent ten minutes filling in, being told is the entire product requirement.
The hidden field is the whole feature
Optimistic locking across web requests does not work by default, and the way it fails is that it appears to work. A normal Rails update action looks like this:
@quiz_answer = QuizAnswer.find_by!(slug: params[:slug])
@quiz_answer.assign_attributes(quiz_answer_params)
@quiz_answer.save
The find returns the row as it is now, including the current lock_version. Whatever happened
while the form sat open is already baked into the object before assign_attributes runs, so the
WHERE lock_version = ? predicate compares the current version against itself and always matches. I
ran exactly that sequence with a competing write in the middle:
saved with no complaint. title = "stale browser value"
The version has to travel to the browser and come back. One line in the form:
<%= f.hidden_field :lock_version %>
With that line, quiz_answer_params carries a lock_version string, assign_attributes casts it to
an Integer, and the object's idea of its own version is the browser's, which is stale. Same script,
one extra assignment:
ActiveRecord::StaleObjectError: Attempted to update a stale object: LkPost.
title in db = "someone else"
Nothing in Rails adds that field for you. The scaffold generators do not emit it and form_with does
not notice the column; the class documentation says "the recommended approach is to add
lock_version as a hidden field to your form" and that sentence is the entire integration. A model
with the column, a controller with no rescue and a form without the field is the common shape, and it
behaves exactly like no locking at all.
touch, destroy, and the one that fails silently
Three methods reach the locking code by paths people do not expect, and one of them is genuinely dangerous.
touch is the surprise. _touch_row pushes the locking column onto the touched attributes, so a
touch writes a new version and checks the old one. On a record another process has already moved:
ActiveRecord::StaleObjectError: Attempted to touch a stale object: LkPost.
A belongs_to ... touch: true on a locked parent inherits that. destroy behaves the same way, with
destroy_row raising "Attempted to destroy a stale object" when the DELETE matches nothing, which
is defensible: destroying a row somebody else just edited is a decision, not a formality.
update_columns is the one to watch. It skips callbacks and validations, it does not bump the
version, and yet it still builds its WHERE clause from _query_constraints_hash, which the locking
module overrides to merge the version in. So it carries the check without carrying the increment:
UPDATE "posts" SET "title" = $1 WHERE "posts"."id" = $2 AND "posts"."lock_version" = $3
On a stale record that statement matches zero rows. It does not raise:
update_columns returned false
title in db = "A wins"
b.title in memory = "B via update_columns"
A false return nobody checks, an attribute in memory holding a value that was never written, and a
row that quietly kept its old content. Every other path through the locking code turns zero affected
rows into an exception. This one turns it into a boolean, which is the difference between a failure
you handle and a failure you ship.
update_all bumps a version it never checks
Relation#update_all has a branch at relation.rb:615 that most people meet by accident:
if updates.is_a?(Hash)
if model.locking_enabled? &&
!updates.key?(model.locking_column) &&
!updates.key?(model.locking_column.to_sym)
attr = table[model.locking_column]
updates[attr.name] = _increment_attribute(attr)
end
A Hash gets the bump appended; a String body goes to sanitize_sql_for_assignment and is emitted
verbatim, untouched. A Hash that names lock_version itself skips the branch and the literal value
you passed is written. Three inputs, three behaviours, all confirmed in the log.
The asymmetry is the interesting part. update_all writes a new version and never compares the old
one, because there is no in-memory record to compare against. So a bulk update is a one-way
invalidation: it cannot itself fail, and it makes every record another process is holding stale. Run
Post.where(published: false).update_all(archived: true) and every editor with a form open on one of
those rows gets a StaleObjectError on Save.
That is also how increment_counter and increment! reach the locking column, since both build a
Hash and land in this same branch. Counter caches by hand works
through that half in detail, including why the view counter on this site is written as a String, so I
will not restate it here.
Pessimistic locking hands the row to one process
lock! and with_lock are the other answer, and they are a much smaller piece of code.
Pessimistic#lock! is a guard plus reload(lock: lock), and with_lock is a transaction around it:
def with_lock(*args)
transaction_opts = args.extract_options!
lock = args.present? ? args.first : true
transaction(**transaction_opts) do
lock!(lock)
yield
end
end
That is pessimistic.rb:97, and the file is 107 lines of which most are documentation. What the
block produces:
TRANSACTION BEGIN
LkPost Load SELECT "posts".* FROM "posts" WHERE "posts"."id" = $1 LIMIT $2 FOR UPDATE
LkPost Update UPDATE "posts" SET "views_count" = $1, ...
TRANSACTION COMMIT
Note the second SELECT. lock! reloads unconditionally, even on a record loaded a microsecond
earlier, because a lock on a stale snapshot would be worthless. That reload is also why lock!
refuses a dirty record rather than discarding the changes:
RuntimeError: Locking a record with unpersisted changes is not supported. Use `save` to persist
the changes, or `reload` to discard them explicitly. Changed attributes: "title".
The four-writer script again, with the read-modify-write moved inside with_lock:
writer 0: ok
writer 2: ok
writer 1: ok
writer 3: ok
=> views_count = 4, lock_version = 4
Four out of four. Nothing was lost and nothing raised, because each process waited its turn and read
the value the previous one wrote. On an optimistically locked model both mechanisms run at once, and
they do not fight: the reload refreshes lock_version inside the lock, so the version predicate is
current by construction and cannot fail.
FOR UPDATE does not block readers
A claim you will find repeated in blog posts about Rails pessimistic locking is that the lock stops
other transactions reading the row. On PostgreSQL that is false, and it is easy to check. One process
takes FOR UPDATE, writes views_count = 100 and holds the transaction open for two seconds.
Another process reads normally:
plain SELECT while the row is FOR UPDATE: took 14ms, views_count = 7
Immediate, and the value is the committed one from before the lock. PostgreSQL's MVCC gives readers
a snapshot, so a row lock is a lock against writers and against other lock requests, never against
SELECT. A writer on the same row in the same window, with lock_timeout set to 500ms:
update_all while locked -> ActiveRecord::LockWaitTimeout after 575ms
This matters for what you put inside the block. The cost of with_lock is paid by the next writer,
not by every page that renders the record, so a lock around a five second API call does not slow down
reads of that row at all. It does mean the next writer waits five seconds, and under any real
concurrency those waits stack.
lock! outside a transaction locks nothing
lock! called with no surrounding transaction is the trap that produces code which looks correct in
review and does nothing in production. The SQL is emitted, so the log looks right:
LkPost Load SELECT "posts".* FROM "posts" WHERE "posts"."id" = $1 LIMIT $2 FOR UPDATE
-> is there an open transaction now? false
another process took FOR UPDATE on the 'locked' row immediately
A row lock lives for the duration of its transaction. In autocommit the transaction ends when the
statement does, so the lock was taken and released before the next line of Ruby ran. Active Record
logs no warning about this and raises nothing, and I could find no check in activerecord 8.1.3.1
that would catch it. with_lock exists precisely so that the transaction and the lock cannot be
separated, which is the reason to prefer it over lock! even when you only need one row.
What waiting costs when it goes wrong
Pessimistic locking replaces a lost update with a queue, and queues have their own failure modes. Three worth having in your head, all reproduced across forked processes against the same row.
A waiter with no timeout waits as long as the holder runs. With a holder sleeping two seconds, a
plain FOR UPDATE acquired after 1.49 seconds. In a web request that is a worker blocked on another
worker.
lock("FOR UPDATE NOWAIT") refuses instead:
ActiveRecord::LockWaitTimeout: PG::LockNotAvailable: ERROR: could not obtain lock on row in
relation "posts" (after 0.04s)
SET lock_timeout = '300ms' gives the same Active Record class from a different PostgreSQL message,
canceling statement due to lock timeout, after 0.38s. Both are ActiveRecord::LockWaitTimeout, so
a rescue on that class covers the two strategies, and e.cause is the PG::LockNotAvailable
underneath if you need to tell them apart.
And locks taken in inconsistent order deadlock. Two processes, two rows, opposite order:
A: ActiveRecord::Deadlocked: PG::TRDeadlockDetected: ERROR: deadlock detected
B: ok
PostgreSQL detected it and killed one transaction; the other finished. That is the good outcome, and it only exists because the database looks for cycles. The rule that avoids it is to lock rows in a fixed order, usually by primary key, everywhere in the codebase, and it is a rule you cannot enforce with a check.
Which one, and what would change my mind
My position: reach for optimistic locking, and treat pessimistic locking as a targeted fix for the handful of operations where a lost update is a money problem rather than a text problem.
Optimistic wins for anything shaped like a form. The contention is human-scale, conflicts are rare, the cost when there is no conflict is zero, and the failure is recoverable by the only party who can actually resolve it. It holds no database resource between requests, which matters because a web request boundary is exactly where you cannot hold a transaction open.
Pessimistic wins when the correct answer depends on the value you just read and no human is available
to arbitrate: decrementing stock, allocating a seat number, moving a balance. A retry loop around
StaleObjectError will get you there too, but a retry loop is a lock with extra steps and a worse
tail latency, and with_lock says what it means in one line.
What would change my mind on the form case is an interface where conflicts stop being rare. A shared
document that three people edit at once produces a StaleObjectError on most saves, and at that
point the honest answer is neither of these mechanisms: it is field-level merging, or a CRDT, or
accepting that the row is the wrong unit of concurrency. Optimistic locking is a good deal precisely
because it assumes conflicts are exceptional, and it degrades badly when that assumption stops
holding.
What the boilerplate ships
Plainly: neither mechanism. Across the twenty-one tables in the LaunchKit product's schema there is
no lock_version column, and no with_lock call anywhere in app/. Most of those rows have exactly
one writer, which is the honest reason.
The place it does have a concurrency problem is the Stripe webhook, and StripeEvent.process_once
answers it with a third thing that is neither optimistic nor pessimistic:
def self.process_once(id:, type:)
transaction(requires_new: true) do
create!(stripe_id: id, event_type: type)
yield
end
rescue ActiveRecord::RecordNotUnique
false
end
A unique index on stripe_id and a rescue on RecordNotUnique. Two simultaneous deliveries of the
same event both try to insert the marker, one wins, the loser's work never runs, and the savepoint
means a failure rolls the marker back so Stripe's retry still does the job. No version column, no row
lock, and the mutual exclusion is a constraint the database was already enforcing. When the thing you
need to serialise can be expressed as "this must not exist twice", a unique index gets you there
before either mechanism in this post does.
The sales site does use optimistic locking, on one table out of thirty-eight. QuizAnswer carries
lock_version :integer default(0), not null, the admin form posts it back in a hidden field, and the
update action rescues StaleObjectError to re-render with the typed values and a 409. No automatic
merge, and the message says so: "Someone else saved this quiz while you were editing. Review the
current values below; your changes were NOT saved." One table in thirty-eight is about the right
ratio. Most rows have one writer, and the ones that do not are usually obvious from the form that
edits them.
Comments
No comments yet. Be the first.