LaunchKit
← All posts
· 20 min read · by The LaunchKit team · 0 views

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:

One duplicate key violation rescued in two places, drawn as two lanes. Rescued inside a plain transaction block there is no savepoint, PostgreSQL marks the whole transaction aborted, and the next statement raises PG::InFailedSqlTransaction so nothing survives. Rescued outside a requires_new block the savepoint is rolled back, the error counts as handled, and the writes on either side commit.

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.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.