If you have been developing Rails applications for years, there’s a good chance you’ve used:
bin/rails credentials:edit
hundreds of times.
You probably know that Rails stores encrypted credentials in:
config/credentials.yml.enc
and keeps the encryption key separately in:
config/master.key
But did you know that Rails can make:
git diff
show the decrypted, human-readable changes to credentials.yml.enc?
I recently discovered this while working on a Rails 8.1.3.1 application and it was one of those:
“I’ve been using Rails every day for years, and I didn’t know Rails could do this!”
moments.
Let’s see how it works.
First: What is credentials.yml.enc?
Rails encrypted credentials allow us to keep secrets such as:
openai:
api_key: ...
or:
aws:
access_key_id: ...
secret_access_key: ...
inside:
config/credentials.yml.enc
The file is encrypted.
The encryption key is stored separately in:
config/master.key
Rails documentation explicitly states that the encrypted credentials file can be stored in version control as long as the master key remains secure. (Ruby on Rails Guides)
So our repository can contain:
config/
├── credentials.yml.enc ← encrypted, safe to commit
└── master.key ← secret, NEVER commit
Editing Rails Credentials
Normally we edit credentials with:
bin/rails credentials:edit
Rails decrypts the credentials, opens them in your configured editor, and encrypts them again when you save.
Rails then ensures the Git diff driver is configured to use:
bin/rails credentials:diff
Rails’ application generator includes this credentials diff enrollment as part of application setup and Rails 7.0 already contained the credentials diffing implementation. (Gem)
So this isn’t actually an 8.1-only feature.
That’s an important distinction.
Is This New in Rails 8.1?
No – and this is an important correction.
The encrypted credentials diff functionality existed before Rails 8.1.
For example, Rails 7.0 already had the credentials:diff implementation, and Rails 7.2’s application generator also enrolled projects in credentials diffing. (Gem)
Rails has supported decrypted Git diffs for encrypted credentials for several versions and Rails 8.x continues to build on the credentials tooling.
Rails 8.1 does introduce other useful credentials functionality. For example, Rails 8.1 added command-line credential fetching, which can be useful for deployment tooling such as Kamal. (Ruby on Rails Guides)
The master key should not be committed. Rails’ security guide explicitly recommends keeping the master key safe and out of version control. (Ruby on Rails Guides)
One Thing to Remember
The decrypted content can appear in your local terminal output.
The difference is a great way to understand what’s really happening.
Quick Reference
# Edit credentials
bin/rails credentials:edit
# Enroll project in credential diffing
bin/rails credentials:diff --enroll
# Normal readable diff
git diff
# Show the actual encrypted file diff
git diff --no-textconv -- config/credentials.yml.enc
# Inspect Git's configuration
git config --show-origin --get-regexp 'diff|textconv|filter'
# Check Git attributes
git check-attr diff -- config/credentials.yml.enc
Security rule:
Y config/credentials.yml.enc → commit it
X config/master.key → NEVER commit it
Rails’ official security guide confirms that encrypted credentials can be stored in version control while the master key must remain protected. (Ruby on Rails Guides)
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_new is necessary
how savepoints work
how save and destroy already use transactions internally
how after_commit differs from after_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: -100
receiver: 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 -> COMMIT
Anything 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.transactiondo
user.update!(name:"Abhilash")
Profile.create!(user:user)
end
This might look like a different mechanism from:
User.transactiondo
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.transactiondo
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.transactiondo
user.save!
profile.save!
end
So the distinction is:
save!
↓
protect this persistence operation
transaction do
↓
protect this entire workflow
Transaction and Exceptions
The normal rollback mechanism is an exception.
User.transactiondo
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.transactiondo
create_user!
create_profile!
end
rescueStandardError=>e
Rails.logger.error(e.message)
raise
end
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.transactiondo
user.update!(status:"processing")
unlesspayment_valid?
raiseActiveRecord::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)
Therefore, don’t blindly replace business exceptions with ActiveRecord::Rollback.
Nested Transactions
Consider:
ApplicationRecord.transactiondo
user.save!
ApplicationRecord.transactiondo
profile.save!
end
end
You might assume there are two independent transactions:
BEGIN
user
BEGIN
profile
COMMIT
COMMIT
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
profile
COMMIT
The Nested Rollback Surprise
Consider this:
User.transactiondo
User.create!(name:"A")
User.transactiondo
User.create!(name:"B")
raiseActiveRecord::Rollback
end
end
Many developers expect:
A -> committed
B -> 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 -> committed
B -> 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.transactiondo
user=User.create!
ApplicationRecord.transaction(requires_new:true) do
AuditLog.create!
raiseActiveRecord::Rollback
end
end
Now the inner transaction gets its own savepoint.
Conceptually:
BEGIN
User
SAVEPOINT
AuditLog
ROLLBACK TO SAVEPOINT
COMMIT
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.transactiondo
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:
classAuditService
defself.record!(event)
AuditLog.transaction(requires_new:true) do
AuditLog.create!(event:event)
end
end
end
That service can be called either:
AuditService.record!("user_created")
or inside another transaction:
User.transactiondo
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 operation
end
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_lockdo
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)
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.transactiondo
account.update!(balance:...)
end
with:
account.with_lockdo
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:
classOrder<ApplicationRecord
after_save:publish_order
defpublish_order
EventBus.publish(id)
end
end
Suppose:
Order.transactiondo
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:
classOrder<ApplicationRecord
after_commit:publish_order
private
defpublish_order
EventBus.publish(id)
end
end
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_order
after_update_commit:publish_order
after_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.transactiondo |transaction|
order.update!(status:"confirmed")
transaction.after_commitdo
NotificationService.notify_order_confirmed(order)
end
end
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_commit
transaction.after_commit
transaction.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_commitdo
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_commitdo
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:
classPublishArticle
defself.call(article)
article.update!(published:true)
Article.current_transaction.after_commitdo
SearchIndexer.index(article)
end
end
end
Now:
PublishArticle.call(article)
works both:
outside transaction
and:
Article.transactiondo
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.transactiondo
begin
User.create!(email:"existing@example.com")
rescueActiveRecord::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.transactiondo
create_user!
create_profile!
end
rescueActiveRecord::RecordNotUnique
# retry or handle outside the transaction
end
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.transactiondo
user.save!
AuditLog.create!
end
works when those models participate in the same database connection.
But imagine:
Primary DB
User
Analytics 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.transactiondo
user.update!
AnalyticsEvent.transactiondo
analytics_event.save!
end
end
These are potentially separate database transactions.
You don’t suddenly have:
BEGIN DB1
BEGIN DB2
COMMIT DB1
COMMIT 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.transactiondo
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.transactiondo
order.update!(status:"confirmed")
order.after_commitdo
OrderConfirmationJob.perform_later(order.id)
end
end
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.transactiondo
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.transactiondo
create_order!
reserve_inventory!
record_payment!
end
That’s a meaningful transaction.
But this:
User.transactiondo
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)
}.toraise_error(PaymentError)
expect(Order.count).toeq(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.transactiondo
end
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.transactiondo
...
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_lockdo
...
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.
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.