Ruby

Rails Transactions: ActiveRecord::Rollback & Error Handling

Rails Transactions: ActiveRecord::Rollback & Error Handling

A Rails transaction wraps multiple database writes into one atomic unit: ActiveRecord::Base.transaction do ... end. If any statement raises an exception, ActiveRecord rolls back every change; if the block finishes, everything commits. Use bang methods (save!, create!) so failures raise. Raising ActiveRecord::Rollback cancels the transaction silently — the block swallows it.

This guide is a reference for how transactions behave in current Rails: the syntax, the exact rollback rules, error handling that doesn’t fight the transaction machinery, and the transaction callbacks added in Rails 7.2. Every example is verified on Rails 8 (ActiveRecord 8.1) and Ruby 3.4.

Rails Transaction Syntax

There are three equivalent ways to open a transaction:

Ruby
ActiveRecord::Base.transaction do
  # ...
end
 
User.transaction do
  # ...
end
 
user.transaction do
  # ...
end

All three start the same database transaction. On a single-database app, the receiver changes only the readability. With multiple databases, it also picks the connection the transaction opens on — writes to a model on a different database run outside the transaction — so invoke transaction on the model whose database you are writing to. A useful convention:

  • ActiveRecord::Base.transaction when the block mixes several models and service calls:
Ruby
ActiveRecord::Base.transaction do
  attributes = user.prepare_attributes(account)
  membership = Membership.create!(attributes)
  LogService.log_creation(user, membership)
end
  • Model.transaction when the block mostly touches that one model:
Ruby
User.transaction do
  user = User.create!(attributes)
  user.log_activity("creation")
end
  • instance.transaction when everything operates on a model instance you already hold:
Ruby
user.transaction do
  user.charge!(amount)
  user.log_activity("charge")
end

How Rollbacks Work in Rails Transactions

Whether a transaction commits or rolls back depends entirely on how control leaves the block:

Inside the blockTransactionWhat the caller sees
An exception is raisedRolls backThe exception, re-raised after the rollback
raise ActiveRecord::RollbackRolls backNothing — the block swallows it and returns nil
return or breakCommitsExecution continues; earlier writes are committed
The block finishesCommitsThe block’s return value

Silent Rollbacks with ActiveRecord::Rollback

ActiveRecord::Rollback is the one exception with special treatment. It rolls the transaction back, but the block swallows it, so nothing reaches the calling code.

Ruby
ActiveRecord::Base.transaction do
  order.update!(status: "paid")
  raise ActiveRecord::Rollback if payment_declined?
end
 
# Execution continues here with the update rolled back —
# no exception was raised to the caller.

Use it when canceling the transaction is a normal outcome rather than an error. Because it fails silently, the caller can’t tell a commit from a rollback. If the caller needs to know, raise a regular exception instead.

return and break Commit the Transaction

In current Rails, exiting a transaction block early with return, break, or throw commits the writes made so far:

Ruby
def create_user
  User.transaction do
    user = User.create!(name: "Ada")
    return if skip_membership? # the user above is still committed
    Membership.create!(user: user)
  end
end

Earlier Rails versions rolled back on return and went through a deprecation cycle to reach today’s behavior. Older articles and answers often state the opposite. On Rails 8, an early return is a commit. If you want to cancel, raise ActiveRecord::Rollback or another exception.

Nested Transactions and requires_new

Calling transaction inside a transaction does not open a real second transaction by default. The inner block is fused into the outer one, which produces a classic gotcha: ActiveRecord::Rollback raised in the inner block is swallowed by the inner block and rolls back nothing.

Ruby
ActiveRecord::Base.transaction do
  User.create!(name: "Ada")
 
  ActiveRecord::Base.transaction do
    User.create!(name: "Grace")
    raise ActiveRecord::Rollback
  end
end
 
# Both users are committed — the inner rollback was silently ignored.

To make the inner block independently cancelable, pass requires_new: true. ActiveRecord emulates the nested transaction with a database savepoint, and the inner rollback undoes only the inner writes:

Ruby
ActiveRecord::Base.transaction do
  User.create!(name: "Ada")
 
  ActiveRecord::Base.transaction(requires_new: true) do
    User.create!(name: "Grace")
    raise ActiveRecord::Rollback
  end
end
 
# "Ada" is committed; "Grace" is rolled back.

Error Handling in Rails Transactions

Rollbacks are driven by exceptions, so error handling and transactions are tightly coupled. These rules keep them working together instead of against each other.

Use Bang Methods So Failures Raise

Non-bang methods like save and create report failure with a return value, not an exception. Inside a transaction, that silence is a data-integrity bug: nothing raises, so nothing rolls back.

Ruby
ActiveRecord::Base.transaction do
  user = User.create(user_attributes)
  user.memberships.create(membership_attributes)
end

If the membership fails validation here, the transaction still commits, and you end up with a user without the membership record it was supposed to have.

The bang versions raise on failure (create! raises ActiveRecord::RecordInvalid when validations fail), and the exception rolls back every write in the block:

