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.
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)
endIf 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:
User.transaction do
@user.update!(balance: @user.balance - 50)
Payment.create!(user_id: @user.id, amount: 50)
endUnder 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:
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
endIf 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.
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}")
endIn 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:
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)
endHowever, 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:
# 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)
endIf 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.
# 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)
endSimilarly, avoid loops or batches inside transactions when possible:
# 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!)
endOr use bulk insert for better performance:
ActiveRecord::Base.transaction do
OrderItem.insert_all(
items.map { |item| { order_id: @order.id, sku: item.sku, qty: item.qty } }
)
endThe 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:
# 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 userRails provides after_commit to solve this:
ActiveRecord::Base.transaction do
user = User.create!(email: 'charlie@example.com')
user.after_commit do
UserMailer.welcome_email(user).deliver_later
end
endThe 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:
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
endFor more complex workflows, you can chain multiple after_commit callbacks:
class Order < ApplicationRecord
after_commit :send_confirmation_email, on: :create
after_commit :sync_to_warehouse, on: :create
after_commit :update_inventory, on: :update
endIf a job fails and you want to retry or log the error, wrap the job enqueueing in error handling:
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
endFor 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:
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
endCommon 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_commitfor jobs: Enqueueing a job before commit can cause the job to run before data is visible. Always useafter_commitor 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 doto 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_commitcallbacks or instance-levelafter_commitblocks 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.






Comments
Loading comments…