Rails Transactions: A Practical Guide

Learn how Rails transactions work: ActiveRecord commits, rollbacks, savepoints, and after_commit callbacks explained.

Jean Emmanuel Cadet
By Jean Emmanuel Cadet Ruby on Rails Developer
Rails Transactions: A Practical Guide

• 15 min read

Share with friends

If you have worked with Active Record for any length of time, you have probably used a transaction without thinking too hard about it. Rails wraps a lot of its own operations in transactions automatically. But once your application starts doing more than one write per request, understanding how Rails transactions actually work stops being optional.

This guide walks through Rails database transactions from the ground up: what they are, how Active Record implements them, how commit and rollback behavior works, how nested transactions and savepoints behave, and the mistakes that trip up even experienced Rails developers. By the end, you will know exactly when to reach for a transaction and when you are better off without one.


What Is a Database Transaction?

A database transaction is a group of one or more SQL operations that the database treats as a single unit of work. Either every operation in the group succeeds and gets permanently saved (committed), or none of them do (rolled back).

This behavior is usually summarized with the acronym ACID:

  • Atomicity: all operations in the transaction succeed together or fail together.
  • Consistency: the database moves from one valid state to another, never a broken in-between state.
  • Isolation: concurrent transactions do not interfere with each other's intermediate results.
  • Durability: once a transaction commits, the changes survive even a server crash.

For Rails developers, atomicity is usually the property you care about most day to day. It is what lets you say "these three database writes either all happen, or none of them happen."


Why Transactions Matter in Rails Apps

Imagine an e-commerce checkout. Placing an order typically involves multiple writes:

  • Creating an Order record.
  • Creating LineItem records for each product.
  • Decrementing Product inventory counts.
  • Charging a payment method.

If the order and line items save successfully but the inventory update fails halfway through, you end up with a paid order for a product that is not actually reserved. Without a transaction, a partial failure like this leaves your database in an inconsistent state that is difficult to detect and even harder to fix after the fact.

Wrapping these writes in a transaction guarantees that if any step fails, the database rolls back to exactly how it looked before the operation started. No partial orders, no phantom inventory changes.


How Active Record Transactions Work

Active Record exposes transactions through ActiveRecord::Base.transaction and through the equivalent method on any model class, since every Active Record model inherits from ActiveRecord::Base.

ActiveRecord::Base.transaction do
order = Order.create!(user: current_user, total: cart.total)
cart.line_items.each do |item|
order.line_items.create!(product: item.product, quantity: item.quantity)
item.product.decrement!(:stock, item.quantity)
end
end

You can also call transaction on a specific model:

Order.transaction do
order = Order.create!(user: current_user, total: cart.total)
# ...
end

Both forms behave the same way under the hood. Rails opens a database transaction, runs the block, and either commits it when the block finishes successfully or rolls it back if something goes wrong. Calling transaction on a specific model is mostly a matter of readability; it signals to anyone reading the code which model is at the center of the operation.

It is worth remembering that a transaction wraps the database work, not your Ruby code in general. If your block calls an external API or writes to a file, those actions are not covered by the rollback. More on that shortly.


Using ActiveRecord::Base.transaction in Practice

Basic Example

The simplest transaction protects a single logical operation made up of two or more writes:

def transfer_funds(from_account, to_account, amount)
ActiveRecord::Base.transaction do
from_account.update!(balance: from_account.balance - amount)
to_account.update!(balance: to_account.balance + amount)
end
end

Because both update! calls use the bang version, an invalid record raises ActiveRecord::RecordInvalid, which Rails catches internally and uses as the signal to roll back the transaction.

Multiple Models in One Transaction

Transactions are not limited to a single model. Any number of models can participate in the same transaction block:

def create_order_with_items(user, cart)
ActiveRecord::Base.transaction do
order = Order.create!(user: user, status: "pending")

cart.line_items.each do |item|
order.line_items.create!(
product: item.product,
quantity: item.quantity,
unit_price: item.product.price
)
item.product.decrement!(:stock, item.quantity)
item.product.save!
end

order.update!(status: "confirmed")
order
end
end

If the loop raises an error on the third line item, none of the previous creates or stock updates from this block are persisted. The database looks exactly as it did before create_order_with_items was called.


Commit and Rollback Behavior

Rails commits a transaction automatically when the block finishes without an unhandled exception. It rolls back automatically when an exception propagates out of the block.

