Rails callback order, printed
Rails callbacks are the only place in a model where the order of the lines you wrote is not the
order they run in, and where a method three files away can decide whether your save returns true.
That is worth something when what you are doing is deriving a column from another column, and it is
the wrong shape for almost everything else people put there.
Everything below was run against activerecord 8.1.3.1 and PostgreSQL 17.7 on a scratch database. The scripts do nothing exotic: they declare every one of the Active Record callbacks on one model and have each of them print its own name. Where a number appears it was measured.
The order, printed rather than recited
Here is the model. Every callback Active Record defines, each one appending its name to an array:
class Post < ActiveRecord::Base
after_initialize { $log << "after_initialize" }
after_find { $log << "after_find" }
before_validation { $log << "before_validation" }
after_validation { $log << "after_validation" }
before_save { $log << "before_save" }
around_save { |_, block| $log << "around_save (before yield)"; block.call; $log << "around_save (after yield)" }
after_save { $log << "after_save" }
before_create { $log << "before_create" }
# ... before_update, before_destroy and their around_ and after_ partners
after_commit { $log << "after_commit" }
after_create_commit { $log << "after_create_commit" }
after_rollback { $log << "after_rollback" }
end
Post.create!(title: "x"):
1. after_initialize
2. before_validation
3. after_validation
4. before_save
5. around_save (before yield)
6. before_create
7. around_create (before yield)
8. around_create (after yield) <- the INSERT happened at the yield
9. after_create
10. around_save (after yield)
11. after_save
12. after_create_commit
13. after_commit
post.update!(title: "z") on a record already in the table:
1. before_validation
2. after_validation
3. before_save
4. around_save (before yield)
5. before_update
6. around_update (before yield)
7. around_update (after yield)
8. after_update
9. around_save (after yield)
10. after_save
11. after_update_commit
12. after_commit
post.destroy:
1. before_destroy
2. around_destroy (before yield)
3. around_destroy (after yield)
4. after_destroy
5. after_destroy_commit
6. after_commit
Destroy is the short one, and the omission is the point: no validation runs, so a before_destroy
that adds an error to errors is writing into an object nobody is going to read. To stop a destroy
you throw, and two sections below this one is what that actually does.
after_find lands before after_initialize
Post.find(id) prints two lines, in this order:
1. after_find
2. after_initialize
Both fire on a record loaded from the database, and they fire in the order the framework
instantiates in rather than the order they are usually listed in. A newly built record gets
after_initialize alone. If you have after_initialize setting a default and after_find reading
it, the read happens first and gets nil, on loaded records only, so the bug is invisible to any
test that builds its subject with new.
What after_commit gives you that after_save does not
The guides say after_commit "makes very different guarantees than after_save". Here is the
guarantee, measured. A second PostgreSQL connection, opened with the pg gem and standing in for a
worker process, counts rows with that id from inside both callbacks:
OTHER = PG.connect(host: "127.0.0.1", port: 15432, dbname: "cb_order_scratch")
class Post < ActiveRecord::Base
after_save { puts "after_save: #{other_sees(id)} row(s)" }
after_commit { puts "after_commit: #{other_sees(id)} row(s)" }
end
Post.create!(title: 'visible?')
after_save: another connection sees 0 row(s) with id=1
after_commit: another connection sees 1 row(s) with id=1
Zero and one. Inside after_save the record has an id, persisted? is true, and it does not exist
for anyone else in the world. Every other process, every worker, every read replica and every human
running psql sees an empty table. after_commit is the first moment that stops being true.
So the rule is not a style preference. Anything that hands the record's identity to something
outside this process belongs in after_commit, because everywhere else in the callback chain the
record is a local fiction. Anything that modifies the record being saved belongs before the write,
because after it there is nothing left to modify without a second UPDATE.
The job that cannot find the record
The classic version of that bug is a job. after_save { WelcomeEmailJob.perform_later(self) }, the
worker picks it up in the two milliseconds before the transaction commits, GlobalID cannot find
the row, and you get ActiveJob::DeserializationError on a record that plainly exists by the time
you go looking.
Rails has a switch for it, and the switch is off. In activejob 8.1.3.1,
active_job/enqueuing.rb:53:
class_attribute :enqueue_after_transaction_commit, instance_accessor: false, instance_predicate: false, default: false
Checked in this site's own application, which runs config.load_defaults 8.1:
$ bin/rails runner 'puts ActiveJob::Base.enqueue_after_transaction_commit.inspect'
false
The perform_later doc comment in the same file, at line 71, says the enqueue "is implicitly
deferred to after the transaction is committed", and EnqueueAfterTransactionCommit#raw_enqueue only takes the
deferring branch if self.class.enqueue_after_transaction_commit. The comment describes the
behaviour you get after opting in. The default is the immediate super.
Which queue backend you run changes how much this matters, and it is one of the quieter arguments
in Solid Queue vs Sidekiq. Solid Queue writes the job as a row in
the same PostgreSQL database, so a job enqueued inside your transaction is inside your transaction:
invisible to pollers until you commit, gone if you roll back. Sidekiq writes to Redis, which knows
nothing about your transaction, so the job is live the instant perform_later returns. The same
line of Ruby is safe on one and a race on the other. Turbo's broadcasts sit in the same trap and
Turbo Stream actions has the discard_on detail that makes the
dropped broadcast silent.
A callback that talks to the network also holds a row lock
The reason not to POST to Stripe from after_save is usually given as a testing argument. The real
argument is a lock. Your callback runs inside the transaction that wrote the row, the row is locked
for writers until that transaction ends, and the transaction cannot end until your callback returns.
after_save { sleep 2.0 }, standing in for an HTTP call, while a second connection tries to update
the same row:
pg_stat_activity: Lock | active | UPDATE posts SET title = $1 WHERE id = $2
other connection's UPDATE waited 1.60s before it was allowed to write
wait_event_type is Lock. The other process is not slow, it is blocked, and it is blocked on a
payment processor's response time. Put that on a User row on a busy signup path and the shape you
get is a connection pool draining while every request waits on somebody else's TLS handshake. The
callback looks like one line of Ruby and behaves like a distributed lock with a remote timeout.
This gets worse rather than better under retries. An HTTP client with three attempts and a backoff turns a 500 millisecond call into a possible fifteen second transaction, and nothing in the model says so.
One failing after_commit skips every callback behind it
after_commit is past the point where anything can be rolled back, which people know, and the
second half of that is less often said out loud. Two records created in one transaction, the first
one's after_commit raising:
after_commit first
raised: RuntimeError: SMTP connection refused
rows still in the table: ["first", "second"]
after_commit second never printed. The exception escaped the commit queue and took every callback
behind it with it, and both rows are durably committed. Whatever the second record's callback was
for, an email, a webhook, a cache invalidation, it did not happen and there is no record anywhere
that it was supposed to.
That is the failure mode to hold in your head when deciding what goes in after_commit. Not "the
data might be wrong", because the data is fine. The data is committed and some arbitrary suffix of
your side effects is missing, which is a state no retry can reconstruct, because the queue that
would have told you what was left is gone with the exception.
Halting a save with throw :abort
Returning false stopped halting callbacks in Rails 5. It is still the first thing people try, and
on activerecord 8.1.3.1 the two ways of trying it fail differently:
before_save method returns false (Rails 5+)
-> returned #<D id: 2, title: "d", ...> # saved anyway
rows titled d: 1
before_save { return false }
-> LocalJumpError: unexpected return
A method returning false is ignored. A block writing return raises, because return from a block
is a LocalJumpError in plain Ruby and has nothing to do with Rails. Neither halts anything.
throw :abort is the mechanism, and it reports differently through each entry point:
A.new.save (before_save throws) -> returned false
A.new.save! (before_save throws) -> ActiveRecord::RecordNotSaved: Failed to save the record
b.destroy (before_destroy) -> returned false
b.destroy! (before_destroy) -> ActiveRecord::RecordNotDestroyed: Failed to destroy B with id=1
F#save (before_validation) -> returned false, and f.errors.full_messages is []
Note the last line. Halting in before_validation gives the caller false and an empty errors
collection, so a controller doing render :new on a failed save renders a form with nothing wrong
with it. If the halt means something to a user, add the error yourself before you throw.
throw :abort in an after_ callback is not a halt
Throwing from the other side of the write is a different event entirely:
throw :abort inside after_save -> UncaughtThrowError: uncaught throw :abort
rows titled c: 0
throw :abort inside after_create -> UncaughtThrowError: uncaught throw :abort
rows titled g: 0
Nothing catches the throw there, so it propagates as a Ruby exception, unwinds the transaction on
its way out, and the row is correctly absent. The data is right and the error is wrong: the caller
gets UncaughtThrowError, which names no model, no attribute and no reason, and which no rescue
clause in your controller is written for. Raising a real exception with a message costs the same
rollback and says what happened.
The commit callbacks run in the order you wrote them, since 7.1
Which of two after_commit blocks goes first depends on a config flag, and the flag is read when
the callback is declared rather than when it fires. active_record/transactions.rb:342:
def prepend_option
if ActiveRecord.run_after_transaction_callbacks_in_order_defined
{ prepend: true }
else
{}
end
end
The library default is false in active_record.rb:358, which gives you reverse declaration order.
Railties turns it on in the when "7.1" branch of configuration.rb:291, so every application on
load_defaults 7.1 or newer gets declaration order. Run the same model file under bare Active
Record in a script and under bin/rails runner, and two after_commit blocks swap places. That is
why a reproduction in a scratch script can disagree with your application for reasons that have
nothing to do with your code.
save with nothing to save still fires everything
save on a persisted record with no dirty attributes, with the SQL logger on:
before_save
after_update
after_save
after_commit
(end)
Not one line of SQL. No BEGIN, no UPDATE, no COMMIT, and the full chain ran including the
commit callbacks. Persistence#_update_record is where it comes from:
if attribute_names.empty?
affected_rows = 0
@_trigger_update_callback = true
Zero rows affected, and the update callback triggered on purpose. So after_update_commit is not a
signal that anything changed, and a callback in it that enqueues a reindex or busts a cache will do
so for a save that did nothing at all. If you want "something actually changed", ask
saved_changes? rather than trusting the callback's name.
dependent: :destroy is a before_destroy, and where you wrote it decides
has_many :comments, dependent: :destroy is not special machinery. It installs a before_destroy
callback at the point in the class body where you wrote the association, and it takes its turn in
the same queue as yours. Two classes, identical except for two lines swapped:
before_destroy declared AFTER the association sees 0 comment(s)
before_destroy declared BEFORE the association sees 2 comment(s)
A callback that archives a record's children, or counts them, or emails their owners, sees them or
does not see them depending on which line of the file it sits on. Nothing in the reading of either
class suggests that the order of two unrelated-looking declarations is load bearing. prepend: true
on your own callback is the escape hatch, and the honest version is to stop relying on the ordering
of a file and do the work in a method the caller invokes.
Your test suite cannot see the difference
after_commit in a transactional test fires, and it is lying to you. The pool wraps each example in
a transaction marked non-joinable, connection_pool.rb:379:
@pinned_connection.begin_transaction joinable: false, _lazy: false
Non-joinable means your model's save cannot merge into that wrapper and opens a nested transaction
of its own, and one line in abstract/transaction.rb:529 decides what that nested transaction is
allowed to do on the way out:
run_commit_callbacks = !current_transaction.joinable?
The parent is not joinable, so the child runs commit callbacks. Reproduced with the same two-connection setup:
inside a joinable: false wrapper (what transactional tests and rails console --sandbox use):
after_commit fired; another connection sees 0 row(s)
rows left after the wrapper rolled back: 0
The callback ran. The row was invisible to every other connection the whole time and then ceased to
exist. So a green spec asserting that your after_commit enqueued a job proves the callback fires
and proves nothing about the property you moved it to after_commit for. The visibility is the
part you cannot test this way, which is a decent argument for not depending on it: an enqueue you
call explicitly from the service object that did the save is a thing a test can actually observe.
rails console --sandbox uses the same trick, at railties/console_sandbox.rb:4.
When a callback is the right answer
Derive an attribute of the record being saved, from other attributes of the record being saved, in pure Ruby, before it is written. That is the whole list.
The Lead model on this site does exactly that:
before_validation :assign_nickname_key
def assign_nickname_key
self.nickname_key = nickname.blank? ? nil : self.class.fold_nickname(nickname)
end
nickname_key is a folded form of nickname with homoglyphs mapped and separators removed, and a
unique index on it is what stops two nicknames that look identical from both existing. It has to be
a before_validation and not a service object call, because validates :nickname_key, uniqueness:
true runs after it and would otherwise validate a stale column. No I/O, no other record, no
network, and the invariant holds for a row written by a console, a seed file or a rake task.
The LaunchKit boilerplate has precisely two model callbacks in the whole application.
User has before_create :assign_referral_code, which loops SecureRandom.alphanumeric(8).upcase
until it finds one no user holds. AiTemplate has before_update :snapshot_previous, which pushes
the _in_database values of three columns onto a versions array so a prompt edit is reversible.
Both write only the record being saved. Neither touches the network.
The mail in that application is not in a callback at all. The confirmation mailer is called with
deliver_later from RegistrationsController and from EmailConfirmationsController, where the
request that decided to send it can be read in the same file. That is a position, not an
accident, and it is the one I would defend: a callback is for keeping a row internally consistent,
and everything a user-visible action causes belongs where the action is.
The gem, and the method that took most of its job
after_commit_everywhere 1.6.0, MIT, 40.5 million downloads, last released 2025-02-07, by Andrey
Novikov. It gives you after_commit { ... } as an ordinary method call from anywhere, not just from
a model class body, which is the right shape when the thing to defer is known at the call site
rather than declared for every save forever.
Rails 7.2 shipped the core version of that idea, and in activerecord 8.1.3.1 it is
ActiveRecord.after_all_transactions_commit, at active_record.rb:573. Its documentation covers
the cases people get wrong by hand:
If there is no currently open transaction, the block is called immediately. [...] If any of the currently open transactions is rolled back, the block is never called.
Called immediately when there is no transaction is the property that makes it usable in a service object that does not know whether its caller opened one. So:
def publish(article)
article.update!(published: true)
ActiveRecord.after_all_transactions_commit do
PublishNotificationMailer.with(article: article).deliver_later
end
end
That is the same guarantee as after_commit, expressed at the one call site that wanted it, on a
method the framework maintains. Reach for the gem when you are on a Rails older than 7.2 or when you
need before_commit and after_rollback in the same style.
The call, and what would change it
Use a callback for a derived column and nothing else. before_validation when a validation reads
what you derived, before_save when nothing does. Everything with a consequence outside the
process, mail, jobs, webhooks, cache busting, search indexing, goes at the call site, deferred with
ActiveRecord.after_all_transactions_commit if it must not fire on a rollback.
The cost of that position is real and I will name it. A callback is the only mechanism that covers
every path into the row: a console session, a seed file, a rake task, a fixture and a future
controller nobody has written yet all go through save, and none of them go through your service
object. Move mail out of after_create and you have accepted that somebody creating a user in
rails console sends no welcome email. For a derived column that would be unacceptable, which is
exactly why derived columns stay in callbacks. For an email it is usually correct.
What would change it: enqueue_after_transaction_commit defaulting to true would kill the single
most common callback bug on the list and make after_create { SomeJob.perform_later(self) }
defensible again. And a way to declare that a callback may not perform I/O, enforced rather than
reviewed, would let the "callbacks are fine, just be careful" position be checkable instead of
aspirational.
What this post does not cover
ActiveSupport::Callbacks itself, the terminator lambda and skip_after_callbacks_if_terminated,
which is the layer below everything here and a different post. Controller filters, which share the
before_/after_/around_ naming and throw :abort and are otherwise unrelated. Autosaved
associations, where a parent's save runs a child's entire callback chain inside the parent's
transaction, which multiplies every hazard above by the number of children. And touch, which fires
after_touch and the commit callbacks while skipping before_save and after_save entirely, so a
counter maintained in after_save misses every touch. The counter version of that argument, and
the update_all that avoids the whole chain, is in
Counter caches by hand.
No timings appear above except the lock wait, which is the one number whose magnitude is the claim. The scripts are a few dozen lines each and reproduce on any PostgreSQL you point them at.
Comments
No comments yet. Be the first.