Transactions and rollback in Rails
Rails gives you transaction do, and the block either all happens or none of it does. That sentence
is true, it is what the guide says, and most writing about Rails transactions stops there. Almost no
real bug is about that sentence. The bugs are about what counts as "none of it": which exceptions
abort the block, which ones got absorbed on the way out, what a nested block actually opens, and
what the Ruby objects believe once it is over.
Everything below was run against activerecord 8.1.3.1 on PostgreSQL 17.7, with
ActiveRecord.run_after_transaction_callbacks_in_order_defined = true to match
config.load_defaults 8.1. The SQL and the error text are copied out of the run.
What the block guarantees
Two things, and they are worth stating separately because only one of them is famous.
Any exception leaving the block triggers a ROLLBACK and is then re-raised, with
ActiveRecord::Rollback as the one exception to the re-raising. The handler at
active_record/connection_adapters/abstract/transaction.rb:649 is rescue Exception => error
rather than rescue StandardError, so an Interrupt or a SignalException rolls back on its way
out too, and a few lines down a thread whose status is "aborting" gets a rollback instead of a
commit. That is the guarantee, and it is wider than the one people rely on.
The second thing is that the block returns its own value, and ActiveRecord::Rollback turns that
value into nil:
transaction { 42 } => 42
with Rollback => nil
So if Order.transaction { ... } is a real idiom. The trap next to it is that a falsy block value
means nothing to the transaction: Order.transaction { false } returns false and commits, exactly
like Order.transaction { 42 } returns 42 and commits.
There is a third behaviour nobody documents in a headline and everybody notices in a log. Rails
materializes the transaction lazily: a block that runs no query sends no BEGIN.
Order.transaction { puts "(nothing queried)" }
(nothing queried)
Order.transaction { Order.count }
TRANSACTION (0.0ms) BEGIN
Order Count (0.7ms) SELECT COUNT(*) FROM "orders"
TRANSACTION (0.0ms) COMMIT
Wrapping a method "just in case" costs nothing when the method does not touch the database. It costs a held connection the moment it does.
The rescue that commits
Here is the bug the title of this section is about, in its smallest form:
Order.transaction do
Order.create!(number: "B-1")
begin
raise "the API call failed"
rescue => e
puts "rescued inside the block: #{e.class}: #{e.message}"
end
end
rescued inside the block: RuntimeError: the API call failed
orders after = 1 (numbers: ["B-1"])
The row is committed. Nothing left the block, so nothing rolled back, and the rescue that was
meant to make the code resilient made it partially applied instead. In real code the shape is never
this obvious: the rescue is inside a service object three calls down, or it is a
rescue Stripe::APIError around an HTTP call, or it is Rails.error.handle, which swallows by
design. Read it as a rule: a rescue inside a transaction block is a decision to commit whatever
came before it, and it should be written by someone who meant that.
Two variants of the same mistake.
save without the bang returns false and raises nothing:
bad.save = false errors=["Number can't be blank"]
orders after = 1 (numbers: ["C-1"])
return commits, and its history is worth knowing because the answer has changed twice:
returned = :returned
orders after = 1 (numbers: ["D-1"])
Committing on a non-local exit is the original behaviour. Rails 6.1 changed it to roll back, for a
specific reason the 7.1 changelog spells out: "in Ruby 2.3, the timeout library started using
throw to interrupt execution which had the adverse effect of committing open transactions". Rails
7.1 changed it back, under the heading "Bring back the historical behavior of committing transaction
on non-local return", once the timeout library stopped doing that. The 7.1 escape hatch,
config.active_record.commit_transaction_on_non_local_return, does not appear anywhere in
activerecord 8.1.3.1 or railties 8.1.3.1, so on 8.1 there is one behaviour and it is the one above.
So an answer written against Rails 7.0 saying return rolls back was correct then and is wrong on
8.1, and a return sitting mid-block in an old service object carries no record of which era its
author was writing for. Two framework versions disagreeing about a keyword is a good reason to test
the behaviour rather than remember it.
Rollback, and where it goes quiet
ActiveRecord::Rollback is the exception that aborts the transaction without escaping the block:
block value = nil
orders = 0
That is the documented contract, and the documentation is explicit about the limit: "the
ActiveRecord::Rollback exception in the nested block does not issue a ROLLBACK. Since these
exceptions are captured in transaction blocks, the parent block does not see it and the real
transaction is committed."
Watch it happen. Outer transaction, inner plain transaction do, raise ActiveRecord::Rollback
inside the inner one:
TRANSACTION (0.1ms) BEGIN
Order Create (0.3ms) INSERT INTO "orders" ... [["number", "E-outer"], ...]
Order Create (0.1ms) INSERT INTO "orders" ... [["number", "E-inner"], ...]
TRANSACTION (0.1ms) COMMIT
orders after = 2 (numbers: ["E-inner", "E-outer"])
No SAVEPOINT, no ROLLBACK, both rows committed. The inner transaction do opened nothing; it joined
the transaction already in progress, absorbed the Rollback because that is what a transaction
block does with it, and returned. The method that raised it believes it undid its work.
This is the failure mode to fear in a codebase where small methods wrap themselves in transactions defensively. Each one is correct read on its own. Call two of them from a third and the inner rollbacks evaporate.
requires_new and the savepoint
One option changes it:
TRANSACTION (0.2ms) BEGIN
Order Create (0.3ms) INSERT INTO "orders" ... [["number", "F-outer"], ...]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Order Create (0.2ms) INSERT INTO "orders" ... [["number", "F-inner"], ...]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
TRANSACTION (0.2ms) COMMIT
orders after = 1 (numbers: ["F-outer"])
requires_new: true emits a savepoint, and the name in the log is literally active_record_1,
numbered by nesting depth. PostgreSQL has no true nested transactions and neither does MySQL, so
this is the emulation, and it is a good one: a savepoint is cheap, it is undone independently, and
the outer transaction survives.
Two details from the same runs. When the outer transaction has not been materialized yet, the inner
requires_new block becomes the real transaction and no SAVEPOINT appears at all, which is the lazy
BEGIN from earlier showing through. And a savepoint that is not rolled back is closed with
RELEASE SAVEPOINT active_record_1, which matters to the next section, because it is not a commit.
The transaction you poisoned
The nastiest case is not a missed rollback. It is a rescue that was correct in every other context.
Order.transaction do
Order.create!(number: "G-1")
begin
Order.create!(number: "G-1")
rescue ActiveRecord::RecordNotUnique => e
puts "rescued: #{e.class}"
end
Order.create!(number: "G-2")
end
rescued: ActiveRecord::RecordNotUnique
PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "index_orders_on_number"
ESCAPED: ActiveRecord::StatementInvalid
PG::InFailedSqlTransaction: ERROR: current transaction is aborted, commands ignored until end of transaction block
orders after = 0
The rescue worked. The next statement did not, and could not, because PostgreSQL marked the whole
transaction aborted the moment the constraint fired. Every statement until ROLLBACK now answers
InFailedSqlTransaction, so the transaction is lost and so is G-1, which had nothing wrong with
it. Rails has warned about this since forever, in the class documentation for transaction: "one
should not catch ActiveRecord::StatementInvalid exceptions inside a transaction block."
RecordNotUnique is a StatementInvalid, and so is every other adapter error, which is what makes
the warning easy to walk past. The insert-or-ignore idiom that is fine at the top of a request is a
landmine one transaction do deeper.
What the product does about it instead
The LaunchKit boilerplate hits this exact case, because a Stripe webhook can be delivered twice and
the ledger row that makes it idempotent is a unique index. app/models/stripe_event.rb:
def self.process_once(id:, type:)
transaction(requires_new: true) do
create!(stripe_id: id, event_type: type)
yield
end
rescue ActiveRecord::RecordNotUnique
false
end
The savepoint is the whole point, and the rescue is deliberately outside the block, not inside it.
When the duplicate insert fires, the savepoint is rolled back, PostgreSQL considers the error
handled, and the enclosing transaction is usable again. Wrapped in an outer transaction with three
more writes around it:
first call = true
second call = false
orders = ["H-1", "H-2", "H-outer"]
payments = 1
The second delivery did nothing, the work either side of it committed, and the block yielded exactly
once. That is what requires_new is for, and it is close to the only thing worth reaching for it
for.
What the record believes afterwards
Here is the part of this post with no documentation behind it. A rolled-back record does not always know it was rolled back, and whether it knows depends on whether the rollback was a savepoint or the real thing.
For a create, both cases are fine. Rails restores id and new_record?:
inside: id=2 persisted?=true new_record?=false
after: id=nil persisted?=false new_record?=true
in db: false
For an update, they diverge. Same model, same update!(total: 999), same rollback, and the only
difference is where the rollback happened:
A. rolled back by a SAVEPOINT (requires_new)
after savepoint: total=999 changed?=false changes={}
db total = 1
B. rolled back by the outermost transaction
after rollback: total=999 changed?=true changes={"total" => [1, 999], "updated_at" => [...]}
db total = 1
In B the object is honest. The attribute reads 999, the dirty tracking says so, and changes names
the value the database actually holds. Call save and you get your update back.
In A the object is confidently wrong. It reports 999, it reports no pending changes, and the
database holds 1. Anything downstream that renders the record, serializes it into a job argument or
calls save on it is working from a value that was undone. restore_transaction_record_state at
active_record/transactions.rb:491 is guarded by restore_state[:level] <= 1, and the savepoint
case does not reach it.
The practical rule is short: after a savepoint rollback, reload anything you intend to keep using.
Nothing warns you.
When after_commit fires
After the COMMIT, and after nothing else.
TRANSACTION (0.2ms) BEGIN
Order Create (0.6ms) INSERT INTO "orders" ... [["number", "I-1"], ...]
-> after_save #I-1
TRANSACTION (0.2ms) COMMIT
-> after_commit #I-1 (A, declared first)
-> after_commit #I-1 (B, declared second)
after_save runs inside, after_commit runs outside, and the gap between the two lines is the
whole reason the callback exists. A job enqueued from after_create references a row that a worker
on another connection cannot see yet, and if the transaction rolls back it never will. The same trap
for broadcasts is the subject of
Turbo Stream actions, which also covers why
enqueue_after_transaction_commit still defaults to false in activejob 8.1.3.1.
A savepoint release is not a commit, and after_commit correctly does not fire on it:
TRANSACTION (0.2ms) BEGIN
Order Create (0.3ms) INSERT INTO "orders" ... [["number", "K-outer"], ...]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Order Create (0.2ms) INSERT INTO "orders" ... [["number", "K-inner"], ...]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
(outer still open)
TRANSACTION (0.1ms) COMMIT
-> after_commit #K-outer
-> after_commit #K-inner
Both callbacks queue up and fire together at the end, which is the behaviour you want and also means
a requires_new block buys you no earlier hook than the outer transaction already gives you.
Two orderings, and one of them changed
Callbacks on the same record run in declaration order under config.load_defaults 7.1 and later,
and in reverse declaration order on the raw gem default. Same two callbacks, one flag:
run_after_transaction_callbacks_in_order_defined = true
-> after_commit (A, declared first)
-> after_commit (B, declared second)
run_after_transaction_callbacks_in_order_defined = false
-> after_commit (B, declared second)
-> after_commit (A, declared first)
The mechanism is the private prepend_option at active_record/transactions.rb:342, seven lines
long, returning { prepend: true } when the flag is on and {} when it is not. Prepending reverses
a chain that was itself running backwards. active_record.rb:358 sets the attribute to false, and
railties/lib/rails/application/configuration.rb:291 sets it to true under the 7.1 defaults, so
what you get depends on your load_defaults line and not on your Gemfile.
Records are a separate ordering, and that one has not changed: they fire in the order they joined the transaction.
-> after_save #J-first
-> after_save #J-second
(both inserted, still inside the block)
-> after_commit #J-first (A, declared first)
-> after_commit #J-first (B, declared second)
-> after_commit #J-second (A, declared first)
-> after_commit #J-second (B, declared second)
So the outer loop is records and the inner loop is callbacks. If you have ever needed an
after_commit on a parent to run after every child's, the ordering is available, and depending on it
is still a bad idea, because it is a property of insertion order in a block somebody will reorder.
A callback that raises after the commit
There is no undo.
escaped: RuntimeError: the welcome email blew up
row committed anyway? true
The exception propagates out of save! as if the save had failed, and the row is on disk. Any caller
that does rescue => e; flash[:error] = "Could not create order" is now lying to the user about a
record that exists.
Treat after_commit bodies as their own failure domain: enqueue a job and let the job retry, or
rescue and report inside the callback. What you cannot do is let a mail delivery or an HTTP call
decide the apparent outcome of a write that already happened.
Why none of this shows up in your tests
RSpec and Minitest wrap each example in a transaction and roll it back. The line that opens it is
connection_pool.rb:379, reached from TestFixtures#setup_transactional_fixtures:
@pinned_connection.begin_transaction joinable: false, _lazy: false
joinable: false is the whole story, and the consequence is specific enough to be worth seeing:
TRANSACTION (0.1ms) BEGIN
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Order Create (0.4ms) INSERT INTO "orders" ... [["number", "R-1"], ...]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
-> after_commit #R-1
(test transaction still open; it will be rolled back)
row survived the test rollback? false
A plain transaction do in application code becomes a savepoint instead of joining, and its
after_commit fires on RELEASE SAVEPOINT. That is deliberate, and it is what makes
after_commit testable at all. It also means your test saw a commit callback fire for a row that
was never committed, so a job enqueued there points at an id that will not exist a millisecond
later.
More to the point for this post: the nested-transaction behaviour from three sections up is
inverted under test. The block that silently joined in production opened a savepoint in your suite,
and raise ActiveRecord::Rollback inside it worked. A green spec proves nothing about that code
path, which is the single best argument for reproducing transaction semantics in a console or a
script rather than in a spec.
lock!, with_lock, and the lock that is not one
record.lock! reloads the row with FOR UPDATE:
Order Load (0.1ms) SELECT "orders".* FROM "orders" WHERE "orders"."id" = $1 LIMIT $2 FOR UPDATE
with_lock is transaction { lock!(lock); yield } and nothing else, at
active_record/locking/pessimistic.rb:97. Prefer it, because the lock and the transaction that
gives the lock any meaning arrive together.
Three ways it does less than you think.
Called outside a transaction, lock! still emits FOR UPDATE and raises nothing. PostgreSQL runs
it in an implicit single-statement transaction, so the lock is taken and released before the next
line of Ruby. You get the SELECT, the log line, the feeling of safety, and no mutual exclusion at
all. Nothing in the framework checks for an open transaction here.
On an unsaved record it does nothing whatsoever. The body is guarded by if persisted? at
pessimistic.rb:74, so Order.new(number: "Q-2").lock! emits no SQL and returns self. Silent.
On a record with unsaved changes it does raise, and this one is good:
RuntimeError: Locking a record with unpersisted changes is not supported. Use `save` to persist the
changes, or `reload` to discard them explicitly. Changed attributes: "total".
A bare RuntimeError rather than a named class, which is a wart, but the message says exactly what
went wrong and what to do about it. The reason it must raise is that lock! reloads, so an
unpersisted change would be thrown away without a word.
Three ways the database ends your transaction
Each of these is real output from two connections fighting over one row.
FOR UPDATE NOWAIT against a held row, and a lock_timeout that expires:
ActiveRecord::LockWaitTimeout
PG::LockNotAvailable: ERROR: could not obtain lock on row in relation "orders"
ActiveRecord::LockWaitTimeout
PG::LockNotAvailable: ERROR: canceling statement due to lock timeout
Two serializable transactions with a read/write dependency:
ActiveRecord::SerializationFailure
PG::TRSerializationFailure: ERROR: could not serialize access due to read/write dependencies among transactions
Two threads taking the same two row locks in opposite orders:
ActiveRecord::Deadlocked
PG::TRDeadlockDetected: ERROR: deadlock detected
The hierarchy is where the useful decision lives, and it is not symmetric:
Deadlocked < TransactionRollbackError < StatementInvalid < AdapterError
SerializationFailure < TransactionRollbackError < StatementInvalid < AdapterError
LockWaitTimeout < StatementInvalid < AdapterError
TransactionRollbackError at active_record/errors.rb:539 is the framework's name for "the database
rolled you back, nothing is wrong with your data, try again". LockWaitTimeout is deliberately not
one of them: a timeout means somebody else is holding the row and may hold it for another minute, so
an immediate retry is the wrong reflex.
So the retry wraps the transaction and rescues the narrow class:
attempts = 0
begin
attempts += 1
Order.transaction do
# ...
end
rescue ActiveRecord::TransactionRollbackError
retry if attempts < 5
end
attempts=3 rows=["Z-3"]
The begin sits outside the transaction, not inside it, for the reason the poisoned-connection
section gave: by the time you can catch the error, the transaction is finished and the only legal
move is to start a new one.
One more thing isolation: will not let you do. Passing it to a nested block, even with
requires_new: true, raises
ActiveRecord::TransactionIsolationError: cannot set transaction isolation in a nested transaction,
because SET TRANSACTION ISOLATION LEVEL applies to a transaction and a savepoint is not one.
Isolation is a property of the outermost block or it is nothing.
Optimistic locking, and the column that bites
A lock_version integer column turns every UPDATE into a conditional one:
UPDATE "orders" SET "total" = $1, "updated_at" = $2, "lock_version" = $3
WHERE "orders"."id" = $4 AND "orders"."lock_version" = $5
[["total", 20], ["lock_version", 2], ["id", 3], ["lock_version", 1]]
Zero rows matched, so Rails rolls the transaction back and raises
ActiveRecord::StaleObjectError: Attempted to update a stale object: Order.. No database lock is
taken, nothing waits, and the loser finds out at write time. That is the right trade for a row a
human edits in a form and the wrong one for a row a background job increments, because the job has
nobody to show a conflict to.
The column is contagious in a way that catches people. It goes into every UPDATE, including the one
increment_counter builds, which is how a counter cache on a locked parent starts invalidating open
edit forms. That story is
Counter caches by hand, and it is why the only optimistically locked
table on this site, quiz_answers, counts its views with a raw update_all string instead.
Rails locking ships in two shapes and the framework picks neither for you, so the choice is about who gets made to wait. Pessimistic locking serializes and makes the second writer wait, which is correct for money and inventory and dangerous for anything holding a lock across an HTTP call. Optimistic locking never waits and hands the second writer a conflict, which is correct for a form a human filled in and useless for a job with nobody to ask.
The call, and what would change it
Use a bare transaction do and let exceptions out of it. That is the whole recommendation, and the
rest of this post is what happens when you deviate.
Reach for requires_new: true in one situation only: you expect a specific database error, you want
to handle it, and you want the enclosing transaction to survive. The Stripe ledger above is the
canonical case. Using it as a general "make my rollback work" switch means you have nested
transactions on purpose, and you are about to meet the record-state divergence from earlier.
Prefer with_lock to lock! for the boring reason that it opens the transaction for you, so the
two most common ways of getting a lock wrong, taking it outside a transaction and taking it on a
record with pending changes, are one of them fewer.
Retry on TransactionRollbackError and nothing broader. rescue StatementInvalid around a
transaction will happily retry a NOT NULL violation forever.
What would change the recommendation: restore_transaction_record_state learning to restore dirty
state after a savepoint rollback would remove the sharpest reason to distrust nested transactions,
and a lock! that raised outside a transaction the way it already raises on unsaved changes would
delete a whole category of lock that does not lock. Both are small changes. Neither has happened.
The cost of the position is that "let exceptions out" is not free in a controller. An exception from
deep inside a service reaches the error handler with no partial state left to inspect, and that
partial state is occasionally the thing that would have made a good error message. The trade is
still worth taking, because the alternative is deciding at every rescue whether it sits inside a
transaction, and nobody gets that right every time.
What this post does not cover
The LaunchKit boilerplate uses transactions in three places and none of them is exotic:
StripeEvent.process_once quoted above, ActiveRecord::Base.transaction { persist } in
app/forms/onboarding/base_form.rb:19 so a multi-model onboarding step is all-or-nothing, and
ConfigTransfer#import wrapping a settings blob plus a set of template upserts. It declares no
after_commit, no after_rollback, no with_lock and no lock_version column anywhere. The
optimistic-locking example above comes from this sales site, not from the product, which is the
honest provenance.
Also absent: advisory locks, where with_advisory_lock 7.6.0, released 2026-08-05, MIT, Ruby 3.3 or
newer, 31 million downloads, is the gem to look at and a named lock across processes is a different
problem from a row lock; multi-database transactions, which Rails will let you open and which the
comment above ActiveRecord.after_all_transactions_commit calls "a sharding anti-pattern that comes
with a world of hurts"; MySQL, where the savepoint SQL is the same and the deadlock detection and
the error codes are not; and any timing figure, because what separates these mechanisms is what they
lock and what they leave behind rather than microseconds.
Comments
No comments yet. Be the first.