In the lock article we broke down how the mental model of a “database mutex” is fundamentally broken, and will come to surprise you over time. This article is going to do the same with transactions, which many of us early on may think of transaction do ... end as a magic incantation to save us from database woes when we write something like this:
Order.transaction do
order.update!(status: "paid")
PaymentGateway.charge!(order) # HTTP call to the payment provider
LedgerEntry.create!(order:, amount_cents: order.total_cents)
FulfillmentJob.perform_async(order.id)
ReceiptMailer.receipt(order).deliver_later
endThis reads a lot like a promise that everything will either succeed together, or fail and clean up for us like magic. If there’s any lesson you should learn with Rails it’s that magic always comes with a cost, and “obvious” things have failure modes that tend to show up at the most inopportune moments.
One might expect that this transaction safely wraps the act of charging, ledgering, dispatching a job, sending an email, and if any part fails it’ll be undone no harm no foul. It’s the mental model you might expect with transaction, but it’s wrong in several dangerous ways.
Just because a transaction and a block share a Ruby construct does not mean they are the same, and failing to understand the underlying database behavior will cause surprises. A transaction is a database-side construct that covers SQL statements within a single connection, whereas the Ruby block covers whatever you typed in it. As you’ll see in the rest of this article there are a lot of failures that can hide in the gap between those two, and it’s wider than you might think.
We’re going to walk through six specific examples with sample code and tests, and most importantly how to find and eliminate them in a production codebase.
What the Transaction Covers
Let’s step through what happens inside that Order.transaction block. ActiveRecord is going to start by checking out a connection, sending BEGIN, running the block, and sending COMMIT if the block finishes or ROLLBACK if it raises. It encompasses the SQL statements that occur between that BEGIN and COMMIT, but nothing else.
There are three properties doing a lot of structural work here:
One: The transaction is per connection, not per model. Calling Order.transaction will not scope anything to Order, it will apply to any model that participates during the same connection. The documentation’s own example saves a balance record inside of an Account.transaction block (Rails: ActiveRecord::Transactions::ClassMethods). It’s useful, but keep in mind that same connection point, because it’s going to come up later as a failure mode.
Two: Isolation is defined by the session, which can vary between different types of databases. InnoDB defaults to REPEATABLE READ (MySQL: Transaction Isolation Levels); PostgreSQL defaults to READ COMMITTED (PostgreSQL: Client Connection Defaults). That means the same Ruby may have different concurrency semantics, and you’re not getting a warning from Rails about these differences. You can ask for a level explicitly with transaction(isolation: :serializable), but only on the outermost transaction. Asking on a nested transaction raises ActiveRecord::TransactionIsolationError (Rails: DatabaseStatements#transaction).
Three: A transaction is not a lock, the same as a lock is not a database mutex. It makes writes atomic, but it does not stop a concurrent transaction from reading the same rows and potentially beating you to the commit. A check-then-act pattern inside of a transaction is a race condition unless you use a FOR UPDATE lock or explicitly raise the isolation level, which was mentioned in the locking article.
Past that? Well that’s where the danger lies. It doesn’t cover Ruby side effects nor does it span connections, databases, shards, or services.
Failure One: Side Effects Don’t Roll Back
Everything that happens inside of that transaction block that is not explicitly SQL will not roll back if the transaction fails. That means HTTP calls, job enqueues, cache writes, file writes, emails sent, or anything else you can do in Ruby will not be undone because the database has no idea any of that happened.
Order.transaction do
order.update!(status: "paid")
PaymentGateway.charge!(order) # charges the card over HTTP
raise ActiveRecord::Rollback # oops, something went wrong
end
# The order status rolled back. The charge did not.That ROLLBACK from raise ActiveRecord::Rollback will undo the order.update!, but the HTTP call to the PaymentGateway has already happened. Undoing that means sending another request, but it’s not going to automatically happen for you, because again the database has no clue what in the world a PaymentGateway is.
A transaction is not a unit of work boundary, it’s a hybrid mixture of a Ruby block scope and a database scope, and those side effects happen in pure Ruby.
The Fix: after_commit (with caveats)
Move side effects to after_commit callbacks or current_transaction.after_commit (Rails 7.2+, originating from Evil Martians’ after_commit_everywhere gem):
# Charge first, outside the transaction
charge_result = PaymentGateway.charge!(order)
# Then wrap only the database writes
Order.transaction do
order.update!(status: "paid")
LedgerEntry.create!(order_id: order.id, amount_cents: order.total_cents, charge_id: charge_result.id)
# Enqueue downstream work only after COMMIT succeeds
ApplicationRecord.current_transaction.after_commit do
FulfillmentJob.perform_async(order.id)
ReceiptMailer.receipt(order).deliver_later
end
endWe charge first because if something fails afterward, a charge with a stored charge_id is refundable. If we’d marked the order “paid” first and then failed to charge, the order says paid but no money moved.
If you prefer class-level callbacks over the inline form:
class Order < ApplicationRecord
after_commit :enqueue_fulfillment, on: :update, if: -> { saved_change_to_status?(to: "paid") }
private
def enqueue_fulfillment
FulfillmentJob.perform_async(id)
end
endThe callback fires only after COMMIT succeeds. If the transaction rolls back, it never runs. For job enqueues specifically, Rails 8.2 makes enqueue_after_transaction_commit the default (Rails API: ActiveJob::Enqueuing): jobs enqueued inside a transaction are automatically deferred until commit and dropped on rollback. If a job needs to enqueue immediately regardless of the surrounding transaction, the per-job opt-out is self.enqueue_after_transaction_commit = false. This is Rails papering over a known failure case with more implicit magic, which creates categorically more confusing edges you’re likely to hit when behavior changes between versions.
To be fair after_commit has its own failure window, as I mentioned in a previous article. It runs after the lock releases, so other transactions can see and modify the row before your callback executes. If the process is killed between COMMIT and the callback firing, the side effect is lost entirely while the data remains durable. Karol Galanciak documents this in The Inherent Unreliability of after_commit Callbacks. For critical effects (payments, webhooks, audit trails), an outbox row committed alongside the business write is a much more durable alternative.
The longer-term fix is pulling effects out of model callbacks entirely and into explicit command objects that state their ordering explicitly, which is the argument from the callbacks article. The larger an application becomes the more you stand to gain by removing implicit knowledge and magic in favor of very explicit and clear interfaces which make accidental failures harder.
Aside: There are contingents which would argue it is up to you to know the framework well. I disagree. An organization of any meaningful size will have hundreds of engineers at varying levels of framework knowledge, and the ones who write the next production incident are likely not the ones reading this article. Relying on universal expertise is not a strategy, it’s a prayer. The systems thinker asks what happens when the next new hire, the contractor under a tight deadline, or the senior engineer who’s never written a line of Ruby before encounters this trap. The answer is they walk straight into it, and your customers pay that price. Guardrails, linters, runtime detection, and explicit APIs exist to make correctness the path of least resistance rather than a reward for memorizing documentation. Anything less is leaving your production stability up to the weakest link in a chain you do not control, which is a fundamentally irresponsible proposition at scale.
Failure Two: Unnecessary Work Extends Lock Lifetime
Transactions should be tight and focused, doing a limited set of work. Failing to do so will lead to our next failure case:
Order.transaction do
store = Store.find(store_id)
orders = store.orders.where(status: "paid").includes(:line_items)
report = DailyReport.build_from(orders) # heavy computation
orders.find_each { |o| o.update!(status: "settled") }
store.update!(last_settled_at: Time.current)
endThe writes in this transaction take row locks that persist until the final COMMIT. The reads and computation don’t take locks themselves, but they extend the transaction’s duration, meaning actions that do not require the database can block all other requests.
While the duration problem is bad, there are other cascading failures which emerge from this. On MySQL, a long-running transaction prevents InnoDB from cleaning up old row versions. Every other query touching those rows has to scan through increasingly stale history to find the current data, and the longer your transaction sits open the slower everyone else gets. Percona documents cases where a single idle transaction degraded SELECT performance across the entire database until a restart (Percona: Chasing a Hung MySQL Transaction). On PostgreSQL the equivalent is vacuum blocking: a long transaction prevents dead row cleanup, and the table bloats until the transaction closes.
Both databases also take up a pooled connection for the entire duration of the block. Rails maintains a limited pool of database connections (typically 5-20 depending on configuration), and each open transaction holds one of those connections exclusively until it commits or rolls back. If your transaction is waiting on a report to compute, that’s one fewer connection available for every other request in the application. Under enough load, the pool empties and new requests start queuing with ActiveRecord::ConnectionTimeoutError even though the database itself is idle.
There’s also a more subtle cost in that long transactions can block database migrations. Migrations run DDL (Data Definition Language) statements like CREATE TABLE, ALTER TABLE, and ADD INDEX, and those statements need exclusive locks that can’t be acquired while your transaction holds its own locks on the same tables. Your deploy sits there waiting for your transaction to finish (GitLab: Merge Request Performance Guidelines).
The same applies to external HTTP calls, except you don’t control how long they take:
Order.transaction do
order.update!(status: "paid")
PaymentGateway.charge!(order) # 2 second HTTP timeout
LedgerEntry.create!(order:, amount_cents: order.total_cents)
endIf PaymentGateway.charge! takes 2 seconds, you’ve held a connection for 2 seconds doing zero database work. If it times out at 30 seconds, you’ve held the connection and its locks for 30 seconds. Retry logic inside the transaction makes this multiplicative: three retries with a 30-second timeout is 90 seconds of lock hold time on a single row, blocking every other request that touches it. GitLab’s transaction guidelines put it plainly: “Ideally, a transaction should only contain database statements.” Their avoid-list is Sidekiq jobs, emails, HTTP API calls, database statements on a different connection, file system operations, heavy computation, and sleep.
The Fix: Narrow the Transaction, Retry Outside It
Do your reads before opening a transaction, compute outside of it, and wrap only the writes:
store = Store.find(store_id)
orders = store.orders.where(status: "paid").includes(:line_items)
report = DailyReport.build_from(orders)
orders.in_batches do |batch|
Order.transaction { batch.update_all(status: "settled") }
end
store.update!(last_settled_at: Time.current)
reportIf you need retry logic, wrap the transaction rather than putting retries inside it:
attempts = 0
begin
Order.transaction do
order.lock!
order.update!(status: "paid")
end
ExternalService.notify!(order)
rescue Net::ReadTimeout => e
attempts += 1
retry if attempts < 3
raise
endEach attempt gets its own short transaction. If the external call fails, the lock is already released and the retry starts fresh.
We’ve traded one giant atomic unit for many smaller ones, meaning a mid-way crash leaves some orders settled and some not. That has the consequence of requiring our jobs to be idempotent, that is, safe to re-run. The benefit we derive from that trade is that each transaction lock takes milliseconds, rather than waiting for the larger unit of work to complete.
At scale that’s a good trade to make, and it’s a lesson that Erlang learned decades ago: Design for failure and recovery, rather than trying to prevent failure entirely (Armstrong: Making Reliable Distributed Systems in the Presence of Software Errors). A job that can crash and resume from where it left off is more durable than one that tries to move the world around it, because retrying that type of job is an on-call nightmare waiting to happen.
Aside: It’s a good idea in general to assume things can and will fail, especially external systems, and design fault tolerance around that. You’ll save yourself hundreds if not thousands of on-call hours collectively by doing so.
Failure Three: Nested Rollbacks Get Swallowed
If you nest transaction blocks you might expect them to cascade, but in reality they’re not true nested transactions. They join the parent transaction by default, and the outermost block is what controls the commits and rollbacks, leading to this failure mode:
Order.transaction do
order.update!(status: "processing")
Order.transaction do
order.update!(status: "paid")
raise ActiveRecord::Rollback # <-- swallowed here
end
order.reload
puts order.status # => "paid" -- the inner write stuck!
end
# COMMIT fires. order.status is "paid" in the DB.Without requires_new, the inner transaction call is a no-op that joins the outer one. ActiveRecord::Rollback is special-cased to be rescued without re-raising, so the outer transaction never sees it meaning both writes commit. Tobias Pfeiffer documented this exact case in Surprises with Nested Transactions, Rollbacks and ActiveRecord, and it remains one of the most common sources of confusion in the framework.
This problem gets worse when you remember that Rails implicitly creates transactions around every save, destroy, and its callback chains (Rails: ActiveRecord::Transactions::ClassMethods). If your explicit transaction block calls a method that internally calls save on another model? You’re nesting transactions which means the swallowed-rollback trap shows up in code that doesn’t look nested.
Savepoints via requires_new: true
Passing requires_new: true creates a savepoint, which is a bookmark inside the transaction that the database can rewind to without rolling back everything before it:
Order.transaction do
order.update!(status: "processing")
Order.transaction(requires_new: true) do
order.update!(status: "paid")
raise ActiveRecord::Rollback
end
# Inner savepoint rolled back, outer continues
order.reload
puts order.status # => "processing"
endThis is the behavior most people expect from nested blocks, but it requires an explicit opt-in. Any exception other than ActiveRecord::Rollback will still bubble up and roll back the entire outer transaction, so domain errors in the inner block need an explicit rescue.
What requires_new emits on the wire depends on whether the outer transaction has written yet:
-- Outer has written: savepoint
BEGIN
INSERT INTO orders (total_cents) VALUES (100)
SAVEPOINT active_record_1
INSERT INTO orders (total_cents) VALUES (3000)
RELEASE SAVEPOINT active_record_1
COMMIT
-- Outer is clean: Rails 7.1+ restarts the transaction instead
BEGIN
INSERT INTO orders (total_cents) VALUES (2000)
ROLLBACK AND CHAIN
INSERT INTO orders (total_cents) VALUES (9000)
COMMIT
The writes inside a savepoint only land when the outer COMMIT fires, and locks acquired inside the savepoint are held until that outer COMMIT too. The ROLLBACK AND CHAIN optimization is documented in rails/rails#44526.
MySQL and PostgreSQL Caveats
On MySQL, DDL (Data Definition Language) statements like CREATE TABLE, ALTER TABLE, and TRUNCATE implicitly commit the current transaction and discard all savepoints. If a TRUNCATE runs inside a requires_new block, MySQL releases the savepoint out from under Rails. When the block finishes and Rails tries to RELEASE SAVEPOINT, it gets a database error because the savepoint no longer exists. TRUNCATE is easy to forget here because it feels like a data operation, but in MySQL it’s DDL (Rails: Caveats).
On PostgreSQL, a database error inside a transaction poisons the entire transaction. Every subsequent statement fails with “current transaction is aborted, commands ignored until end of transaction block” until you issue a full ROLLBACK. Rescuing ActiveRecord::StatementInvalid inside the block and continuing will appear to work on MySQL but explode on PostgreSQL (Rails: Exception Handling). Evil Martians wrote up the production version in detail.
The Fix: Flatten, or Use requires_new With Rescue
If you need a sub-operation that can fail without taking out the outer transaction, wrap it in requires_new: true and rescue the error explicitly:
Order.transaction do
order.update!(status: "processing")
begin
Order.transaction(requires_new: true) do
PaymentRecord.create!(order:, amount: order.total)
end
rescue ActiveRecord::RecordInvalid => e
# Handle the failure: the savepoint rolled back,
# but the outer transaction continues
order.update!(status: "payment_failed")
end
endOr better yet don’t nest transaction blocks at all and flatten the logic. If you can’t flatten it, extract a service object that owns its own transaction boundary.
Failure Four: A Single Write Doesn’t Need a Wrapper
Sometimes you don’t even need a transaction:
Order.transaction do
order.update!(status: "paid")
endA single update! already wraps itself and its callbacks in a transaction (Rails: ActiveRecord::Transactions::ClassMethods), and a single SQL statement is atomic on its own. Adding another transaction around it only adds indirection, and leads us right back to Failure Three.
The Fix: Delete It
Does a transaction group two or more statements whose partial successes are unacceptable within a single connection? Great, add a transaction. If not, delete it.
Failure Five: Non-Local Exits Commit the Transaction
Using return, break, or throw inside a transaction block has meant different things across different Rails versions:
Order.transaction do
order.update!(status: "paid")
return if order.total_cents.zero? # <-- early return
LedgerEntry.create!(order:, amount_cents: order.total_cents)
endWhat happens with the return? Through Rails 6.0, it committed the transaction. Rails 6.1 deprecated it because Timeout.timeout used Ruby’s throw internally (a control flow mechanism similar to return that exits a block from the outside), which was committing half-finished transactions when a timeout fired. Rails 7.0 made return, break, and throw roll back instead. Then the timeout gem (0.4.0) switched to raising an exception instead of using throw, and Rails 7.1 restored the original commit behavior.
On Rails 8, return inside a transaction block commits. The transaction completes with whatever writes happened before the return. In the example above, the order gets status “paid” with no ledger entry. The return looked like an early abort but acted as a partial commit.
This is common enough that RuboCop (a popular Ruby linter) ships Rails/TransactionExitStatement to flag it on the affected versions (rubocop-rails docs). On Rails 8 the cop stops flagging because the behavior is defined (commit), but “defined” and “intentional” are different things. A return committing a partial write is rarely what the author meant.
The exact same five lines of code mean something dramatically different across different versions of Rails without the developer changing anything. Some will commit without warning, some rollback, and then all of a sudden they commit again all depending on what Rails version you’re on. When a framework solves problems by altering the implicit semantics of existing syntax rather than introducing new explicit API, it teaches engineers that they cannot trust what their code means by reading it. You end up needing a version-specific mental model to predict behavior, which is the opposite of readable software.
The Fix: Don’t Use return Inside Transaction Blocks
Use conditional logic instead:
Order.transaction do
order.update!(status: "paid")
unless order.total_cents.zero?
LedgerEntry.create!(order:, amount_cents: order.total_cents)
end
endOr better yet extract the decision outside:
return if order.total_cents.zero?
Order.transaction do
order.update!(status: "paid")
LedgerEntry.create!(order:, amount_cents: order.total_cents)
endFailure Six: Transactions Cannot Span Connections
If your application uses multiple databases (a separate database for a different part of the business, or multiple copies of the same database split by region or customer), a single transaction block cannot coordinate writes across them:
Order.transaction do
order.update!(status: "paid") # writes to primary
WarehouseItem.lock.find_by!(sku:).decrement!(:stock) # writes to warehouse_db
endIf Order and WarehouseItem use different database connections (configured via connects_to in Rails), this does not give you a cross-database transaction. Each connection has its own transaction. If the warehouse write fails after the order write succeeds, you get a committed order with no inventory deduction. The transaction only applies to Order, not WarehouseItem.
GitLab’s guidelines are explicit about this: statements against a different database connection inside your transaction block “are not part of the transaction and are not rolled back in case something goes wrong. They act as third-party calls” (GitLab: Transaction Guidelines).
There is no mechanism in Active Record that coordinates commits across connections. Writing to two databases inside the same transaction block means each connection commits independently. A crash between the two leaves you inconsistent with no recovery path, because the framework has no coordination protocol between them.
The Fix: Eventual Consistency or Sagas
For cross-database operations, you need to acknowledge that the two writes will not be atomic, and handle that inconsistency explicitly.
An outbox pattern writes a message row in the same transaction as your business data. The message commits or rolls back with the write, guaranteeing they’re in sync. A separate process reads the outbox and delivers the messages afterward.
Order.transaction do
order.update!(status: "paid")
OutboxEvent.create!(
aggregate_type: "Order",
aggregate_id: order.id,
event_type: "order.paid",
payload: { sku: order.sku, quantity: 1 }
)
end
# A background processor reads the outbox and updates the warehouse DBA saga is a sequence of independent transactions where each step knows how to undo itself if a later step fails. If the payment goes through but inventory reservation fails, the payment step issues a refund. Each step is its own transaction on its own database or service, and the undo logic is explicit rather than relying on a ROLLBACK that can’t reach across connections.
begin
Order.transaction { order.update!(status: "paid") }
WarehouseDb.transaction { WarehouseItem.lock.find_by!(sku:).decrement!(:stock) }
rescue WarehouseDb::OutOfStock
# Compensate: undo the order
Order.transaction { order.update!(status: "payment_reversed") }
PaymentGateway.refund!(order)
endNeither of those are pretty, and they bring coordination problems that only exist once you split your data across databases. It’s part of why breaking things into services isn’t nearly as easy as it sounds. Pat Helland’s Life Beyond Distributed Transactions covers why transparent multi-database atomicity is impractical at scale: the coordination overhead, the failure modes when participants disagree, and the latency costs outweigh the convenience, and the industry moved toward these explicit-failure patterns instead.
Finding These in Your App
Knowing about these failures is one thing. Finding the ones already living in your codebase is another, and I guarantee you have a few if you’ve been around Rails long enough.
Start with what’s happening right now. On MySQL you can query information_schema.innodb_trx to see every open transaction, how long it’s been running, and how many rows it’s locking:
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_seconds,
trx_rows_locked, trx_mysql_thread_id
FROM information_schema.innodb_trx
WHERE trx_started < NOW() - INTERVAL 5 SECOND
ORDER BY trx_started;
On PostgreSQL the telltale sign is idle in transaction. It means a session opened a transaction, ran some SQL, and then stopped issuing statements while keeping the transaction open. That’s what happens when your code is inside a transaction block doing something slow like an HTTP call. The rails-pg-extras gem wraps this and other diagnostics into rake tasks (rake pg_extras:long_running_queries, rake pg_extras:bloat), or you can query pg_stat_activity directly:
SELECT pid, now() - xact_start AS txn_age, state, left(query, 80) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND xact_start < now() - interval '5 seconds'
ORDER BY xact_start;
PostgreSQL considers this dangerous enough to ship idle_in_transaction_session_timeout, a setting that will kill sessions stuck in this state beyond a configured duration. If you turn on query_log_tags (a Rails feature that annotates every SQL statement with the controller, job, or source file that generated it) your query results come pre-annotated so you can trace them directly back to the offending code (Rails: ActiveRecord::QueryLogs).
For catching these before they hit production, a custom RuboCop cop (a “cop” is a single rule) can flag side effects inside transaction blocks during code review:
module RuboCop
module Cop
module Domain
class NoIoInTransactions < Base
MSG = "Move `%<name>s` outside the transaction; it cannot be rolled back."
IO_METHODS = %i[
perform_async perform_later deliver_now deliver_later
post get put patch delete
].freeze
def_node_matcher :transaction_block?, <<~PATTERN
(block (send _ {:transaction :with_lock} ...) ...)
PATTERN
def on_send(node)
return unless IO_METHODS.include?(node.method_name)
return unless node.each_ancestor(:block).any? { |b| transaction_block?(b) }
add_offense(node, message: format(MSG, name: node.method_name))
end
end
end
end
endIf you want runtime enforcement, palkan/isolator instruments Active Record transactions and raises the moment foreign work happens inside one. It’ll catch HTTP calls, Sidekiq enqueues, mailer deliveries, and it works on the implicit transactions from callbacks too. Run it raising in test, logging on staging, and you’ll find things you didn’t know were there.
At the database layer you can set guardrails that prevent any single transaction from doing too much damage regardless of what the application code does. MySQL’s innodb_lock_wait_timeout controls how long a blocked transaction waits for a lock before giving up. PostgreSQL’s statement_timeout caps query execution time, and idle_in_transaction_session_timeout kills sessions that sit open without issuing statements. The innodb_trx query above returns trx_mysql_thread_id so you can KILL the specific session that’s been holding things up. These won’t fix the root cause, but they prevent one bad transaction from cascading into a full outage while you track it down.
How Other Ecosystems Handle This
I’m hard on Rails because I’ve spent over 15 years using it by now, so naturally I’m going to know about how Rails handles (or fails to) these types of issues. What happens when we take a look at other languages? Do they have similar problems, or have they found ways to prevent these issues? As it turns out it’s a mixed bag.
Java/Spring
Spring in Java forces you to name the nesting behavior. When one @Transactional method calls another, you declare propagation: REQUIRES_NEW gets its own independent transaction, NESTED gets a savepoint, and MANDATORY refuses to run without an existing transaction. The framework won’t guess for you, it can still make HTTP calls inside a transactional method (Spring doesn’t prevent side effects), but at least the composition question has an explicit answer:
@Transactional
public void processOrder(Order order) {
order.setStatus("paid");
orderRepository.save(order);
}
// Side effects in a separate, non-transactional method
Elixir/Ecto
Ecto in Elixir makes the transaction a data structure you build before executing it. The default Multi operations must return Ecto operations, so side effects have to show up as explicit named Multi.run steps. You can still put an HTTP call in there, but it’s visible in the pipeline as a deliberate choice:
Multi.new()
|> Multi.update(:order, Order.changeset(order, %{status: "paid"}))
|> Multi.insert(:ledger, LedgerEntry.changeset(%{order_id: order.id, amount: total}))
|> Repo.transaction()
Rust/Diesel
Diesel passes your transaction block a &mut PgConnection reference, and the block must return a Result<T, E>. The compiler enforces that the failure path exists in the type signature, so you can’t forget to handle it. That said, the block body is arbitrary Rust and you can make HTTP calls, write files, or do anything else inside it with no complaint from the compiler. Diesel solves the rollback-path problem (you can’t forget to handle failure) but does nothing about side effects inside the block, same as Rails:
conn.transaction(|conn| {
diesel::update(orders.find(id))
.set(status.eq("paid"))
.execute(conn)?;
Ok(())
})
.NET/TransactionScope
.NET’s TransactionScope is more implicit still. It provides an ambient transaction that automatically escalates to a distributed two-phase commit the moment a second database connection enlists (Microsoft: Transaction Management Escalation). The using block looks as innocent as Rails’ transaction do, except it will promote your local transaction into a coordinated distributed transaction with all the overhead and failure modes that entails. Rails stops at one connection and leaves the other uncoordinated. TransactionScope coordinates both, but the coordination overhead (MSDTC, network round trips, blocking participants) made it a production liability that .NET teams spent years learning to avoid.
The Takeaway
Every one of these has sharp edges. Spring doesn’t prevent side effects inside @Transactional methods. Ecto’s Multi.run lets you put arbitrary side effects in there. Diesel doesn’t constrain side effects at all. TransactionScope’s implicit coordination was expensive enough that teams actively worked to prevent it from triggering. The spectrum runs from “declare everything explicitly” (Spring propagation, Ecto’s pipeline) to “we’ll handle it implicitly” (TransactionScope). Rails sits in the middle: a flexible block that handles rollback for SQL and leaves everything else up to you.
The universal truth underneath all of them is that unless you know what the database is doing underneath the abstractions you will end up getting surprised by each and every one of them eventually. As software engineers we have an obligation to understand our tools, but we should also have an expectation that most people won’t and design against those cases as much as we can by making the correct choices easy. That’s harder than one might think, but investing early and often in golden paths pays off in the long run.
Quick Reference
For anyone who skips straight to the end, here’s the decision table from the opening example:
| Line of code | Inside transaction? | Why |
|---|---|---|
order.update!(status: "paid") |
✅ Yes | Database write, needs atomicity |
LedgerEntry.create!(...) |
✅ Yes | Database write, must be atomic with order |
PaymentGateway.charge!(order) |
❌ No | HTTP side effect, not rollback-safe |
FulfillmentJob.perform_async(...) |
❌ No | Side effect, use after_commit |
ReceiptMailer.receipt(...).deliver_later |
❌ No | Side effect, use after_commit |
order.lock! |
✅ Yes | Lock only meaningful inside transaction |
AuditLog.create!(...) |
✅ Maybe | If it must be atomic with the write, yes |
Rails.cache.write(...) |
❌ No | Not rollback-safe, use after_commit |
Webhook.notify!(...) |
❌ No | HTTP side effect |
If ROLLBACK can’t undo it, it doesn’t belong inside the block.
Further Reading
- Rails API:
ActiveRecord::Transactions::ClassMethods, the primary documentation, including the nested transaction behavior - Rails API:
DatabaseStatements#transaction, connection-level transaction method with isolation options - Evil Martians: The Silence of the Ruby Exceptions, a production thriller on rescuing errors inside PostgreSQL transactions and why it poisons the entire transaction
- Evil Martians: Rails’ after_commit Everywhere, the gem and rationale for using transactional callbacks outside model classes
- Karol Galanciak: The Inherent Unreliability of after_commit Callbacks, on the gap between commit and side-effect execution that most service objects ignore
- Tobias Pfeiffer: Surprises with Nested Transactions, independent documentation of the fused-nesting surprise
- GitLab: Transaction Guidelines, operational rules from a team running one of the largest Rails-adjacent codebases
- palkan/isolator, runtime detection of non-atomic interactions (HTTP, job enqueues, cache writes) inside database transactions
- Rails 8.2:
enqueue_after_transaction_commit, how Rails 8.2 makes job deferral the default rather than opt-in - Pat Helland: Life Beyond Distributed Transactions, the seminal paper on why distributed transactions are impractical at scale and what to do instead
- Jimmy Bogard: Life Beyond Distributed Transactions (Implementation), practical saga implementation built on Helland’s paper
- Microsoft: Transaction Management Escalation, how .NET’s TransactionScope promotes to distributed 2PC without warning when a second resource enlists
- MySQL: START TRANSACTION, what
BEGIN/COMMIT/ROLLBACKdo at the wire level - PostgreSQL: Transaction Isolation, the four levels and what each prevents
- Ecto.Multi documentation, Elixir’s “transaction as data structure” approach
- microservices.io: Transactional Outbox, pattern reference for the outbox approach described in Failure Six
Wrapping Up
It is my strongly held opinion that while software engineers should know their tools, frameworks, and languages, coverage will inevitably end up wildly inconsistent. The larger the company, the worse this becomes, to where assuming a unified basis of knowledge can actively be dangerous. Our frameworks and tools should reflect this fact by building guardrails, making failures obvious and easy to recover from, and making the correct thing the easy thing. That extends beyond transactions, but it is a frequent theme in Rails for me that the framework succeeds in 0 to 1 moments but begins to fail developers beyond that point as their companies and products grow.
You cannot change how transaction works, but you can change how your codebase uses it. Linters that catch side effects at review time, runtime detection that raises in test, database-level timeouts that kill runaway sessions, and explicit patterns like outbox tables and after_commit hooks that make the ordering visible. Some of that should be the framework’s job, and to be fair most frameworks haven’t gotten it right either. Until they do, it’s on us. The earlier you invest in making the wrong thing hard the less time you spend figuring out why production is on fire.
So much of making large Rails applications workable comes down to making the implicit explicit. Rails’ greatest strength at small scale is that it hides decisions from you, which ironically becomes its greatest weakness at scale. Callbacks are hiding side effects from you, transactions are merging without you knowing, behaviors vary wildly between versions and databases, and every one is implicit knowledge that you either carry in your head or learn the hard way. The value of explicit code is that a stranger can read and understand it without memorizing a framework’s edge cases, and the larger the company the more you’re going to find a lot of folks you don’t know changing code you can never possibly review all of.
Make the correct thing easy and clear, it’s expensive, but it’s worth it.