LaunchKit
← All posts
· 16 min read · by The LaunchKit team · 0 views

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.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.