Ruby
ActiveRecord::Base.transaction do
  user = User.create!(user_attributes)
  user.memberships.create!(membership_attributes)
end

Now either both records exist or neither does.

Rescue Outside the Transaction Block

Where you put the rescue decides whether the rollback happens. Rescuing inside the block catches the exception before the transaction machinery sees it, so the transaction commits:

Ruby
User.transaction do
  user.charge!
  user.grant_access!
rescue SomeError
  # The exception never reaches the transaction — it COMMITS,
  # including whatever charge! wrote before the failure.
end

Let the exception propagate out of the block, and rescue around the transaction instead:

Ruby
def charge_user
  User.transaction do
    user.charge!
    user.grant_access!
  end
rescue SomeError
  # The transaction has already rolled back — handle the failure here.
end

This way you get both behaviors you want: the rollback and the rescue.

Do Not Rescue from ActiveRecord::StatementInvalid

ActiveRecord::StatementInvalid means the database itself rejected a query — a syntax error, a constraint violation, a lost connection. Swallowing it hides real infrastructure problems:

Ruby
def perform_action(...)
  User.transaction do
    # ...
  end
rescue ActiveRecord::StatementInvalid
  # Don't do this — you've silenced a database-level failure.
end

Let these errors surface to your error tracker. You should always know when queries fail at the database level.

Do Not Catch Generic Errors

Rescuing broad classes like StandardError around a transaction catches far more than the failure you had in mind, including errors raised by unrelated code inside the block. That silences real bugs and makes rollback behavior unpredictable. Rescue the specific error classes you expect the block to raise, and let everything else propagate.

Transaction Callbacks: after_commit and after_rollback

Side effects like emails and background jobs don’t roll back. If you enqueue a job inside a transaction and the transaction rolls back, the job still runs against data that no longer exists.

Since Rails 7.2, the transaction block yields the transaction object, and you can register callbacks on it directly:

Ruby
ActiveRecord::Base.transaction do |transaction|
  transaction.after_commit do
    OrderMailer.confirmation(order).deliver_later
  end
 
  order.update!(status: "paid")
end

The after_commit callback runs only if the transaction commits; on rollback it’s discarded. There’s also after_rollback for the inverse: cleanup or logging that should happen only when the transaction fails. Keep side effects in these callbacks rather than in the transaction body, and rollbacks stay side-effect-free.

When Not to Use Transactions

Transactions guarantee atomicity across multiple writes. Used elsewhere, they add cost without adding safety:

  • A single query doesn’t need one. Every individual statement is already atomic; wrapping one save! in a transaction adds overhead and nothing else.
  • Keep non-database work out of the block. An external API call or slow computation inside a transaction holds the database connection and any row locks for its whole duration. Do the slow work first, then open the transaction for the writes.

The Costs of Transactions

Even a correct transaction isn’t free. While it’s open, it occupies a connection from the pool and holds locks on every row it has written. Other requests queue behind those locks, and under load, a few long-running transactions are enough to exhaust the pool or deadlock with each other. Transactions also add complexity (rollback paths, callback timing, nesting), so keep the code inside a block as small as possible.

Long-running transactions rarely fail loudly — they surface as slow queries and stuck connections first. AppSignal’s database monitoring shows which transactions hold locks longest, so you catch contention before users do. The slow query guide shows how to find the offenders in a Rails app.

Wrapping Up

Rails transactions are a small API with precise rules: exceptions roll back and re-raise, ActiveRecord::Rollback rolls back silently, return and break commit, and nested blocks need requires_new: true to roll back independently. Use bang methods so failures raise, rescue outside the block, and move side effects into after_commit.

With those rules in place, a transaction block does exactly what it promises: all of the writes, or none of them.

P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Frequently asked questions

Does Rails roll back a transaction on exceptions?
Yes. Any exception raised inside a transaction block rolls back every write made in the block, and the exception is re-raised after the rollback. The only exception ActiveRecord swallows is ActiveRecord::Rollback, which rolls back silently.
What does ActiveRecord::Rollback do?
Raising ActiveRecord::Rollback inside a transaction block rolls back the transaction without re-raising: the block swallows the error and returns nil. Code after the transaction keeps running as if nothing failed.
How do I roll back a transaction in Rails?
Raise an exception inside the transaction block. Use bang methods like save! and create! so failed writes raise automatically, or raise ActiveRecord::Rollback to cancel the transaction without an error reaching the caller.
Do return and break roll back a Rails transaction?
No. In current Rails, exiting a transaction block with return or break commits any writes already made in the block. Only exceptions trigger a rollback.

Published , Updated

Wondering what you can do next?

  • Share this article on social media
Paweł Dąbrowski

Paweł Dąbrowski

Our guest author Paweł is an open-source fan and growth seeker with over a decade of experience in writing for both human beings and computers. He connects the dots to create high-quality software and build valuable relations with people and businesses.

All articles by Paweł Dąbrowski

Become our next author!

Find out more
$appsignal install

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal