Rails: The Sharp Parts. Callbacks Are Not Invariants | baweaver
baweaver
baweaver
Software Engineering
Simplicity is hard work. But there's a huge payoff. The person who has a genuinely simpler system is going to be able to affect the greatest change with the least work.<br>— Rich Hickey
ruby<br>June 13, 2026
Rails: The Sharp Parts. Callbacks Are Not Invariants
Last time we pulled apart indexes and found that add_index writes the suggestion, not the plan, and confusing the two leads to production surprises. Buried in the article before that, on locks, was an aside:
Single-ingress writes are worth their weight in gold… They eliminate callbacks, allow for easier instrumentation, clearer optimization paths, and much easier debugging.
That’s a bold assertion, but for me a very warranted one: model callbacks with domain behavior should be removed, and replaced with explicit objects that have exactly one ingress.
Why do I have that opinion? Of all the sharp parts in Rails, callbacks have led to the most entanglement of otherwise unrelated code in surprising ways that frequently cause outages at scale. Magic has a cost, and while it feels good to write in the moment and seems clear, that cost will come due whether weeks, months, or even years later.
The Same Model, Two Directions
Consider two scenarios on the same model.
Example one starts with a backfill task:
Order.where(region: nil).find_each do |order|<br>order.update!(region: "us-west-2")<br>end
The model had an after_commit that synced any changed order to the CRM, so forty thousand orders re-synced, the webhook queue backed up for half an hour, and whatever unfortunate partners we’re working with probably rate-limited us. Nobody wrote “sync to the CRM” in that backfill task; the model did, implicitly, whether or not we knew it.
Example two. A different engineer runs the same backfill with update_all:
Order.where(region: nil).update_all(region: "unknown")
It’s reasonably fast and because of the update_all it’s not going to trigger a sync storm like the last one, so we’ve avoided the callback cost here, right?
Well that same Order model was also maintaining an OpenSearch search index using a callback, and now thousands of orders are silently falling out of searches for the next few weeks until a customer asks why they can’t find their orders.
Both examples made the same mistake, albeit from opposite directions. The first didn’t consider what callbacks might run on an update, and the second didn’t consider what callbacks won’t run if you skip them. In code review that shared belief sounds like “put it in a callback so it always happens, no matter who writes the record.” That sentence is wrong twice: it doesn’t always happen, and you can’t see when it does.
What save Actually Runs
save reads like a verb, an atomic action, but it’s a program. When you call it, ActiveRecord runs the compiled callback chain for :save (and :create or :update, and :validation, and later :commit) with your record threaded through every entry (Rails: ActiveRecord::Callbacks). Your before_save blocks are entries in that chain, and so are entries you never wrote and may not even know about, because association macros register callbacks too.
Two models, zero user callbacks:
Note : Code samples in this article run against a shared test schema. The self.table_name lines map models to those tables; in a real app these would be separate migrations.
class Author ActiveRecord::Base<br>self.table_name = "events"<br>has_many :posts, class_name: "Post", foreign_key: :event_id, dependent: :destroy<br>end
class Post ActiveRecord::Base<br>self.table_name = "seats"<br>belongs_to :author, class_name: "Author", foreign_key: :event_id, touch: true, counter_cache: true<br>end
def census_demo<br>post_save_filters: Post._save_callbacks.map { _1.filter.to_s },<br>post_count: census(Post),<br>author_count: census(Author)<br>end
# => {<br># post_save_filters: ["autosave_associated_records_for_author"],<br># post_count: 5,<br># author_count: 6<br># }
That lone save entry is autosave, the feature where saving a record also saves any loaded associated records, and belongs_to registered it on your behalf. Two lines of associations and zero callbacks of your own still leave eleven registered, and a typical model adds five to ten more with domain behavior.
Aside : run that same census on ActiveRecord 7.2 and you get 6 and 8. The framework’s own contribution to your chain changes across upgrades, which means the program behind save shifts under you even when your model’s file doesn’t.
A callback is control flow attached to persistence that the call site can’t see, written by an author who can’t see the call sites: two blind spots pointed at each other, and everything below is a consequence of that.
Failure One: The Paths That Skip the Chain
So how does the assumption break? The assumption developers carry is “always happens.” Rails never said that , and the API surface shows exactly where it...