transaction is one of the few Active Record APIs that reads like a promise. Wrap the work in a block, and either all of it happens, or none of it does.
An account upgrade shows where the reading breaks:
Account.transaction do
account.update!(plan: "growth")
ProvisioningClient.enable_growth_features(account.external_id)
AuditEvent.create!(account:, action: "plan_upgraded")
endA validation added to the AuditEvent a week earlier rejects the new audit record, so create! raises ActiveRecord::RecordInvalid. The SQL log ends the way it should:
BEGIN
UPDATE "accounts" SET "plan" = 'growth' ...
INSERT INTO "audit_events" ...
ROLLBACKThe account row goes back to starter, so the ticket is closed as a failed upgrade the customer can retry.
Two weeks later, billing notices the account has been invoiced for the starter plan while its team has been using growth features the whole time.
The transaction does what it promises. The provisioning service has already accepted the request, and nothing in that SQL log can describe it.
It can roll back database changes performed inside the transaction on that connection. Everything else between do and end is ordinary Ruby that has already run by the time a rollback is issued.
A rollback cannot take back a remote API call, an email, a file write, or a value assigned to a Ruby object. Those effects never passed through the transaction’s connection, so it has no way to reach them. The business operation spans both systems; the database’s atomicity stops at one connection.
Which effects did the connection execute, and which happened somewhere it cannot roll back?
Answering it means working outward from the connection: what decides whether the block commits, what a rollback does to a model object still held in memory, what happens to the work that has already left the process, and why a nested transaction is usually not a separate one.
The block is wider than the transaction
The class method looks model-specific:
Account.transaction do
# ...
endThe class name selects the connection pool. ActiveRecord::Transactions obtains a connection from that pool and delegates the block to its transaction implementation.1 Any model writing through the same connection participates:
Account.transaction do
account.update!(plan: "growth")
AuditEvent.create!(account:, action: "plan_upgraded")
endBoth statements travel on one connection, so both become durable at commit, or both disappear at rollback. The connection carries those statements between BEGIN and COMMIT or ROLLBACK; the models and the lexical block add no protection of their own.
Ruby transaction block
|
+-- UPDATE accounts -- same connection -- protected
+-- INSERT audit_events -- same connection -- protected
+-- HTTP request -- remote system --- not protected
+-- Ruby assignment -- process memory -- not protectedApplications that use multiple databases inherit the same rule. A transaction opened on the primary database does not include a write routed through a separate analytics or billing connection, and Active Record does not offer a distributed transaction across them.2 Two connections mean two independent commits, even when the code that issues them sits in one block.
The block groups application code. The connection decides what the database can undo.
What decides commit or rollback
If the block exits normally, Active Record commits. If an exception leaves the block, Active Record rolls back and re-raises
ActiveRecord::Rollback is the deliberate exception. It causes a rollback without escaping the transaction call:
result = Account.transaction do
account.update!(plan: "growth")
raise ActiveRecord::Rollback
end
result
# => nilThe row is restored, execution continues after the block, and the caller gets nil.
The outcome depends on how the block exists:
An unmatched throw raises UncaughtThrowError, so it follows the exception path and rolls back.
A false value has no rollback meaning for an explicit transaction block:
Account.transaction do
account.update!(plan: "growth")
false
end
account.reload.plan
# => "growth"That looks inconsistent with save returning false inside its own transaction wrapper. The difference comes from save's lifecycle: with_transaction_returning_status converts a false status into an internal ActiveRecord::Rollback. An explicit block has no such convention and follows exception flow only.
Returning early from a transaction block also commits it:
def upgrade(account)
Account.transaction do
account.update!(plan: "growth")
unless ProvisioningClient.enable_growth_features(account.external_id)
return :provisioning_failed
end
AuditEvent.create!(account:, action: "plan_upgraded")
end
endThe early return reads like an abort but commits the plan change, skips the audit event, and reports a failure the database has no record of. Ruby’s return is not an exception, so the adapter’s ensure clause sees no error and commits.3
Rails briefly treated these exits differently. Through Rails 6.0, they committed; Rails 6.1 rolled them back because Timeout.timeout used throw and could silently commit partial work. After timeout 0.4.0 began raising again, Rails 7.1 offered an opt-in and Rails 7.2 restored committing.4
RuboCop’s Rails/TransactionExitStatement reflects the same version split: it flags return, break, and throw for older Rails targets but disables itself on Rails 7.2 and later.
For service code, bang methods keep the intent visible:
Account.transaction do
account.update!(plan: "growth")
AuditEvent.create!(account:, action: "plan_upgraded")
endAn invalid record raises, and the exception reaches the transaction call. With non-bang methods, the caller has to inspect each false return and raise or roll back deliberately; otherwise the block continues and commits whatever did succeed.
One Rails mechanism, traced into production
RailsRevelry follows ordinary Rails APIs into framework source and the failures they create in mature systems.
Subscribe free for one source-backed Rails mental model each week.
Rollback restores the row, but the object keeps the assigned value
Rollback restores the database writes without rewinding the Ruby program.
account = Account.find(42)
# plan: "starter"
Account.transaction do
account.update!(plan: "growth")
raise ActiveRecord::Rollback
endThe row still holds starter. The object in memory tells a different story:
account.plan
# => "growth"
account.plan_changed?
# => true
account.saved_changes
# => {}
account.reload.plan
# => "starter"After rollback, Rails restores the record’s transaction bookkeeping without replacing its current attributes with the row’s older values. growth remains in memory, while dirty tracking once again treats starts as the persisted baseline. That is why plan_changed? is true and the successful-looking saved_changes from inside the block is empty.
restore_transaction_record_state implements that behavior. It reinstates the attribute state captured when the record was registered with the transaction, re-applies a differing current value as a user assignment, and clears the dirty-tracking caches.5 reload produces the final result by querying the row again.
Record identity is treated differently. A record created inside the rolled-back transaction is returned to its unsaved state:
event = AuditEvent.new(account:, action: "plan_upgraded")
AuditEvent.transaction do
event.save!
event.persisted? # => true
event.id # => 8231
raise ActiveRecord::Rollback
end
event.persisted?
# => false
event.id
# => nilRails restores what it recorded about the record’s identity and lifecycle. It leaves the values you assigned alone.
This is the snapshot problem reached from a different direction. After a rollback, there are two facts to inspect, and only one of them is durable:
retained Ruby object
plan: "growth"
database row
plan: "starter"It surfaces most often in rescue code that reuses the instance:
begin
Account.transaction do
account.update!(plan: "growth")
AuditEvent.create!(account:, action: nil)
end
rescue ActiveRecord::RecordInvalid
Rails.logger.info(
account_id: account.id,
plan: account.plan
)
endThe log records growth for a row that is still starter. If the committed value matters, query it or call reload after the transaction has closed. An in-memory is not a commit receipt.
External effects have no rollback path
The provisioning call in the opening example crosses into another system:
ProvisioningClient.enable_growth_features(account.external_id)Once the remote service accepts that request, a later database rollback has no protocol for retracting it. The same holds for several ordinary operations:
deliver_nowcan hand an email to a mail serveran HTTP client can mutate another service
a file can be written to object storage
a cache entry can be deleted or replaced
a message can be published to a broker
another database connection can commit on its own
Placing those calls between do and end changes when they run relative to the SQL. It does not give the database authority over them.
Rescuing and compensating cannot provide the same guarantee as rollback. A second API call could disable the features after the database failure, but it is another fallible distributed operation. The customer may observe the intermediate state, the compensating call may fail, or the original request may time out after the remote service applies it. Compensation aims for eventual reconciliation; it cannot provide atomicity across independently committed systems.
Dependent external work should become eligible only after commit. If an effect does not pass through the transaction’s connection, rollback cannot reverse it.
Nested transactions are usually not nested
Transaction calls appear inside other transaction calls all the time, often because a service object calls a model method that opens its own:
Account.transaction do
account.update!(plan: "growth")
AuditEvent.transaction do
AuditEvent.create!(account:, action: "plan_upgraded")
raise ActiveRecord::Rollback
end
endThe indentation suggests two independently controlled units. By default, the inner call joins the transaction already open on the connection.6 ActiveRecord::Rollback is then swallowed by that inner call, the outer block never sees an exception, and both statements commit:
BEGIN
UPDATE "accounts" ...
INSERT INTO "audit_events" ...
COMMITNothing rolled back. The audit event the code tried to discard is now durable.
When the inner unit needs to be undone on its own, use requires_new: true, which Rails implements with a savepoint on most databases:
Account.transaction do
account.update!(plan: "growth")
AuditEvent.transaction(requires_new: true) do
AuditEvent.create!(account:, action: "plan_upgraded")
raise ActiveRecord::Rollback
end
endBEGIN
UPDATE "accounts" ...
SAVEPOINT active_record_1
INSERT INTO "audit_events" ...
ROLLBACK TO SAVEPOINT active_record_1
COMMITThe insert is discarded, and the account update still commits.
Releasing a savepoint does not durably commit its work. If the outer transaction later rolls back, everything before and after the savepoint goes with it. requires_new gives the inner work a place to roll back to without making it independent of the outer commit. It also cannot reverse an API call made inside the nested block, because savepoints coordinate database state on one connection and nothing else.
Do not rescue a broken transaction and keep going
Some exceptions describe an application refusal: a validation failed, a callback raised, a service decided to abort. Database statement errors need different treatment. On PostgreSQL, many errors represented by ActiveRecord::StatementInvalid or its subclasses leave the current transaction unusable until it rolls back. Rails warns against catching them inside the block and continuing.7
Account.transaction do
AuditEvent.create!(deduplication_key: "upgrade-42")
begin
AuditEvent.create!(deduplication_key: "upgrade-42")
rescue ActiveRecord::RecordNotUnique
# On PostgreSQL, the transaction may now be aborted.
end
account.update!(plan: "growth")
endThe already-aborted transaction can make the final update fail even when the account change is valid. The error surfaces at the last statement, several lines after the database error that left the transaction unusable.
Let the database exception leave the block. Handle or retry the whole unit of work after Active Record has rolled it back. A rescue clause cannot repair the connection’s transaction state.
Tracing a partial effect
When an operation that should have been atomic leaves partial results, start from the connection rather than the indentation. For each effect, ask:
What changed: a row, a Ruby object, a remote service, a cache, or a file?
Which connection executed it, if any?
How did the block exit: normally, on
ActiveRecord::Rollback, on another exception, or on a non-local exit?Did a nested block join the open transaction or use
requires_new: true?Are you reading a retained model instance or querying committed state?
Applied to the opening bug, the SQL log holds one story:
BEGIN
UPDATE "accounts" ...
INSERT INTO "audit_events" ...
ROLLBACKThe application holds another:
provisioning.enable_growth_features account=acct_8fa2 acceptedThe two traces do not contradict each other. They belong to different systems with different commit rules, and reconciling them is the actual debugging work. Log external calls with enough identifying detail to line them up against a rolled-back transaction later.
A successful SQL write can still roll back. Only commit turns it into durable database state.
Have you seen a transaction roll back while an external effect remained? Reply or leave a comment with what escaped the rollback.
The model-level entry point and restore_transaction_record_state both live in ActiveRecord::Transactions.
The Rails 8.1 transaction API documentation covers connection scope, object state after rollback, exception handling, nested transactions, savepoints, and multi-database limits.
ConnectionAdapters::TransactionManager#within_new_transaction rolls back in its rescue Exception clause and commits from ensure when no exception was raised.
The reasoning behind the 6.1 change and the 7.1 opt-in is recorded in the Rails 7.1 Active Record CHANGELOG entry for commit_transaction_on_non_local_return.
The model-level entry point and restore_transaction_record_state both live in ActiveRecord::Transactions.
Joining an open transaction, swallowing ActiveRecord::Rollback, and creating a savepoint-backed requires_new transaction live in ActiveRecord::ConnectionAdapters::DatabaseStatements.
The Rails 8.1 transaction API documentation covers connection scope, object state after rollback, exception handling, nested transactions, savepoints, and multi-database limits.


