Rails ActiveRecord Callbacks: When They Help and When They'll Burn You
ActiveRecord callbacks let you hook into an object’s lifecycle — before_save, after_create, around_destroy — and run code automatically. They’re one of the first Rails features developers reach for, and one of the first things senior developers learn to distrust.
The problem isn’t callbacks themselves. It’s that they create invisible coupling between “saving a record” and “everything that should happen when a record is saved.” That coupling compounds until your test suite takes 45 seconds to create a single user because after_create triggers an email, an analytics event, a webhook, and three cache invalidations.
Here’s how to use callbacks without building a house of cards.
The Callbacks That Are (Mostly) Safe
Some callbacks belong in models because they’re about data integrity — making sure the record itself is correct before it hits the database.
Normalizing Data with before_validation
class User < ApplicationRecord
before_validation :normalize_email
private
def normalize_email
self.email = email&.strip&.downcase
end
end
This is a textbook safe callback. It’s about the data on this record, it has no side effects, and it makes validation more reliable. If you removed it, you’d get inconsistent email casing in your database.
Other good fits for before_validation:
- Stripping whitespace from string fields
- Setting default values that depend on other attributes
- Generating slugs from titles
Setting Defaults with before_create
class Organization < ApplicationRecord
before_create :set_default_plan
private
def set_default_plan
self.plan ||= "free"
end
end
Database defaults handle simple cases, but when the default depends on logic (e.g., setting a trial_ends_at based on current time plus 14 days), before_create keeps it close to the model.
Maintaining Counters with after_create / after_destroy
Rails has counter_cache built in, but sometimes you need custom counting:
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
# Rails handles this automatically — no callback needed
end
Use the built-in counter_cache: true option on belongs_to instead of writing your own after_create / after_destroy pair. It handles race conditions and edge cases you’d otherwise have to solve yourself.
The Callbacks That Cause Problems
Sending Emails from after_create
This is the most common callback mistake in Rails applications:
# Don't do this
class Order < ApplicationRecord
after_create :send_confirmation_email
private
def send_confirmation_email
OrderMailer.confirmation(self).deliver_later
end
end
It looks clean. It’s also a trap. Here’s why:
Problem 1: Your test suite sends emails (or enqueues jobs) every time it creates an order. You’ll end up littering tests with ActionMailer::Base.deliveries.clear or wrapping factories in perform_enqueued_jobs blocks.
Problem 2: Seed scripts and data migrations trigger the callback. Run Order.create!(...) in a migration and you’ve just sent a confirmation email for a three-year-old order.
Problem 3: The callback runs even when you don’t want it to. Importing orders from a CSV? Admin creating a test order? The email fires every time unless you start reaching for skip_callbacks, which breaks the implicit contract that callbacks always run.
Calling External Services from after_save
# This will hurt eventually
class Payment < ApplicationRecord
after_save :sync_to_stripe
private
def sync_to_stripe
StripeService.update_payment(self)
end
end
When the Stripe API is down, saving a payment record fails. Your users can’t complete checkout because a third-party sync is coupled to your persistence layer. The failure mode is invisible — the exception comes from inside a callback, not from the controller action that triggered it.
Cascading Callbacks
The worst callback bugs come from chains:
class User < ApplicationRecord
after_create :create_default_workspace
end
class Workspace < ApplicationRecord
after_create :create_default_channel
end
class Channel < ApplicationRecord
after_create :notify_workspace_members
end
Creating a user now sends a notification. Nobody looking at User.create! would expect that. And when the notification service raises an error, the entire user creation rolls back — including the workspace and channel — because it all happened inside a single transaction.
This is the callback cascade problem, and it’s responsible for some of the most confusing bugs in Rails applications. The stack trace shows an error in notify_workspace_members, but the actual symptom is “users can’t sign up.”
What to Use Instead
Service Objects for Business Logic
Move side effects out of callbacks and into explicit service objects:
class Orders::Create
def initialize(user:, cart:)
@user = user
@cart = cart
end
def call
order = Order.create!(
user: @user,
items: @cart.items,
total: @cart.total
)
OrderMailer.confirmation(order).deliver_later
Analytics.track("order_created", user: @user, order: order)
WebhookService.notify(:order_created, order)
order
end
end
Now every side effect is visible at the call site. Tests for Order don’t trigger emails. You can create orders in seeds and migrations without side effects. And when the webhook service fails, you can see exactly where and why in the stack trace.
after_commit for Cache Invalidation
If you must use a callback for side effects, after_commit is safer than after_save because it runs after the database transaction commits:
class Product < ApplicationRecord
after_commit :invalidate_cache, on: [:create, :update, :destroy]
private
def invalidate_cache
Rails.cache.delete("products/#{id}")
Rails.cache.delete("products/index")
end
end
after_commit won’t fire if the transaction rolls back, which prevents cache invalidation for records that don’t actually exist. This is a meaningful improvement over after_save, which runs inside the transaction.
In Rails 7.1+, you can also use after_commit with enqueue:
class Product < ApplicationRecord
after_create_commit :index_in_search
private
def index_in_search
SearchIndexJob.perform_later(self)
end
end
Jobs enqueued from after_commit won’t run until the record is actually persisted, avoiding the race condition where Sidekiq picks up the job before the transaction commits.
ActiveSupport::Notifications for Decoupled Events
For complex event-driven architectures, Rails has a built-in pub/sub system:
# In your service object
ActiveSupport::Notifications.instrument("order.created", order: order)
# In an initializer
ActiveSupport::Notifications.subscribe("order.created") do |event|
OrderMailer.confirmation(event.payload[:order]).deliver_later
end
This fully decouples the event from the handler. The code creating the order doesn’t need to know what happens afterward.
The Decision Framework
Use this to decide where logic belongs:
Use a callback when:
- The logic is about data integrity on this specific record
- It has no external side effects (no HTTP calls, no emails, no job enqueuing)
- Removing it would leave the database in an inconsistent state
- It should run every single time, with no exceptions
Use a service object when:
- The logic involves multiple models or external services
- Tests shouldn’t trigger it by default
- You can imagine scenarios where you’d want to skip it
- It represents a business process, not a data constraint
Use after_commit when:
- You need to react to persisted changes (cache invalidation, search indexing)
- The side effect should only happen if the transaction succeeds
- The side effect is lightweight and unlikely to fail
Dealing with Existing Callback Spaghetti
If you’re working in a codebase that already has callbacks everywhere, here’s a practical approach:
Step 1: Audit your callbacks. Run this in the Rails console:
ApplicationRecord.descendants.each do |model|
callbacks = model.__callbacks.flat_map { |type, chain| chain.map { |cb| "#{type}: #{cb.filter}" } }
puts "#{model.name}: #{callbacks.join(', ')}" if callbacks.any?
end
Step 2: Categorize each callback as “data integrity” or “side effect.”
Step 3: Extract side-effect callbacks into service objects one at a time. Start with the ones causing the most test pain or the most confusing bugs.
Step 4: For callbacks you can’t remove yet, add comments explaining why they exist and what triggers them. Future developers (including you in six months) will thank you.
Callbacks and skip_callbacks: A Code Smell
If you find yourself writing this:
User.skip_callback(:create, :after, :send_welcome_email)
user = User.create!(email: "test@example.com")
User.set_callback(:create, :after, :send_welcome_email)
That’s a signal the logic doesn’t belong in a callback. You’re working around your own architecture. The skip_callback / set_callback pattern is also not thread-safe in production — two concurrent requests could interfere with each other.
Rails 7.1 added with_callbacks which is slightly better but still a workaround:
User.suppress do
User.create!(email: "test@example.com")
end
If you regularly need to create records without triggering callbacks, the callbacks are doing too much.
Frequently Asked Questions
Are Rails callbacks bad?
No. Callbacks for data normalization and integrity checks are fine. The problems start when callbacks trigger side effects like emails, API calls, or job enqueuing. The callback itself isn’t bad — it’s the coupling between persistence and business logic that causes issues.
Should I use after_save or after_commit?
Prefer after_commit for anything that interacts with the outside world (jobs, caches, external services). after_save runs inside the transaction, which means the record might not actually be persisted when your side effect runs. after_commit guarantees the data is in the database.
How do I test models that have callbacks?
If your callbacks handle data integrity (normalizing fields, setting defaults), test them as part of your normal model tests. If your callbacks trigger side effects, that’s a sign to extract them into service objects that you can test independently.
Can callbacks cause N+1 queries?
Yes. A callback like after_save :update_parent_stats that loads associations will trigger a query every time a record is saved. If you’re saving records in a loop, you’ll get N+1 behavior from callbacks just like you would from views. Use after_commit with background jobs for expensive computations.
What about around_* callbacks?
around_save, around_create, etc. are rarely needed and hard to reason about. They wrap the entire operation and require you to explicitly yield to continue the chain. In seven years of Rails consulting, I’ve seen legitimate uses for around_* callbacks exactly twice — both involving complex audit logging. If you think you need one, you probably need a service object instead.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation