ActiveRecord Transactions and Locking — Keeping Data Consistent Under Concurrency
Database transactions are one of those topics where knowing the basics is easy, but knowing when the basics aren’t enough is harder. ActiveRecord::Base.transaction wraps database operations in an atomic block — either everything commits or nothing does. That’s the foundation. But concurrent requests, race conditions, double-spending, and inventory overselling all require going further: understanding when to use optimistic vs pessimistic locking, how transactions nest, and what actually happens when a transaction rolls back in Rails.
Transactions — The Basics
A transaction groups multiple database operations into a single atomic unit. If any operation fails, all changes roll back.
Example:
# Transfer funds between accounts — must be atomic
def transfer(from_account, to_account, amount)
ActiveRecord::Base.transaction do
from_account.decrement!(:balance, amount)
to_account.increment!(:balance, amount)
TransactionLog.create!(from: from_account, to: to_account, amount: amount)
end
true
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "Transfer failed: #{e.message}"
false
end
If to_account.increment! raises (invalid record, constraint violation), the from_account.decrement! is rolled back. Without the transaction, you’d have money leaving one account with nothing arriving in the other.
Transactions and Exceptions
ActiveRecord rolls back the transaction on any exception, but not all exceptions are equal.
Example:
ActiveRecord::Base.transaction do
user.update!(email: "[email protected]") # raises ActiveRecord::RecordInvalid on failure
user.profile.update!(bio: "Mathematician")
end
# Only ActiveRecord exceptions (RecordInvalid, RecordNotSaved) trigger rollback automatically
# For custom exceptions, raise explicitly:
ActiveRecord::Base.transaction do
charge = PaymentProcessor.charge(user, amount)
raise ActiveRecord::Rollback unless charge.success?
Order.create!(user: user, charge_id: charge.id)
end
ActiveRecord::Rollback is a special exception that rolls back the transaction without propagating up to the caller — the transaction block returns nil but doesn’t re-raise. Use it when you want rollback without error handling above the transaction.
Example:
# after_commit vs after_save — important distinction
class Order < ApplicationRecord
after_save :notify_slack # fires even if transaction rolls back
after_commit :notify_slack # fires only after successful commit
after_commit :send_confirmation, on: :create
after_rollback :log_failure
end
Use after_commit for side effects that shouldn’t happen if the transaction rolls back (emails, webhooks, cache invalidation). Use after_rollback to clean up external resources (uploaded files, payment holds) when a transaction fails.
Nested Transactions and Savepoints
Nesting transactions in Rails requires understanding that most databases use savepoints for nested operations.
Example:
# Nested transaction behavior
User.transaction do
user.update!(name: "Ada")
User.transaction do
# This is a savepoint, not a true nested transaction on most DBs
user.update!(email: "invalid") # fails
# Only the inner "transaction" rolls back — outer continues
end
# user.name = "Ada" is still committed
end
Example:
# Explicit savepoints with :requires_new
User.transaction do
user.update!(name: "Ada")
User.transaction(requires_new: true) do
user.update!(email: "invalid") # fails
# Rolls back to savepoint — outer transaction continues
end rescue ActiveRecord::RecordInvalid
# Explicitly rescuing allows the outer transaction to commit
end
Without requires_new: true, ActiveRecord ignores nested transaction calls (they join the outer transaction). With requires_new: true, a savepoint is created and the inner block can roll back independently.
Optimistic Locking — Detect Conflicts, Don’t Prevent Them
Optimistic locking assumes conflicts are rare. It lets multiple operations proceed concurrently and detects conflicts at save time.
Example:
# Migration
add_column :posts, :lock_version, :integer, default: 0, null: false
# Model — nothing else needed; Rails detects the column automatically
class Post < ApplicationRecord
# lock_version column auto-activates optimistic locking
end
Example:
# How it works
post_a = Post.find(1) # lock_version: 0
post_b = Post.find(1) # lock_version: 0 (same record, different instance)
post_a.update!(title: "New title") # sets lock_version to 1
post_b.update!(title: "Other title")
# => ActiveRecord::StaleObjectError: Attempted to update a stale object
# Handle the conflict
begin
post.update!(title: params[:title])
rescue ActiveRecord::StaleObjectError
# Reload and show the conflict to the user
redirect_to edit_post_path(post), alert: "Post was modified by someone else. Please review and resubmit."
end
Optimistic locking is right for user-facing edit forms where concurrent edits are rare but shouldn’t silently overwrite each other. The classic CMS collision detection.
Pessimistic Locking — Prevent Concurrent Access
Pessimistic locking acquires a database lock before accessing a record, preventing other transactions from reading or modifying it until the lock is released.
Example:
# lock! — SELECT ... FOR UPDATE
Account.transaction do
account = Account.lock.find(params[:id])
# or: account = Account.find_by!(id: params[:id]).lock!
raise "Insufficient funds" if account.balance < amount
account.decrement!(:balance, amount)
end
Example:
# SELECT ... FOR UPDATE SKIP LOCKED — skip rows already locked
# Perfect for job queues and claim patterns
def claim_next_job
Job.transaction do
job = Job.where(status: :pending)
.order(:created_at)
.lock("FOR UPDATE SKIP LOCKED")
.first
return nil unless job
job.update!(status: :processing, claimed_at: Time.current)
job
end
end
SKIP LOCKED allows multiple workers to claim jobs concurrently without competing — each worker skips rows that other workers have already locked. This is how most database-backed job queues work internally.
| Strategy | When to use | Tradeoff |
|---|---|---|
| Optimistic | Rare conflicts, user-facing | Fails late, better throughput |
| Pessimistic | Frequent conflicts, financial | Blocks concurrent access, lower throughput |
SKIP LOCKED |
Work queues, claim patterns | No retry needed, ideal for job processing |
Pro-Tip: When you’re seeing intermittent
duplicate keyviolations, negative inventory balances, or double-charged customers in a Puma or Sidekiq workload, the root cause is almost always a missing transaction with pessimistic locking. The pattern “check then act” (if balance >= amount then debit) is only safe when the check and the act are inside the same transaction with a lock. Two concurrent requests both readbalance = 100, both see it’s sufficient, and both debit — resulting inbalance = -100. Fix:lock!the account before the check, inside the transaction.
Conclusion
Transactions in ActiveRecord are the first layer of protection for data integrity. Knowing when to add locking — and which kind — is the second. Optimistic locking for user-facing edit conflicts, pessimistic for financial operations and inventory, SKIP LOCKED for concurrent workers. Understanding after_commit vs after_save keeps your side effects from firing on rolled-back transactions. Together, these patterns prevent the class of bugs that only appear under real production concurrency — the kind that look fine in development and tests and cause incidents in production.
FAQs
Q1: Does wrapping everything in a transaction hurt performance?
Short transactions have minimal overhead. Long transactions (database locks held for seconds) hurt throughput by blocking concurrent writes. Keep transactions as short as possible — do validation and preparation outside the transaction, do only the database writes inside it.
Q2: Is ActiveRecord::Base.transaction the same as Model.transaction?
Yes, for most purposes. Both use the same database connection. The difference matters only with multiple databases — Model.transaction uses the model’s configured database connection, which is relevant in multi-database setups.
Q3: Can I use transactions across multiple models?
Yes, as long as they use the same database connection. User.transaction { user.update!(...); order.create!(...) } is valid — the transaction wraps all operations on that connection.
Q4: What happens if a callback raises inside a transaction?
An exception in a before_* or after_save callback causes the transaction to roll back. An exception in an after_commit callback does not roll back the committed transaction — the data is already committed. Handle errors in after_commit callbacks carefully.
Q5: When should I use with_lock vs lock!?
record.with_lock { ... } is shorthand for Record.transaction { record.lock!; ... } — it opens a transaction and acquires the lock in one call. Use it when you need to lock a single record. lock! alone requires you to be inside an explicit transaction already.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