Active Record Callbacks
Hook into the lifecycle of Active Record objects — but only when it's the right tool.
When To Use This Skill
- Adding or modifying model callbacks (before_save, after_create, after_commit, etc.)
- Deciding whether logic belongs in a callback vs a service object
- Debugging unexpected callback behavior or ordering issues
- Implementing transaction-safe side effects (after_commit patterns)
- Halting the callback chain or suppressing callbacks
The Golden Rule
Callbacks are for model-intrinsic concerns ONLY. If the logic wouldn't make sense as a column default or database constraint, it probably doesn't belong in a callback.
✅ USE Callbacks For
- Setting defaults —
before_validation :set_default_role - Normalizing data —
before_save :strip_whitespace,before_save :downcase_email - Deriving values —
before_save :calculate_total_from_line_items - Generating tokens/slugs —
before_create :generate_uuid - Maintaining counter caches — when Rails built-in isn't enough
- Touching timestamps — simple
updated_atpropagation - Cleaning up owned resources —
after_destroy_commit :delete_file_from_storage
❌ DO NOT USE Callbacks For
- Sending emails → Service object or
after_commit+ job at minimum - Calling external APIs → Service object
- Creating other records → Service object
- Complex business logic → Service object
- Anything conditional on context → Service object (callbacks don't know WHY save was called)
- Enqueuing jobs → Use
after_commit, neverafter_save(data may not be committed yet) - Authorization checks → Controller/policy layer
- Logging business events → Service object or
after_commit
Why This Matters
Callbacks are the #1 source of Rails spaghetti. They:
- Run on EVERY save, even when you don't want them to
- Create invisible coupling (saving User sends an email?!)
- Make debugging nightmarish (callback chains are hard to trace)
- Break in bulk operations (
update_allskips them entirely) - Cause test fragility (every test that saves a record triggers the whole chain)
When in doubt, use a service object. You can always extract INTO a callback later if it truly is model-intrinsic. Extracting OUT of a callback is much harder.
Lifecycle Order
Creating a Record
before_validation
after_validation
before_save
around_save
before_create
around_create
[INSERT]
around_create (after yield)
after_create
around_save (after yield)
after_save
after_commit / after_rollback
Updating a Record
before_validation
after_validation
before_save
around_save
before_update
around_update
[UPDATE]
around_update (after yield)
after_update
around_save (after yield)
after_save
after_commit / after_rollback
Destroying a Record
before_destroy
around_destroy
[DELETE]
around_destroy (after yield)
after_destroy
after_commit / after_rollback
Key insight: before_save/after_save wrap BOTH create and update. They always run before/after the more specific before_create/after_create or before_update/after_update.
Instructions
Step 1: Ask "Should This Be a Callback?"
Apply the intrinsic test: Would this logic need to run regardless of WHERE or WHY the record is being saved?
- Normalizing an email address? Yes — always lowercase it → callback
- Sending a welcome email? No — only on signup, not admin edits → service object
- Setting a UUID? Yes — always needs one → callback
- Notifying Slack? No — depends on context → service object
If the answer involves "only when..." or "except when..." — it's probably NOT a callback. Conditional callbacks (if: / unless:) are a code smell that the logic doesn't belong here.
Step 2: Choose the Right Hook
| Need | Hook | Why |
|------|------|-----|
| Set defaults before validation | before_validation | Values available for validation |
| Normalize/derive data | before_save | Runs on both create and update |
| Set values only on first create | before_create | Won't run on updates |
| React after DB write (in transaction) | after_save / after_create / after_update | Data saved but transaction may rollback |
| React after DB commit (safe) | after_commit / after_create_commit | Transaction committed — safe for jobs, external calls |
| Clean up on delete | after_destroy_commit | Record is truly gone |
| Prevent save/destroy | before_save / before_destroy + throw :abort | Halts the chain |
Critical rule: If your callback talks to ANYTHING outside the database (APIs, job queues, file systems, mailers), use after_commit, not after_save. Data inside after_save may be rolled back.
Step 3: Registration
Prefer private methods with macro-style registration:
class User < ApplicationRecord
before_validation :normalize_email
before_create :generate_api_token
private
def normalize_email
self.email = email&.strip&.downcase
end
def generate_api_token
self.api_token = SecureRandom.hex(32)
end
end
Use blocks only for true one-liners:
class User < ApplicationRecord
before_save { self.email = email&.downcase }
end
Never use lambda/proc style — it's harder to read and test:
# Don't do this
before_save ->(user) { user.email = user.email&.downcase }
Step 4: Conditional Callbacks (Use Sparingly)
If you need a conditional callback, prefer :if / :unless with a symbol:
class Order < ApplicationRecord
before_save :normalize_card_number, if: :paid_with_card?
before_save :apply_discount, unless: :promotional?
end
Multiple conditions (all must pass):
before_save :archive_data, if: [:completed?, :older_than_90_days?]
Code smell alert: If you have more than one condition, or complex proc conditions, the logic almost certainly belongs in a service object instead.
Step 5: Halting the Chain
Use throw :abort to prevent the operation:
class Account < ApplicationRecord
before_destroy :ensure_no_active_subscriptions
private
def ensure_no_active_subscriptions
if subscriptions.active.any?
errors.add(:base, "Cannot delete account with active subscriptions")
throw :abort
end
end
end
Behavior of throw :abort:
save→ returnsfalsesave!→ raisesActiveRecord::RecordNotSaveddestroy→ returnsfalsedestroy!→ raisesActiveRecord::RecordNotDestroyed
Never raise exceptions in callbacks to halt execution. Use throw :abort.
Step 6: Transaction Callbacks (after_commit)
The most important callback distinction in Rails:
| Hook | When it runs | Transaction state | Safe for external calls? |
|------|-------------|-------------------|-------------------------|
| after_save | After INSERT/UPDATE | Still in transaction | ❌ NO — may rollback |
| after_commit | After COMMIT | Committed | ✅ YES |
| after_rollback | After ROLLBACK | Rolled back | N/A |
Convenience aliases (prefer these):
class User < ApplicationRecord
after_create_commit :send_welcome_email_job
after_update_commit :sync_to_crm_job
after_destroy_commit :cleanup_storage
after_save_commit :broadcast_changes # create OR update
end
Gotcha — after_create_commit + after_update_commit with same method name:
# BUG: Only after_update_commit will fire — they alias to after_commit internally
after_create_commit :log_change
after_update_commit :log_change # This overwrites the create one!
# FIX: Use after_save_commit for both
after_save_commit :log_change
Gotcha — after_commit runs once per record per transaction:
User.transaction do
user.update!(name: "First")
user.update!(name: "Second")
end
# after_commit fires ONCE, not twice
Step 7: Skipping Callbacks
These methods bypass callbacks entirely:
update_column/update_columns— single record, no callbacks/validationsupdate_all— bulk update, no callbacksdelete/delete_all/delete_by— no callbacks (vsdestroywhich fires them)insert/insert_all/upsert/upsert_all— bulk insert, no callbacksincrement!/decrement!— counter updates, no callbackstouch_all— bulk touch, no callbacks
Use these intentionally when you know callbacks aren't needed (e.g., counter updates, bulk data migrations).
Step 8: around_* Callbacks
Wrap the operation with yield:
class User < ApplicationRecord
around_save :benchmark_save
private
def benchmark_save
start = Time.current
yield # The actual save happens here
duration = Time.current - start
Rails.logger.info("User#save took #{duration}s")
end
end
Avoid around_* callbacks unless you genuinely need to wrap the operation (benchmarking, custom transaction handling). They add complexity and are harder to reason about.
Step 9: Callback Objects (For Reuse)
Extract into a class when the same callback logic applies to multiple models:
class NormalizeBlankToNil
def self.before_save(record)
record.attributes.each do |key, value|
record[key] = nil if value.is_a?(String) && value.blank?
end
end
end
class User < ApplicationRecord
before_save NormalizeBlankToNil
end
class Post < ApplicationRecord
before_save NormalizeBlankToNil
end
Class methods (shown above) don't need instantiation. Use instance methods only if the callback object needs its own state.
Step 10: before_destroy Ordering With dependent:
class User < ApplicationRecord
# ⚠️ WRONG ORDER — children deleted before callback runs
has_many :posts, dependent: :destroy
before_destroy :check_can_delete
# ✅ CORRECT — use prepend: true so callback runs first
has_many :posts, dependent: :destroy
before_destroy :check_can_delete, prepend: true
# ✅ ALSO CORRECT — declare callback before association
before_destroy :check_can_delete
has_many :posts, dependent: :destroy
end
Anti-Patterns
1. The God Callback
# ❌ TERRIBLE — does way too much
after_create :setup_everything
def setup_everything
send_welcome_email
create_default_workspace
sync_to_stripe
notify_admin_slack
enqueue_onboarding_drip
end
→ Use a UserRegistrationService instead.
2. Conditional Spaghetti
# ❌ Code smell — context-dependent logic
after_save :maybe_notify, if: -> {
saved_change_to_status? && status == "published" && !importing? && !admin_edit?
}
→ Move to PublishPostService that explicitly calls notification.
3. Callback-Driven Architecture
# ❌ "Saving a record" triggers a Rube Goldberg machine
after_save :update_cache
after_save :recalculate_totals
after_save :sync_search_index
after_save :notify_subscribers
after_save :update_analytics
→ Most of these belong in service objects or async jobs triggered explicitly.
4. Using after_save for External Calls
# ❌ DANGEROUS — transaction may rollback after email is sent
after_save :send_notification_email
# ✅ SAFE — only fires after commit
after_commit :send_notification_email, on: :create
Suppressing Callbacks
Use ActiveRecord::Suppressor to temporarily prevent saves of a specific model:
Notification.suppress do
User.create!(name: "Test") # Won't create notifications even if after_create does
end
Use sparingly — this is a testing/seeding tool, not production logic.
Reference
For detailed patterns, examples, edge cases, and the complete callback lifecycle, see reference.md in this skill directory.