This means the pattern below is a common bug. Rescuing the exception inside the block prevents Rails from ever seeing the failure, so it commits anyway:

# Bug: this commits even though the update failed
ActiveRecord::Base.transaction do
order.update!(status: "confirmed")
begin
inventory_service.reserve!(order)
rescue StandardError => e
Rails.logger.error(e.message)
end
end

If reserve! fails and you swallow the error inside the block, the transaction has no way of knowing anything went wrong, and order.update! is committed regardless. If a failure inside a step should cancel the whole transaction, let the exception propagate, or explicitly trigger a rollback yourself.


ActiveRecord::Rollback Explained

Sometimes you want to cancel a transaction on purpose, without treating it as an application error. That is what ActiveRecord::Rollback is for.

ActiveRecord::Base.transaction do
order.update!(status: "confirmed")

unless inventory_service.reserve!(order)
raise ActiveRecord::Rollback
end
end

# execution continues here, transaction was rolled back,
# no exception bubbles up to the caller

Raising ActiveRecord::Rollback tells Rails to undo everything in the block, but unlike other exceptions, Rails catches it silently and lets your code continue right after the transaction block. This is the right tool when a failed condition inside the transaction is an expected outcome, not a bug you need to handle further up the call stack.

If you do need the caller to know the operation failed, raise a normal exception (or a custom error class) instead, and let it propagate.


What Happens When an Exception Is Raised Inside a Transaction

Any exception other than ActiveRecord::Rollback behaves the same way: Active Record rolls back the transaction, then re-raises the exception so it continues up the call stack.

def create_order!(user, cart)
ActiveRecord::Base.transaction do
order = Order.create!(user: user)
order.line_items.create!(cart.items_attributes)
charge_payment!(order) # raises PaymentError if the card is declined
end
rescue PaymentError => e
# transaction has already been rolled back by this point
flash_error(e.message)
end

The important detail is ordering: the rollback happens as part of unwinding the transaction block, before your rescue clause outside the block ever runs. By the time you handle the error, the database is already back to its pre-transaction state.


Nested Transactions and Savepoints

Rails lets you nest transaction blocks, but the default behavior surprises a lot of developers coming from other frameworks. By default, a nested transaction does not create an independent transaction. It joins the parent transaction, so a rollback inside the nested block rolls back everything, including work done in the outer block.

ActiveRecord::Base.transaction do
user.update!(status: "active")

ActiveRecord::Base.transaction do
profile.update!(verified: true)
raise ActiveRecord::Rollback
end

# user.update! above is ALSO rolled back, because the
# inner block joined the same outer transaction
end

If you actually want the inner block to be independently rollback-able, you need a real savepoint by passing requires_new: true:

ActiveRecord::Base.transaction do
user.update!(status: "active")

ActiveRecord::Base.transaction(requires_new: true) do
profile.update!(verified: true)
raise ActiveRecord::Rollback
end

# user.update! is preserved, only the profile update was rolled back
end

Under the hood, requires_new: true creates a database savepoint rather than a fully separate transaction (most relational databases do not support truly independent nested transactions). A savepoint is a marker within the same transaction that you can roll back to without discarding everything that happened before it. This is the pattern to use whenever one part of a larger operation is allowed to fail independently of the rest.


Transaction Callbacks: after_commit and after_rollback

Active Record model callbacks like after_save or after_create run inside the transaction, before it has actually committed. That matters because if the transaction later rolls back (for example, if another model fails to save further down the block), any side effects those callbacks triggered have already happened and cannot be undone.

after_commit and after_rollback solve this by running only after the transaction has actually finished:

class Order < ApplicationRecord
after_commit :notify_customer, on: :create
after_rollback :log_failed_order, on: :create

private

def notify_customer
OrderMailer.confirmation(self).deliver_later
end

def log_failed_order
Rails.logger.warn("Order creation rolled back for user #{user_id}")
end
end

after_commit only fires once the transaction has fully committed and the data is actually durable in the database. after_rollback only fires if the transaction was rolled back. This distinction is exactly why after_commit is the right place for side effects that should never happen for data that did not actually get saved.

One practical gotcha: in test suites that wrap each test in a transaction for speed (Rails' default transactional fixtures), after_commit callbacks do not fire by default, because the test transaction is rolled back rather than committed. If you are testing after_commit behavior directly, you typically need use_transactional_tests = false for that test, or a gem like test_prof's after_commit matchers, to observe the callback firing.


Why External Side Effects Don't Belong Inside a Transaction

A database transaction can be rolled back. An email that has already been sent cannot be un-sent. An API call that already charged a customer cannot be silently undone just because your next database write failed.

This is why code like the following is a common source of bugs:

# Risky: the email might send even if the transaction later fails,
# or the transaction might hold a lock open while waiting on the network
ActiveRecord::Base.transaction do
order = Order.create!(user: user)
OrderMailer.confirmation(order).deliver_now
order.line_items.create!(cart.items_attributes)
end

Two separate problems show up here. First, if line_items.create! fails after the email already sent, the customer gets a confirmation for an order that no longer exists. Second, deliver_now performs a network call while the database transaction is still open, which holds a row lock (or worse, in databases like SQLite, blocks other writes entirely) for as long as the network call takes.

The fix is almost always to move the side effect into an after_commit callback, or push it onto a background job that only enqueues after commit:

ActiveRecord::Base.transaction do
order = Order.create!(user: user)
order.line_items.create!(cart.items_attributes)
end

# only reachable if the transaction committed successfully
OrderMailer.confirmation(order).deliver_later

deliver_later is itself worth a second look here too: Active Job's default adapter enqueues jobs immediately, which can still race ahead of a transaction that has not committed yet. Using after_commit inside the model, as shown in the previous section, is generally the safer default because Rails guarantees it only runs once the data is actually persisted.


Database Constraints, Validations, and Transactions

Active Record validations run in Ruby, before any SQL reaches the database. They are your first line of defense, but they are not airtight. Two requests can race past a validates :email, uniqueness: true check at nearly the same time, both see no existing record, and both attempt to insert, because the validation query and the insert are not atomic with each other.

Database constraints close that gap. A unique index at the database level guarantees uniqueness no matter how many requests race to insert at once:

class AddUniqueIndexToUsersEmail < ActiveRecord::Migration[8.0]
def change
add_index :users, :email, unique: true
end
end

With the index in place, the losing insert in a race condition raises ActiveRecord::RecordNotUnique inside the transaction, which rolls it back cleanly instead of silently creating a duplicate row. Validations and constraints are complementary: validations give you friendly error messages for the common case, constraints guarantee correctness under concurrency. If you already spend time optimizing your Active Record queries, it is worth applying the same discipline to your schema constraints, since a missing index often turns into a transaction rolling back for reasons that are hard to reproduce locally.


Transaction Isolation in Practice

Isolation determines how much one transaction can "see" of another transaction's uncommitted changes while both are running concurrently. Most Rails apps never need to think about this directly, because the default isolation level of your database (commonly READ COMMITTED on PostgreSQL) is a reasonable default for typical web traffic.

You can override the isolation level for a specific transaction when you need stronger guarantees, such as when reading a value and writing back a derived value based on it:

ActiveRecord::Base.transaction(isolation: :serializable) do
account = Account.lock.find(account_id)
account.update!(balance: account.balance - amount)
end

Stricter isolation levels reduce the chance of race conditions but increase the chance of transactions failing or blocking under concurrent load, so reach for them only where correctness genuinely depends on it, not as a default setting.

In practice, most Rails developers reach for lock: true (a SELECT ... FOR UPDATE under PostgreSQL and MySQL) far more often than they change the isolation level directly. Row locking is a more targeted tool: instead of changing how the whole transaction behaves, it blocks other transactions from reading or writing a specific row until yours finishes.

ActiveRecord::Base.transaction do
seat = Seat.lock.find(seat_id)
raise ActiveRecord::Rollback if seat.reserved?
seat.update!(reserved: true, user: current_user)
end

This pattern, sometimes called "pessimistic locking," is the standard way to prevent two concurrent requests from both reserving the same seat, slot, or limited-quantity item. Active Record also supports optimistic locking through a lock_version column, which does not hold a database lock at all; instead it detects conflicting updates after the fact and raises ActiveRecord::StaleObjectError, which is a better fit for low-contention resources where blocking other requests is more costly than occasionally retrying a failed update.

Isolation behavior also varies meaningfully by database engine. PostgreSQL supports row-level locking and multiple isolation levels concurrently; SQLite, by contrast, locks the entire database file for the duration of a write transaction, which changes how you should think about transaction length and concurrency. If your app runs on SQLite, it is worth reading how SQLite compares to PostgreSQL for Rails apps and, if you are running SQLite in production, how to tune SQLite for a production Rails 8 deployment, since long-running transactions have a much bigger blast radius there than on PostgreSQL.


Common Transaction Mistakes Rails Developers Make

  • Swallowing exceptions inside the block. Rescuing an error without re-raising it (or raising ActiveRecord::Rollback) tells Rails everything succeeded, so it commits partial work.
  • Doing external I/O inside a transaction. Emails, API calls, and file uploads inside a transaction block hold database locks open and can produce side effects that never get rolled back.
  • Wrapping a single write in a transaction for no reason. A single create! or update! call is already atomic on its own; wrapping it in transaction do ... end adds nothing but overhead and noise.
  • Assuming nested transactions are independent. Without requires_new: true, a nested transaction block joins the parent, so a rollback inside it rolls back the outer work too.
  • Relying on after_save for side effects that must not fire on a later rollback. Use after_commit when the side effect should only happen for data that is actually durable.
  • Not accounting for deadlocks under concurrency. Two transactions that update the same rows in a different order can deadlock; keeping transactions short and touching rows in a consistent order reduces this risk significantly.
  • Leaving transactions open longer than necessary. Fetching data, calling external services, or doing heavy computation inside a transaction block extends how long locks are held, which increases contention under load.

A concrete version of the deadlock mistake looks like this. Two background jobs process a transfer between the same two accounts, but in opposite order:

# Job A
ActiveRecord::Base.transaction do
account_1.lock!
account_2.lock!
# ...
end

# Job B, running around the same time
ActiveRecord::Base.transaction do
account_2.lock!
account_1.lock!
# ...
end

If Job A locks account_1 while Job B locks account_2 at nearly the same moment, each job then waits on a row the other one is holding, and the database eventually kills one of the transactions to break the deadlock. The fix is to always lock rows in a consistent, predictable order, for example always locking the account with the lower ID first, regardless of which side of the transfer it represents. This kind of bug rarely shows up in development, where requests run one at a time, and only appears once you have real concurrent traffic, which makes it worth thinking through deliberately rather than discovering it in production.


When You Need Transactions (and When You Don't)

Use a transaction when:

  • You are writing to more than one table (or more than one row) that must succeed or fail together.
  • You are reading a value and writing a derived value back, where a race condition would corrupt the result.
  • A multi-step business operation (checkout, signup, fund transfer) needs to be all-or-nothing.

You probably do not need a transaction when:

  • You are performing a single create!, update!, or destroy call. Active Record already treats a single write as atomic.
  • The operation is read-only. Transactions exist to protect writes, not queries.
  • The steps involved are genuinely independent and a partial failure is acceptable, or even desirable, for your use case.

A useful rule of thumb: if you would be upset to find one of the writes committed without the others, wrap them in a transaction. If each write stands fine on its own, skip it.


Best Practices for Clean Transaction Code

  • Keep transaction blocks as short as possible. Move query building, computation, and external calls outside the block wherever you can.
  • Use after_commit for emails, notifications, webhooks, and any other side effect that should only happen for data that actually persisted.
  • Prefer bang methods (create!, update!, save!) inside transactions so failures raise exceptions automatically instead of failing silently.
  • Use requires_new: true deliberately when a sub-operation genuinely needs to be rollback-able on its own, and document why.
  • Pair application-level validations with database-level constraints for anything where correctness matters under concurrency.
  • Extract complex multi-model transactions into a dedicated service object or model method, so the transaction boundary is easy to see and test in isolation.
  • Write a test that deliberately fails midway through the transaction and asserts that nothing was persisted. This catches accidental partial commits early.

Rails transactions are one of those features that are easy to use correctly by accident and easy to misuse without noticing, especially once callbacks, background jobs, and external services enter the picture. The core idea is simple: keep your transaction blocks focused on database writes, let exceptions do the work of signaling failure, and push anything that cannot be undone outside the block. Once that habit is in place, transactions stop being a source of mysterious bugs and become one of the most reliable tools you have for keeping your data consistent.

💌 Don’t miss out! Join my newsletter for web development tips, tutorials, and insights delivered straight to your inbox.

Thanks for reading & Happy coding! 🚀

Follow me on:

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.