active-record-callbacks
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.
More from thinkoodle/rails-skills
stimulus
Expert guidance for building Stimulus controllers in Rails applications. Use when creating JavaScript behaviors, writing data-controller/data-action/data-target attributes, building interactive UI components, or working with Hotwire Stimulus. Covers controller creation, targets, values, actions, classes, outlets, lifecycle callbacks, progressive enhancement, and common patterns like clipboard, flash, modal, toggle, and form validation.
4i18n
Expert guidance for Rails I18n (internationalization and localization). Use when working with translations, locale files, t() / l() helpers, lazy lookups, pluralization, interpolation, date/time/number formatting, model translations, error message translations, setting locale from URL/header/session, or organizing YAML translation files. Triggers on "i18n", "internationalization", "translation", "locale", "localize", "t()", "translate", "multilingual", "pluralization", "locale file", "YAML translation".
4active-model
Build plain Ruby objects that integrate with Rails forms, validations, serialization, and Action Pack — without a database. Use when building form objects, search forms, API wrappers, configuration objects, virtual models, or any PORO that needs model-like behavior. Triggers on "active model", "form object", "plain Ruby object", "non-database model", "ActiveModel", "PORO", "service object validation", "virtual model", "tableless model", "search form", "ActiveModel::API", "ActiveModel::Model".
4lucide-icons
Expert guidance for using Lucide icons in Rails applications via the lucide-rails gem. Use when adding icons, choosing icon names, styling SVG icons, making icons accessible, or building icon-based UI patterns (buttons, nav, badges, empty states). Triggers on "lucide", "icon", "lucide-rails", "SVG icon", "icon library", "lucide_icon", "feather icon", "inline SVG", "add an icon", "icon button", "icon name".
4propshaft
Expert guidance for the Propshaft asset pipeline in Rails 8+. Use when working with assets, stylesheets, CSS organization, JavaScript assets, import maps, asset precompilation, fingerprinting, images, fonts, CDN setup, or migrating from Sprockets. Covers file structure, stylesheet_link_tag, image_tag, @layer ordering, @import, CSS custom properties, asset helpers, deployment, and common Propshaft pitfalls. Trigger on "propshaft", "asset pipeline", "assets", "stylesheet", "javascript assets", "import maps", "asset precompile", "CSS organization", "sprockets migration", "fingerprinting", "stylesheet_link_tag", "image_tag", "asset_path".
4