Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 015 / 100

Transactions in Rails: a practical guide for developers

Keep related writes consistent.

Expona Admin8 min read

Introduction

Database consistency is non-negotiable in production systems. When you're processing a payment, updating inventory, and recording a transaction simultaneously, you need guarantees that either all three succeed or none of them do. This is where transactions in Rails: a practical guide for developers becomes essential knowledge. In this article, we'll walk through ActiveRecord transactions, understand when exceptions trigger rollbacks, learn why keeping transactions short matters, and explore the critical pattern of enqueueing jobs after commit.

ActiveRecord::Base.transaction do ... end

Rails provides a straightforward API for wrapping database operations in a transaction block. The most common pattern is ActiveRecord::Base.transaction do, which ensures that all database queries within the block execute as a single atomic unit.

ruby
ActiveRecord::Base.transaction do
  user = User.create!(email: 'alice@example.com', balance: 100)
  wallet = Wallet.create!(user_id: user.id, amount: 100)
  AuditLog.create!(action: 'user_created', user_id: user.id)
end

If any of these three operations fails, the entire block is rolled back and no partial data is written to the database. This is critical in scenarios like user onboarding, where you need a user record, a wallet, and an audit log all created together or not at all.

You can also call transaction directly on a model instance or relation:

ruby
User.transaction do
  @user.update!(balance: @user.balance - 50)
  Payment.create!(user_id: @user.id, amount: 50)
end

Under the hood, Rails wraps your block in a BEGIN, COMMIT, or ROLLBACK statement depending on whether an exception is raised. The transaction respects your database's isolation level, which you can configure per connection.

For nested transactions, Rails uses savepoints. If you call transaction inside an already-active transaction block, Rails creates a named savepoint rather than a new transaction. This keeps the nesting clean and predictable:

ruby
ActiveRecord::Base.transaction do
  order = Order.create!(user_id: @user.id, total: 150)
  
  ActiveRecord::Base.transaction(requires_new: true) do
    # This creates a savepoint, not a new transaction
    OrderItem.create!(order_id: order.id, sku: 'ABC', qty: 2)
  end
end

If you need an actual nested transaction (not a savepoint), pass requires_new: true to the inner block. This is less common but useful when you need rollback isolation between logical units of work.

Exceptions roll back

Rails automatically rolls back a transaction if any exception is raised inside the block. This is the safety mechanism that prevents partial writes. When an exception occurs, Rails catches it, issues a ROLLBACK to the database, and re-raises the exception so your application can handle it.

ruby
begin
  ActiveRecord::Base.transaction do
    user = User.create!(email: 'bob@example.com')
    # This validation error raises ActiveRecord::RecordInvalid
    Profile.create!(user_id: user.id, bio: nil)
  end
rescue ActiveRecord::RecordInvalid => e
  Rails.logger.error("User creation failed: #{e.message}")
end

In this example, the user is never created because the Profile creation fails validation. The transaction rolls back before the exception escapes.

This behavior applies to all exceptions, not just ActiveRecord errors. If you raise a custom exception, divide by zero, or hit any other Ruby error, the transaction rolls back:

ruby
ActiveRecord::Base.transaction do
  invoice = Invoice.create!(user_id: @user.id, total: 500)
  
  # If this raises, the invoice is rolled back
  charge_amount = 500 / params[:divisor]
  
  Payment.create!(invoice_id: invoice.id, amount: charge_amount)
end

However, be aware that only database operations are rolled back. If you've already sent an email, made an API call, or written to a file inside the transaction block before the exception, those operations won't be undone. This is why the next patterns matter so much.

Also note that some exceptions—like connection timeouts or database errors—can leave the transaction in an inconsistent state. Always log these carefully and implement retry logic at the application level if appropriate.

Keep transactions short

A transaction holds a database lock and reserves memory for the rollback log. Long transactions block other queries, reduce throughput, and risk timeouts. The golden rule: execute only the queries that must be atomic, then commit and move on.

Here's a common anti-pattern:

ruby
# Bad: API call inside transaction
ActiveRecord::Base.transaction do
  order = Order.create!(user_id: @user.id, total: 150)
  
  # Blocks the transaction while waiting for external service
  shipping_quote = ShippingService.fetch_quote(order)
  
  OrderShippingInfo.create!(order_id: order.id, cost: shipping_quote.cost)
end

If the shipping service is slow or hangs, the transaction stays open and locks the orders table. This starves other requests.

The better approach: fetch external data before starting the transaction.

ruby
# Good: external calls before transaction
shipping_quote = ShippingService.fetch_quote(@user.zip_code)

ActiveRecord::Base.transaction do
  order = Order.create!(user_id: @user.id, total: 150)
  OrderShippingInfo.create!(order_id: order.id, cost: shipping_quote.cost)
end

Similarly, avoid loops or batches inside transactions when possible:

