Spot the Tax · Card 15 of 20
update_all is a SQL escape hatch
Why a fast bulk update can quietly leave 50,000 users with broken state.
The code
What will this cost you in six months?
class User < ApplicationRecord
before_save :extend_trial_if_needed
private
def extend_trial_if_needed
self.trial_ends_at = 14.days.from_now if role == "trial"
end
end
# In a rake task:
User.where(role: "free").update_all(role: "trial") The problem
update_all sends a single SQL UPDATE statement directly. It skips callbacks, validations, and even updated_at. That's the whole point of it — it's the fast path. But it also means the before_save that sets trial_ends_at doesn't fire. 50,000 users now have their role set to "trial" but a nil trial_ends_at, and three days later support starts getting tickets from people whose trial expired the moment they signed up.
Take a moment. Before revealing, think about what you'd use instead when callbacks need to fire — and how you'd make it obvious in the code when callbacks are deliberately being skipped.
The solution
When the model has callbacks that need to fire (or validations you can't skip, or invariants the model is responsible for), iterate explicitly with find_each and call update! on each record. It's slower than the SQL escape hatch, but it preserves everything Active Record was doing for you.
- Callbacks fire, invariants stay intact
- If you do choose to use
update_all, leave a comment that says callbacks are deliberately skipped - The next reader can tell whether the choice was considered or accidental
User.where(role: "free").find_each do |user|
user.update!(role: "trial")
end The principle at play — Performance shortcuts skip the framework
Active Record gives you a lot of behavior for free: validations, callbacks, timestamps, dirty tracking, association management. The price of that is overhead, and Rails gives you escape hatches like update_all, delete_all, and insert_all for the cases where you don't need any of it and want raw SQL speed.
The thing to remember is that those escape hatches don't just skip the overhead — they skip every guarantee the framework was providing. The validations that protect your data, the callbacks that keep related state in sync, the timestamps that tell you when a row last changed: all of them silently disappear. That's fine when you've thought about it. It's painful when you haven't.
The discipline is to be deliberate. If you're using a bulk SQL operation, ask "what is this skipping, and am I OK with that?" before you ship it. And leave a one-line comment so the next developer doesn't have to re-derive the answer.