Active Record Callbacks
A callback runs at a named moment in a record's life cycle so side effects stay next to the model.
<!-- hal:authoritative:yaml -->
A callback is a method Rails invokes at a named moment in a record’s life cycle (before or after validate, save, create, update, or destroy) so side effects stay next to the model instead of scattered through controllers.
§I — Frame
Duha session 05 for the Rails track. Dual-fire beside Rust. The spine remains the official Rails Guides snapshot on disk: rails-guides/ at v8.1.3.1. Today’s file is active_record_callbacks.html.
Session 04 gated writes with validations. Callbacks are the next layer: when the object is about to change or has just changed, run a method. Keep validations for “may this persist?” Keep callbacks for “what else must happen around that persistence?”
By the end, register a callback (method, block, or proc), know the create/update/destroy order, prefer after_commit for work that needs a durable row, know which APIs skip callbacks, and halt a chain with throw :abort instead of raising for control flow.
Associations depth and the query interface stay later syllabus rows.
§II — Life cycle, then registration
Objects are initialized, validated, created, updated, destroyed, and loaded. Callbacks hook those moments. The smallest form:
class BirthdayCake < ApplicationRecord
after_create -> { Rails.logger.info("Congratulations, the callback has run!") }
end
BirthdayCake.create runs the lambda after the insert succeeds in the normal save path.
Four registration styles appear in the guide:
- Macro + private method —
before_validation :ensure_username_has_valuethen a privatedef. - Block — short one-liners on the macro itself.
- Proc / lambda —
before_validation ->(user) { … }. - Callback object — a class or module with a matching class method (
self.before_validation(record)).
Declare callback methods private. Do not call update / save from inside a callback to “fix” state; assign attributes (self.attr = …) in a before-* hook instead. Nested saves breed surprise recursion and commit-order bugs.
Scope a callback to a context with :on:
before_validation :ensure_username_has_value, on: :create
after_validation :set_location, on: [:create, :update]
§III — Order on create, update, destroy
Creating runs, in order:
before_validation → after_validation → before_save → around_save → before_create → around_create → after_create → after_save → after_commit / after_rollback
Updating swaps create for update in the middle, but keep this rule: save callbacks always wrap the more specific create/update callbacks. after_save runs after after_create or after_update, regardless of declaration order in the class body.
Destroying (destroy, not delete) runs:
before_destroy → around_destroy → after_destroy → after_commit / after_rollback
around_* callbacks yield to the wrapped operation:
around_save :log_saving
def log_saving
Rails.logger.info("Saving…")
yield
Rails.logger.info("Saved.")
end
Validation callbacks also fire when you call valid? / invalid? directly, not merely during save.
§IV — after_commit vs after_save
after_save / after_create / after_update run inside the same database transaction as the write. If a later step rolls the transaction back, those callbacks already ran. Side effects that must see a committed row (enqueue mail, ping an external API, touch a cache that other processes read) belong on after_commit (or the commit variants scoped to create/update/destroy).
after_rollback is the sibling for cleanup when the transaction aborts.
Rule of thumb from this page’s shape: use after_* save/create/update for in-transaction model bookkeeping; use after_commit when the outside world must only hear about durable rows.
§V — Skipping and halting
Same spirit as validations: some APIs bypass the callback chain. The guide’s skip list includes delete / delete_all / delete_by, update_column / update_columns / update_all, insert / insert_all, upsert / upsert_all, counter helpers, and related forms. destroy runs destroy callbacks; delete does not. If you call a skipper, you own the missing hooks.
To stop the chain without treating it as an unexpected exception, throw :abort from a before-* callback:
before_destroy :check_admin_count
def check_admin_count
if admin? && User.where(role: "admin").count == 1
throw :abort
end
end
Raising inside a callback rolls back the transaction and re-raises. That surprises callers who expect save / create to return false on soft refusal. Prefer throw :abort for deliberate halts; reserve raises for real errors.
Conditional callbacks use :if / :unless the same way validations do. Keep the predicate cheap and free of further persistence.
§VI — One complete proof
- Add
before_validationthat fills a blank attribute, andafter_create(orafter_commit on: :create) that logs or enqueues a no-op job. - Create a record and confirm the before-hook ran before validations and the after-hook ran after insert.
- Name one API that skips callbacks (
delete,update_columns, …) and say when you would refuse it for ordinary form flow. - Say aloud:
after_savecan run inside a transaction that later rolls back;after_commitwaits for durability.
When those four hold, this Guides page’s selected depth is done.
§VII — Closing
Callbacks attach behavior to the object life cycle. Register them privately, assign in before-* hooks, and keep nested saves out. Learn the create/update/destroy order and the save-wraps-create/update rule. Put external side effects on after_commit. Know the skip list. Halt with throw :abort. Session 04’s validations still decide whether a save should proceed; callbacks decide what else happens around that decision. Associations are the next unmarked row.
Done-criteria: register a life-cycle callback, state create vs update order (including save wrapping), prefer after_commit for durable side effects, and name a skip method plus throw :abort.