Rails: The Sharp Parts. lock Is Not a Mutex | baweaver
baweaver
baweaver
Software Engineering
Simplicity is hard work. But there's a huge payoff. The person who has a genuinely simpler system is going to be able to affect the greatest change with the least work.<br>— Rich Hickey
ruby<br>June 05, 2026
Rails: The Sharp Parts. lock Is Not a Mutex
Rails is great for a lot of things, but as your app gets bigger you’re going to start running into issues which require you to go deeper for solutions, and some of them? Well they have a few sharp and pointy edges that it’s best to be aware of.
This is a series where we dive into a few of those sharp edges, the documentation around them (they do warn you for a lot of these), and when and why they go from good solution to production incidents.
We’re going to start with lock. Most of us have written something that looks roughly like this:
Seat.transaction do<br>seat = Seat.lock.find(seat_id)
raise "already reserved" if seat.reserved?
seat.update!(reserved: true, reserved_by: user_id)<br>end
At a glance it looks fine: lock the seat, check the state, update, and on we go. Not too hard to understand, reasonably clear, what’s wrong with that?
The trap we fall into with ActiveRecord is that it abstracts a lot of details from us, in some cases database behavior which is unique to each implementation, and in others from the way the underlying concept works if we’re not careful.
Looking at lock you might think, reasonably, that it’s effectively a database mutex that just works, but that’s not quite the case. More accurately ActiveRecord is asking the underlying database for a pessimistic lock (Rails: ActiveRecord::Locking::Pessimistic), and how it goes about that depends on transaction boundaries, isolation levels, the database, the query, and every other code path that happens to touch the same rows.
Rails gives you lock, lock!, and with_lock but at the end of the day the database makes the rules, and it may not be fully aligned with your mental model.
Now to be clear, there is a time and a place for locks. A well scoped SELECT ... FOR UPDATE in a real transaction is a synchronization primitive, and it really does fix a single-row lost-update race. What it does not do is protect an invariant that spans multiple rows, rows that don’t exist, or anything which lives above a single record. Lucky for you that’s the category of bugs that will absolutely ding PagerDuty.
They’re sneaky little things too. They tend to go by a few other names like latency, retries, deadlocks, stale data, or duplicate work. Race conditions are always such delightful fun to figure out because they’re so difficult to simulate locally in tests.
(I should know, it took me way too long while writing this article to do so)
Start With the Invariant, Not the Lock
Locks are an implementation detail. The invariant is the architecture, and if you get those two backwards you’ll spend a lot of time tuning the wrong thing.
Quick aside on “invariant,” since I’m going to lean on the word a lot. An invariant is just a rule about your data that’s supposed to be true no matter what, before and after every operation, forever. “An account balance is never negative.” “Every order belongs to exactly one customer.” “A seat is reserved at most once.” The whole game in this article is making the database hold those rules for you instead of hoping every piece of code politely remembers to.
I’m going to use seat reservations for the whole article, because the invariant is straightforward to say out loud:
A seat may be reserved at most once.
Now picture two requests landing at almost the same instant:
A reads status -> available<br>B reads status -> available<br>A reserves<br>B reserves
Each request looks fine in isolation, they both did what we’d want, but the second you remove isolation you have an issue. Neither of those two requests knows about each other, and correctness was never a property of a single request. Together? They run into each other.
So before you reach for a lock remember to ask a few questions:
What invariant am I protecting?
Who is even allowed to write this resource?
Can the database express this rule directly with a constraint, a unique index, a CHECK?
Is the contention surface one row, a pile of rows, or a predicate over a set of rows that might not all exist yet?
Which resources participate, in what order?
Most locking failures I’ve seen trace straight back to an invariant nobody ever wrote down, or one that turned out to be a lot bigger than the single row people were locking.
What lock Actually Does
When people read Seat.lock.find(id), the mental model is “only one thing can touch this now.” What Rails actually emits is closer to:
SELECT * FROM seats WHERE id = ? FOR UPDATE
Account.lock.find(1) produces exactly that SELECT … FOR UPDATE, and you can pass your own database-specific clause like lock("FOR UPDATE NOWAIT") or lock("FOR UPDATE SKIP LOCKED") when you need it (Rails:...