ruby
# Bad: loops inside transaction
ActiveRecord::Base.transaction do
  items.each do |item|
    OrderItem.create!(order_id: @order.id, sku: item.sku, qty: item.qty)
  end
end

# Good: loop outside, transaction wraps only the critical part
item_records = items.map { |item| OrderItem.new(order_id: @order.id, sku: item.sku, qty: item.qty) }

ActiveRecord::Base.transaction do
  item_records.map(&:save!)
end

Or use bulk insert for better performance:

ruby
ActiveRecord::Base.transaction do
  OrderItem.insert_all(
    items.map { |item| { order_id: @order.id, sku: item.sku, qty: item.qty } }
  )
end

The target is sub-second transactions. If your transaction takes longer than a few hundred milliseconds, profile it and move non-critical work outside.

Enqueue jobs after commit

Background jobs are a fact of life in Rails: sending emails, processing images, syncing to external systems. The challenge is timing: if you enqueue a job before the transaction commits, the job might run before the data exists in the database.

Consider this scenario:

ruby
# Bad: job enqueued before commit
ActiveRecord::Base.transaction do
  user = User.create!(email: 'charlie@example.com')
  UserMailer.welcome_email(user).deliver_later
end

# The job runs and tries to find user with id=1, but the transaction hasn't committed yet
# In some databases or with certain isolation levels, the query sees no user

Rails provides after_commit to solve this:

ruby
ActiveRecord::Base.transaction do
  user = User.create!(email: 'charlie@example.com')
  
  user.after_commit do
    UserMailer.welcome_email(user).deliver_later
  end
end

The block passed to after_commit runs after the transaction successfully commits and data is visible to other connections. This guarantees the user record exists before the job tries to access it.

A cleaner pattern uses the class-level callback:

ruby
class User < ApplicationRecord
  after_commit :send_welcome_email, on: :create

  private

  def send_welcome_email
    UserMailer.welcome_email(self).deliver_later
  end
end

# In your controller or service:
ActiveRecord::Base.transaction do
  User.create!(email: 'charlie@example.com')
  # after_commit callback fires after transaction succeeds
end

For more complex workflows, you can chain multiple after_commit callbacks:

ruby
class Order < ApplicationRecord
  after_commit :send_confirmation_email, on: :create
  after_commit :sync_to_warehouse, on: :create
  after_commit :update_inventory, on: :update
end

If a job fails and you want to retry or log the error, wrap the job enqueueing in error handling:

ruby
after_commit do
  begin
    UserMailer.welcome_email(self).deliver_later
  rescue StandardError => e
    Rails.logger.error("Failed to enqueue welcome email for user #{id}: #{e.message}")
    Sentry.capture_exception(e) if defined?(Sentry)
  end
end

For testing, remember that after_commit doesn't fire in test mode by default unless you use use_transactional_fixtures: false in your test configuration. Consider using perform_enqueued_jobs from ActiveJob's test helpers to verify job enqueueing:

ruby
def test_user_creation_enqueues_welcome_email
  perform_enqueued_jobs do
    User.create!(email: 'test@example.com')
  end
  
  assert_enqueued_jobs 1, only: UserMailer::WelcomeEmailJob
end

Common mistakes

  • Assuming all code inside a transaction is atomic: Only database writes are rolled back. External API calls, file writes, and cache updates happen regardless of transaction success.
  • Long transactions with external calls: Fetch data from APIs and files before opening a transaction. Lock contention and timeouts are silent killers in production.
  • Forgetting after_commit for jobs: Enqueueing a job before commit can cause the job to run before data is visible. Always use after_commit or verify that your isolation level handles this.
  • Catching exceptions without re-raising: If you rescue inside a transaction block and don't re-raise, the transaction still commits. Make sure you understand the control flow.
  • Not testing rollback behavior: Write tests that verify rollback works. A common mistake is creating data in create! but forgetting that validation failures roll back the transaction.
  • Using transactions for read-only queries: Unnecessary transaction overhead. Reserve transactions for writes that need consistency guarantees.

Key takeaways

  • Wrap related database writes in ActiveRecord::Base.transaction do to guarantee all-or-nothing atomicity.
  • Any exception inside a transaction block triggers a rollback. Use this to your advantage, but remember that external operations (API calls, emails) won't be undone.
  • Keep transactions short: fetch external data first, avoid loops and slow calls, target sub-second execution. Lock contention is a silent killer in production.
  • Always enqueue background jobs after the transaction commits using after_commit callbacks or instance-level after_commit blocks to guarantee data is visible to job workers.
  • Test rollback behavior and transaction boundaries. Use perform_enqueued_jobs` in tests to verify jobs are enqueued at the right time.
  • Understand your database's isolation level and how it interacts with transaction visibility. Read Committed is common and usually sufficient for Rails apps.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles