Rails Transactions: A Practical Guide
Learn how Rails transactions work: ActiveRecord commits, rollbacks, savepoints, and after_commit callbacks explained.
• 15 min read
• 15 min read
Learn how Rails transactions work: ActiveRecord commits, rollbacks, savepoints, and after_commit callbacks explained.
• 15 min read
• 15 min read
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.
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:
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."
Imagine an e-commerce checkout. Placing an order typically involves multiple writes:
Order record.LineItem records for each product.Product inventory counts.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.
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.
ActiveRecord::Base.transaction in PracticeThe 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.
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.
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 ExplainedSometimes 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.
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.
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.
after_commit and after_rollbackActive 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.
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.
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.
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.
ActiveRecord::Rollback) tells Rails everything succeeded, so it commits partial work.create! or update! call is already atomic on its own; wrapping it in transaction do ... end adds nothing but overhead and noise.requires_new: true, a nested transaction block joins the parent, so a rollback inside it rolls back the outer work too.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.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.
Use a transaction when:
You probably do not need a transaction when:
create!, update!, or destroy call. Active Record already treats a single write as atomic.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.
after_commit for emails, notifications, webhooks, and any other side effect that should only happen for data that actually persisted.create!, update!, save!) inside transactions so failures raise exceptions automatically instead of failing silently.requires_new: true deliberately when a sub-operation genuinely needs to be rollback-able on its own, and document why.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.