What Happens When You Call save
Why a failed save can leave errors empty and let surrounding database work commit.
An account upgrade starts with an ordinary assignment:
account = Account.find(42)
account.plan = "growth"
account.changes_to_save
# => {
# "plan" => ["starter", "growth"]
# }The object has the right value. Dirty tracking has the right transition, but the save fails:
account.save
# => false
account.errors.full_messages
# => []The row remains on the starter plan. The SQL log contains no UPDATE.
That missing UPDATE is useful evidence: save stopped before the adapter attempted the row write. It does not yet tell us what stopped it.
The cause lives in a concern included by Account:
module BillingMigrationGuard
extend ActiveSupport::Concern
included do
before_update :prevent_plan_change_during_migration
end
private
def prevent_plan_change_during_migration
# An Account column maintained by the background billing migration.
throw :abort if billing_migration_state == "running"
end
endThe callback stopped the update after validation but before Active Record reached SQL. It added no error, so the caller received false without an explanation.
In a mature model, finding that callback may be harder than this example suggests. Start by inspecting the update chain:
Account._update_callbacks.map do |callback|
[callback.kind, callback.filter]
endThe array may contain named methods, callback objects, and anonymous Proc instances installed by concerns, associations, or gems.
Callback inspection locates the guard. We still need to explain why save returned false, why errors stayed empty, and what else can commit after the update is refused.
save is assembled across Active Record
We already saw how an assignment becomes Active Record dirty-tracking state. Dirty tracking can describe the pending update:
account.plan_change_to_be_saved
# => ["starter", "growth"]Persistence still has to accept and execute it.
Rails builds save by layering several modules around the same operation. ActiveRecord::Transactions supplies the outer wrapper:1
def save(**)
with_transaction_returning_status { super }
endActiveRecord::Validations runs validation before delegating further:2
def save(**options)
perform_validations(options) ? super : false
endActiveRecord::Callbacks wraps persistence in the save callbacks:3
def create_or_update(**)
_run_save_callbacks { super }
endFinally, ActiveRecord::Persistence#create_or_update selects _create_record or _update_record from new_record?.4 A new record takes the create callbacks and INSERT branch, with corresponding validation and callback cancellation points.
The method calls nest in this order because each module delegates inward with super. The transaction wrapper is entered before validation runs, so even a uniqueness validator’s SELECT runs inside the transaction that may later contain the INSERT or UPDATE. The SQL write appears near the inside of the operation, after validation and the relevant before callbacks have completed.
For an account update, the callback order around that write is:
before_savebefore_updatetimestamps handling and
_update_recordafter_updateafter_save
Around callbacks wrap their corresponding save or update work.
Where save can stop
A validation failure and a callback abort both make non-bang save return false, but Rails reached different stages.
account.billing_email = nil
account.save
# => false
account.errors.full_messages
# => ["Billing email can't be blank"]Validation populated errors and prevented the callback and persistence path from continuing. save! reports that failure with ActiveRecord::RecordInvalid.
The billing migration guard runs later. Validation passes, the update callbacks begin, and throw :abort halts the chain. save! reports with ActiveRecord::RecordNotSaved.
save returning false tells you that the lifecycle refused the write. It does not tell you which stage refused it.
The non-bang method is not an exception-free form of persistence. A raised callback exception, ActiveRecord::RecordNotUnique, ActiveRecord::StaleObjectError, a connection failure, or a readonly record still raises from save.
If callback inspection returns an anonymous block, ask Ruby where it was defined:
callback.filter.source_location if callback.filter.respond_to?(:source_location)validate: false also has a narrower effect than its name can suggest:
account.save(validate: false)It skips validation. The transaction wrapper, save callbacks, update callbacks, timestamps, and persistence still run. The billing migration guard can still abort this call.
That makes validate: false a poor substitute for a direct-write API. It bypasses one stage of save, not the model lifecycle.
One Rails boundary, explained each week
RailsRevelry follows ordinary Rails APIs into the framework source and back to the production behaviour they create.
If this helped you explain a save that returned false, subscribe free for next week’s article on what Active Record transactions actually protect.
A callback can change the proposed write
Before callbacks can assign new values before Rails builds the statement:
before_update do
self.billing_email = billing_email.strip.downcase
endBefore save, changes_to_save may contain the address with its original whitespace. After this callback assigns the normalized address, dirty tracking and the eventual UPDATE contain the normalized value.
After a successful write, Rails clears the pending change and records the persisted transition in saved_changes. That history describes what reached the database, including changes made by before callbacks.
The billing migration guard has a larger effect: it vetoes the entire update.
Adding an error makes that veto visible:
def prevent_plan_change_during_migration
return unless billing_migration_state == "running"
errors.add(:plan, "cannot change during billing migration")
throw :abort
endIt does not make the design explicit. A user-initiated plan change is a business operation with an expected refusal case. Hiding that decision in before_update means every caller has to discover the callback contract through false, errors, or source inspection.
An explicit upgrade operation can return a domain result before assigning the plan.
class UpgradeAccountPlan
Result = Data.define(:status, :reason)
def self.call(account, to:)
if account.billing_migration_state == "running"
return Result.new(
status: :refused,
reason: :billing_migration_running
)
end
account.update!(plan: to)
Result.new(status: :upgraded, reason: nil)
end
endNow the expected business refusal has a name:
result = UpgradeAccountPlan.call(account, to: "growth")
result.status
# => :refused
result.reason
# => :billing_migration_runningThe callback can remain as protection against another persistence path bypassing the operation. But ordinary callers no longer have to infer a business decision from false and an empty errors collection.
The migration guard is an expected refusal; other persistence failures still raise from update!.
true does not require an UPDATE
An unchanged object can complete the save lifecycle:
account = Account.find(42)
account.has_changes_to_save?
# => false
account.save
# => trueWhy is there no UPDATE?
Rails enables partial updates by default, which means an UPDATE contains only attributes that changed. Here, dirty tracking has no changed attributes, and no callback adds one, so Rails has no columns to write.
Account.partial_update? # => true
The save lifecycle still completes, and its callbacks still run. An after_save callback therefore does not prove that the adapter executed a write. Code that publishes an event from after_save on the assumption that a row changed can publish work for a no-op save. If the event belongs to a plan change, guard it with saved_change_to_plan?; saved_changes? is broader and matches any persisted attribute change.
Moving external publication to after_commit protects it from a later transaction rollback. It solves a different problem and does not replace the attribute-change guard.
A failed save can leave the outer transaction running
The callback abort becomes more consequential inside a larger transaction. Consider a service that checks the result and returns it from the transaction block:
result = Account.transaction do
AuditEvent.create!(
account: account,
event_type: "plan_upgrade_requested"
)
account.plan = "growth"
saved = account.save
Rails.logger.warn("Plan upgrade refused for Account #{account.id}") unless saved
saved
end
result
# => falseThe service reports failure, but the audit event commits. Returning false normally from an Account.transaction block does not ask Rails to roll that transaction back.
save always enters Active Record’s transaction wrapper.
If no transaction is already open, the wrapper starts one for the save. When the save returns
false,with_transaction_returning_statusraisesActiveRecord::Rollback, and that transaction rolls back.If the caller already opened a transaction, as in the example above,
savejoins it. Rails does not create a savepoint for this ordinary nested transaction. The inner transaction call catchesActiveRecord::Rollback, returns control to the outer block, and the outer transaction continues.5
After the block:
account.reload.plan
# => "starter"
AuditEvent.exists?(
account: account,
event_type: "plan_upgrade_requested"
)
# => trueIf the account update and audit event must succeed or fail together, the caller has to react to the failed save:
Account.transaction do
AuditEvent.create!(
account: account,
event_type: "plan_upgrade_requested"
)
account.plan = "growth"
account.save!
endThe callback abort now raises ActiveRecord::RecordNotSaved. Unless application code rescues it inside the transaction, the exception leaves the block, and Rails rolls back the outer transaction.
This is where the return value becomes part of the surrounding operation. false describes one refused save. It does not automatically refuse the transaction around it.
When save does not write:
Capture its return value or exception.
Inspect
errorsbefore rerunning validation.Check the SQL log for an
INSERTorUPDATE.Inspect the save, create, or update callbacks.
Decide whether the surrounding transaction can continue.
First ask how far this save got. Then ask whether the larger transaction may still commit.
Rails wraps save and save! with with_transaction_returning_status in ActiveRecord::Transactions.
The validation overrides for save, save!, and perform_validations live in ActiveRecord::Validations.
The save, create, and update callback wrappers live in ActiveRecord::Callbacks.
The create/update branch and adapter write paths live in ActiveRecord::Persistence.
ConnectionAdapters::DatabaseStatement#transaction joins an existing transaction unless requires_new is requested and silently catches ActiveRecord::Rollback.


