/ Tags: RAILS / Categories: RAILS

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 key violations, 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 read balance = 100, both see it’s sufficient, and both debit — resulting in balance = -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.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More