Database transactions are one of those Rails features that appear simple:
User.transaction do
# database operations
end
But at a senior Rails engineering level, transactions are much more than wrapping a few save! calls in a block.
They affect data consistency, concurrency, failure handling, callbacks, database locking, nested service objects, multiple databases and even how external systems should be triggered.
A strong Rails developer should understand not only how to start a transaction, but also:
- which transaction API to use
- how class-level and instance-level transactions differ
- what actually gets rolled back
- how nested transactions work
- when
requires_newis necessary - how savepoints work
- how
saveanddestroyalready use transactions internally - how
after_commitdiffers fromafter_save - how to handle database exceptions safely
- how transactions interact with locks and isolation levels
- what Rails transactions cannot protect
- how to design transaction boundaries in service objects
This article approaches transactions from that perspective.
What Is a Database Transaction?
A transaction groups multiple database operations into a single unit of work.
Conceptually:
BEGIN
operation 1
operation 2
operation 3
COMMIT
If something fails:
BEGIN
operation 1
operation 2
operation 3 <-- failure
ROLLBACK
The important property is atomicity:
Either all database changes become permanent, or none of them do.
Rails uses the database transaction facilities provided by the underlying database connection. Active Record describes transactions as protective blocks where SQL statements become permanent only when the complete operation succeeds. (Ruby on Rails API)
A classic example is transferring money:
Account.transaction do
sender.withdraw!(100)
receiver.deposit!(100)
end
We don’t want this situation:
sender: -100receiver: 0
If the deposit fails, the withdrawal must also disappear.
The Basic Rails Transaction Block
The most common syntax is:
ActiveRecord::Base.transaction do
user.save!
profile.save!
audit_log.save!
end
If any operation raises an exception, Rails rolls back the transaction. (Ruby on Rails API)
In a modern Rails application, however, I generally prefer:
ApplicationRecord.transaction do
user.save!
profile.save!
audit_log.save!
end
Why?
ApplicationRecord represents the application’s Active Record hierarchy and is generally a clearer boundary than directly referencing ActiveRecord::Base.
Why Do We Need Transactions?
Imagine an order creation workflow:
order = Order.create!
payment = Payment.create!
order.update!(status: "paid")
InventoryItem.create!(...)
Without a transaction, failure halfway through could leave:
Order ✅Payment ✅Order paid ✅Inventory ❌
The application is now inconsistent.
Instead:
ApplicationRecord.transaction do
order = Order.create!
payment = Payment.create!
order.update!(status: "paid")
InventoryItem.create!
end
Now the desired guarantee is:
Everything succeeds -> COMMITAnything fails -> ROLLBACK
Class-Level Transaction Methods
The class-level form is the most common Rails API.
User.transaction do
user = User.create!
profile = Profile.create!(user: user)
end
You can also use:
ApplicationRecord.transaction do
user.update!
profile.update!
end
An important senior-level detail is that the class calling .transaction doesn’t restrict which models can participate.
For example:
Account.transaction do
account.update!(balance: 900)
TransactionLog.create!(
account: account,
amount: 100
)
end
TransactionLog participates in the same transaction.
Why?
Because a Rails transaction is fundamentally associated with a database connection, not with a particular model class. (Ruby on Rails API)
This is an important distinction:
Transaction ↓Database connection ↓SQL statements ↓Multiple ActiveRecord models
Not:
Transaction ↓One ActiveRecord model only
Instance-Level Transactions
Rails also supports transactions on model instances.
For example:
user.transaction do user.update!(name: "Abhilash") Profile.create!(user: user)end
This might look like a different mechanism from:
User.transaction do user.update!(name: "Abhilash") Profile.create!(user: user)end
For normal Active Record usage, they operate against the same underlying database connection.
Rails explicitly provides transaction as both a class-level and model-instance API. (Ruby on Rails API)
When is instance-level useful?
It can communicate intent:
order.transaction do order.update!(status: "processing") order.create_payment!end
This reads naturally as:
Perform these operations as one transaction around this order.
However, I would usually use the class-level transaction in service objects, because the service boundary is about a unit of work rather than a specific model.
Service Object Transaction Boundary
This is usually my preferred architecture for complex workflows.
class CheckoutService
def call(user:, cart:)
Order.transaction do
order = create_order(user, cart)
charge_payment(order)
reserve_inventory(order)
order.update!(status: "confirmed")
order
end
end
private
def create_order(user, cart)
Order.create!(
user: user,
total: cart.total
)
end
def charge_payment(order)
Payment.create!(
order: order,
amount: order.total
)
end
def reserve_inventory(order)
# ...
end
end
This gives the workflow one explicit transaction boundary.
That is much easier to reason about than having every individual method start its own transaction.
save and destroy Are Already Transactional
This is an area that surprises many developers.
Rails automatically wraps save and destroy operations in transactions. This ensures validations and callbacks execute under transactional protection. (Ruby on Rails API)
For example:
user.save!
is already transaction-protected at the database-operation level.
But that doesn’t mean you don’t need explicit transactions.
Consider:
user.save!profile.save!
Each operation is protected individually.
That does not give you:
user + profile = atomic unit
You need:
User.transaction do user.save! profile.save!end
So the distinction is:
save! ↓protect this persistence operationtransaction do ↓protect this entire workflow
Transaction and Exceptions
The normal rollback mechanism is an exception.
User.transaction do user.save! profile.save! raise "Something failed"end
The transaction rolls back.
Rails then propagates the exception to the caller.
That means this pattern is common:
begin User.transaction do create_user! create_profile! endrescue StandardError => e Rails.logger.error(e.message) raiseend
The important design principle is:
Don’t use transactions as a replacement for error handling.
A transaction determines what happens to database state.
Your application code still needs to determine what happens to the failure.
ActiveRecord::Rollback
Rails provides a special exception:
ActiveRecord::Rollback
Example:
User.transaction do user.update!(status: "processing") unless payment_valid? raise ActiveRecord::Rollback end user.update!(status: "confirmed")end
The transaction rolls back, but ActiveRecord::Rollback is specifically handled by Rails and isn’t propagated like a normal exception. (Ruby on Rails API)
This makes it useful when you intentionally want:
rollback database changes+don't treat this as an application exception
For example:
Order.transaction do order.update!(status: "processing") raise ActiveRecord::Rollback unless inventory_available?end
raise vs ActiveRecord::Rollback
Compare:
raise PaymentError
with:
raise ActiveRecord::Rollback
The semantic difference is significant.
Normal exception
raise PaymentError
Result:
ROLLBACK↓exception propagates↓caller can rescue it
ActiveRecord::Rollback
raise ActiveRecord::Rollback
Result:
ROLLBACK↓rollback is internally handled↓execution exits transaction
Therefore, don’t blindly replace business exceptions with ActiveRecord::Rollback.
Nested Transactions
Consider:
ApplicationRecord.transaction do user.save! ApplicationRecord.transaction do profile.save! endend
You might assume there are two independent transactions:
BEGIN user BEGIN profile COMMITCOMMIT
That’s not generally how Rails works.
By default, a nested transaction joins the existing transaction. Most databases don’t provide true nested transactions, so Rails emulates subtransactions with savepoints where necessary. (Ruby on Rails API)
Conceptually:
BEGIN user profileCOMMIT
The Nested Rollback Surprise
Consider this:
User.transaction do User.create!(name: "A") User.transaction do User.create!(name: "B") raise ActiveRecord::Rollback endend
Many developers expect:
A -> committedB -> rolled back
But because the nested transaction joins the parent transaction, the rollback exception is handled by the inner transaction boundary and the outer transaction can still commit.
The result can be:
A -> committedB -> committed
This behavior is documented by Rails and is one of the most important transaction gotchas to understand. (Ruby on Rails API)
requires_new: true
When you need an actual nested transactional boundary, use:
requires_new: true
Example:
ApplicationRecord.transaction do user = User.create! ApplicationRecord.transaction(requires_new: true) do AuditLog.create! raise ActiveRecord::Rollback endend
Now the inner transaction gets its own savepoint.
Conceptually:
BEGINUserSAVEPOINTAuditLogROLLBACK TO SAVEPOINTCOMMIT
Result:
User ✅AuditLog ❌
Rails uses database save points to emulate nested transactions on databases that do not support true nested transactions.
When Should You Use requires_new?
It is especially useful when you have an inner operation that should be isolated from the outer workflow.
For example:
Order.transaction do create_order! Order.transaction(requires_new: true) do create_optional_audit_record! end finalize_order!end
The inner operation can fail without necessarily destroying the outer work.
This is particularly useful in reusable service objects.
Suppose:
class AuditService def self.record!(event) AuditLog.transaction(requires_new: true) do AuditLog.create!(event: event) end endend
That service can be called either:
AuditService.record!("user_created")
or inside another transaction:
User.transaction do user.save! AuditService.record!("user_created")end
The requires_new boundary gives the service an explicit savepoint when called inside an existing transaction.
Transaction Isolation Levels
Transactions don’t only provide atomicity.
They also influence how concurrent transactions can see and modify data.
Rails allows:
Account.transaction(isolation: :serializable) do # critical operationend
Supported isolation levels include:
:read_uncommitted:read_committed:repeatable_read:serializable
Support and semantics depend on the database adapter. Rails documents these options and notes that isolation cannot generally be changed while joining an existing transaction or creating a nested savepoint transaction.
For example:
Order.transaction(isolation: :serializable) do order = Order.find(order_id) order.update!( status: "confirmed" )end
This can be appropriate for highly concurrent business operations, but it isn’t something I would enable casually.
Higher isolation can increase contention and retry requirements.
A senior engineer should ask:
What consistency guarantee does this business operation actually require?
rather than:
Which isolation level sounds safest?
Transactions + Row Locking
Transactions become particularly powerful when combined with pessimistic locking.
Suppose two requests attempt to update the same account simultaneously.
account.with_lock do account.update!( balance: account.balance - 100 )end
with_lock is a useful Rails shortcut: it starts a transaction, reloads the record using a row lock, and then executes the block. Rails also allows transaction options such as requires_new, isolation, and joinable to be passed to with_lock. (Ruby on Rails API)
Conceptually:
BEGINSELECT ... FOR UPDATEmodify rowCOMMIT
This is useful for operations such as:
account.with_lock do raise InsufficientFunds unless account.balance >= amount account.update!( balance: account.balance - amount )end
The important point is that transaction + locking solves a different class of problem from transaction alone.
A transaction provides atomicity.
A lock helps control concurrent access.
with_lock vs transaction
Compare:
account.transaction do account.update!(balance: ...)end
with:
account.with_lock do account.update!(balance: ...)end
The first provides transactional atomicity.
The second provides:
transaction+record reload+row-level locking
Use with_lock when concurrency around a particular row is part of the problem.
after_save vs after_commit
This distinction becomes extremely important when transactions are involved.
Consider:
class Order < ApplicationRecord after_save :publish_order def publish_order EventBus.publish(id) endend
Suppose:
Order.transaction do order.update!(status: "paid") raise "Something failed"end
The after_save callback can run while the transaction is still in progress.
The database transaction can subsequently roll back.
Now your external system might have received:
Order paid
while your database says:
Order unpaid
That’s dangerous.
Use after_commit for External Side Effects
Rails provides:
class Order < ApplicationRecord after_commit :publish_order private def publish_order EventBus.publish(id) endend
Now the external operation happens only after the database transaction has successfully committed.
Rails explicitly recommends transaction callbacks such as after_commit when interacting with systems outside the database transaction. (Ruby on Rails Guides)
For narrower cases:
after_create_commit :publish_orderafter_update_commit :publish_orderafter_destroy_commit :remove_from_search
These are convenient aliases provided by Rails.
Per-Transaction Callbacks
Modern Rails also allows callbacks to be registered directly against a transaction.
For example:
Order.transaction do |transaction| order.update!(status: "confirmed") transaction.after_commit do NotificationService.notify_order_confirmed(order) endend
This is interesting because the callback is associated with the unit of work, rather than with the model lifecycle.
Rails supports transaction-level callbacks such as:
transaction.before_committransaction.after_committransaction.after_rollback
This can be cleaner for domain/service-oriented workflows where you don’t want the model itself to know about notification behavior.
ActiveRecord.after_all_transactions_commit
Another useful modern Rails API is:
ActiveRecord.after_all_transactions_commit do NotificationService.notify(...)end
This is useful when code may be invoked from either inside or outside a transaction.
Rails guarantees that the callback runs after all currently open transactions have successfully committed. If any transaction rolls back, the callback isn’t executed.
This can be particularly useful in reusable application services.
Article.current_transaction
Modern Rails exposes transaction state through:
Article.current_transaction
You can register an operation:
Article.current_transaction.after_commit do SearchIndexer.index(article)end
This makes a service transaction-aware without requiring it to know whether its caller has opened a transaction.
Rails documents this API as a representation of the current transaction, savepoint, or lack of an active transaction. (Ruby on Rails API)
This is particularly interesting for reusable service objects.
For example:
class PublishArticle def self.call(article) article.update!(published: true) Article.current_transaction.after_commit do SearchIndexer.index(article) end endend
Now:
PublishArticle.call(article)
works both:
outside transaction
and:
Article.transaction do PublishArticle.call(article)end
The external action can correctly follow the transaction boundary.
Don’t Rescue StatementInvalid Inside a Transaction
This is one of the most important PostgreSQL-specific transaction rules.
Bad:
User.transaction do begin User.create!(email: "existing@example.com") rescue ActiveRecord::StatementInvalid # ignore end User.create!(email: "new@example.com")end
A database error such as a unique constraint violation can leave the PostgreSQL transaction in an aborted state.
After that, subsequent SQL statements can fail with an error similar to:
current transaction is aborted,commands ignored until end of transaction block
Rails explicitly recommends restarting the entire transaction after ActiveRecord::StatementInvalid, rather than continuing within the damaged transaction.
Better:
begin User.transaction do create_user! create_profile! endrescue ActiveRecord::RecordNotUnique # retry or handle outside the transactionend
The key idea is:
Database failure ↓Transaction may be unusable ↓Exit transaction ↓Handle/retry outside it
This is especially important when building retry logic for concurrency errors.
Transactions Are Not Distributed Transactions
A Rails transaction normally operates on one database connection.
Therefore:
User.transaction do user.save! AuditLog.create!end
works when those models participate in the same database connection.
But imagine:
Primary DB UserAnalytics DB AnalyticsEvent
A transaction on the primary database cannot automatically roll back a transaction on another database connection.
Rails explicitly documents that transactions are not distributed across database connections.
This becomes especially important with Rails multiple-database applications.
Multiple Databases: Don’t Assume One Transaction
Imagine:
User.transaction do user.update! AnalyticsEvent.transaction do analytics_event.save! endend
These are potentially separate database transactions.
You don’t suddenly have:
BEGIN DB1BEGIN DB2COMMIT DB1COMMIT DB2
with a globally atomic guarantee.
Instead, you have two independent database resources.
This is where architectural patterns such as:
- transactional outbox
- event-driven processing
- retries
- idempotency
- compensating actions
become more appropriate than trying to force a distributed transaction.
Transactions and Background Jobs
Consider:
Order.transaction do order.update!(status: "confirmed") OrderConfirmationJob.perform_later(order.id)end
This can be dangerous.
Depending on timing, the job could execute before the surrounding transaction has committed.
Then the worker might query:
Order.find(order_id)
and not observe the expected committed state.
Instead:
Order.transaction do order.update!(status: "confirmed") order.after_commit do OrderConfirmationJob.perform_later(order.id) endend
Or use the appropriate transactional callback mechanisms.
The principle is:
Don’t allow asynchronous consumers to depend on database state that hasn’t committed yet.
Rails’ transaction callbacks are specifically designed for such post-commit work.
Keep Transactions Small
A transaction should generally cover the minimum amount of work necessary.
Avoid:
Order.transaction do order.update! HTTP.get(payment_api) HTTP.get(shipping_api) expensive_calculation sleep(5) order.update!end
Now the database transaction stays open while waiting on external systems.
That can mean:
transaction open ↓database connection occupied ↓locks potentially held ↓other requests wait ↓throughput decreases
A better architecture is often:
external preparation ↓short DB transaction ↓commit ↓after_commit / job ↓external side effect
Transaction Boundary vs Business Operation
A useful senior-level rule is:
A transaction boundary should normally correspond to a business operation that must be atomic.
For example:
Order.transaction do create_order! reserve_inventory! record_payment!end
That’s a meaningful transaction.
But this:
User.transaction do user.update!end
may be unnecessary if you’re only performing one persistence operation.
Remember:
user.update!
already has transactional protection around the persistence operation.
Testing Transaction Behavior
Transactions are especially valuable to test explicitly.
Example:
it "rolls back the order when payment fails" do expect { CheckoutService.call(user, cart) }.to raise_error(PaymentError) expect(Order.count).to eq(0)end
Test the business guarantee, not the implementation detail.
Good transaction tests answer questions such as:
Does failed payment rollback the order?Does failed inventory reservation rollback the payment?Does an after_commit job run only after successful commit?Does a nested requires_new operation rollback independently?
A Practical Senior-Level Example
Let’s build a realistic checkout flow.
class CheckoutService
def call(user:, cart:)
order = nil
Order.transaction do
order = Order.create!(
user: user,
total: cart.total,
status: "pending"
)
reserve_inventory!(cart)
Payment.create!(
order: order,
amount: cart.total,
status: "paid"
)
order.update!(status: "confirmed")
ActiveRecord::after_all_transactions_commit do
OrderConfirmationJob.perform_later(order.id)
end
end
order
end
private
def reserve_inventory!(cart)
cart.items.each do |item|
item.product.with_lock do
raise OutOfStock if item.product.stock < item.quantity
item.product.update!(
stock: item.product.stock - item.quantity
)
end
end
end
end
There are several senior-level ideas here.
Atomicity
Order.transaction
ensures the order, payment and inventory changes form one unit.
Concurrency control
with_lock
protects inventory from concurrent updates.
Post-commit processing
ActiveRecord.after_all_transactions_commit
prevents the job from being dispatched before the transaction chain is complete.
This is much closer to production-grade transaction design than simply knowing:
Model.transaction doend
Transaction APIs at a Glance
| API | Main purpose | Typical usage |
|---|---|---|
Model.transaction | Transaction around a unit of work | Service objects |
instance.transaction | Transaction associated with a model instance | Model-centric workflows |
ApplicationRecord.transaction | Application-wide transaction boundary | Shared models |
transaction(requires_new: true) | Independent nested savepoint | Isolating sub-operations |
transaction(isolation: :serializable) | Stronger concurrency guarantees | Highly concurrent workflows |
with_lock | Transaction + row lock | Balance/inventory updates |
after_commit | Run code after commit | External side effects |
after_rollback | React to rollback | Cleanup/recovery logic |
transaction.after_commit | Callback attached to a specific transaction | Service/domain workflows |
ActiveRecord.after_all_transactions_commit | Run after outermost transaction chain commits | Transaction-aware reusable services |
current_transaction.after_commit | Make services transaction-aware | Reusable domain services |
Rails provides all of these around the same fundamental transaction system.
How I Decide Which API to Use
As a practical decision tree:
One database operation
Usually:
user.update!
No explicit transaction required.
Several operations must succeed together
Use:
User.transaction do ...end
Reusable service may be called inside another transaction
Consider:
transaction(requires_new: true)
when independent rollback semantics are actually required.
Concurrent modification of one row
Use:
record.with_lock do ...end
External system must run only after DB success
Use:
after_commit
or a transaction-aware post-commit mechanism.
Multiple database connections
Don’t assume a single transaction protects everything.
Consider:
outboxeventsidempotencyretriescompensating actions
instead.
Common Transaction Mistakes
1. Putting HTTP calls inside transactions
Order.transaction do order.save! PaymentGateway.charge!end
Avoid long-running external calls inside database transactions.
2. Assuming nested transactions are independent
transaction do transaction do endend
The inner block normally participates in the outer transaction.
Use:
transaction(requires_new: true)
when you specifically need savepoint-based isolation.
3. Publishing events from after_save
Bad:
after_save :publish_event
for external systems that require committed data.
Prefer:
after_commit :publish_event
4. Rescuing database errors inside the transaction
Bad:
transaction do begin risky_database_operation rescue ActiveRecord::StatementInvalid end another_database_operationend
For PostgreSQL in particular, leave the failed transaction and retry/recover at a higher level.
5. Assuming transactions protect in-memory Ruby objects
Suppose:
user = User.find(1)User.transaction do user.update!(name: "New Name") raise ActiveRecord::Rollbackend
The database row is rolled back.
But don’t assume your Ruby object has magically reverted every piece of in-memory state to its pre-transaction state. Rails explicitly notes that database rollback doesn’t restore Active Record objects to their original in-memory state.
Rails Takeaways
The important lesson isn’t:
“Use
.transactionwhen you have multiple saves.”
The deeper mental model is:
Transaction
│
┌───────────┴───────────┐
│ │
Atomicity Concurrency
│ │
commit/rollback locks/isolation
│ │
└───────────┬───────────┘
│
Application
boundary
│
┌───────────┴────────────┐
│ │
DB operations external systems
│ │
transaction after_commit
A senior Rails developer should decide transaction boundaries deliberately.
The questions I would ask during a code review are:
What exactly must be atomic?Which database connection is involved?Can this service be called inside another transaction?Does this nested transaction really need requires_new?Are we holding locks longer than necessary?Could a database constraint failure leave the transaction unusable?Are we calling an external service before commit?Can a background job observe uncommitted data?Are multiple databases involved?Do we need a lock, or is a transaction alone sufficient?What happens when this operation is executed concurrently?
That is where transaction knowledge moves from Rails syntax to system design.
Final Mental Model
Think about Rails transactions in five layers:
1. transaction ↓ Atomic unit of database work2. rollback ↓ Undo database changes when work fails3. requires_new / savepoints ↓ Isolate nested database work4. locks / isolation ↓ Control concurrent behavior5. after_commit ↓ Safely interact with the world outside the DB
Mastering these five concepts gives you most of what you need to design transaction-safe Rails applications.
And the biggest senior-level principle is simple:
Transactions should protect business invariants, not merely surround database code.
That distinction is what separates knowing the Rails transaction API from designing reliable transactional systems.
References
- Rails Active Record Transactions API and transaction semantics. (Ruby on Rails API)
- Rails transaction callbacks,
after_commit,after_rollback, per-transaction callbacks, andafter_all_transactions_commit. (Ruby on Rails Guides) - Rails
ActiveRecord::TransactionAPI, including transaction-aware callbacks andcurrent_transaction. (Ruby on Rails API) - Rails pessimistic locking and
with_lock. (Ruby on Rails API)
Happy Implementing!