Ruby & Rails

The callback that fires in a test you didn't write

Active Record callbacks are convenient until a fixture, a seed script or a bulk import triggers side effects nobody expected.

13 August 2026 3 min read Mohammad Aaquib Jawed

A callback attaches behaviour to a lifecycle event. after_create :send_welcome_email reads beautifully and works perfectly for the one path you were thinking about.

Then someone writes a seed script and two hundred welcome emails go out.

The problem is invisible coupling

A callback makes a side effect a property of the record rather than of the operation. Every place that creates the record inherits the behaviour — including places written later, by people who never read the model.

Admin tools, data imports, test factories, rake tasks, console sessions. Each is a valid way to create a record, and none of them wanted the email.

The tell is when people start passing flags around to suppress behaviour — a skip_notifications attribute, a thread-local, a global toggle in the test suite. That flag is the codebase telling you the coupling is in the wrong place.

Where the logic wants to live

The operation that means "a user signed up" is not the same as the operation that means "a row exists". Signing up involves creating a record *and* sending an email *and* perhaps provisioning something. That is a use case, and it deserves a name.

Move it into an object or method that represents the actual operation. Creating a record stays boring, and every caller opts into the side effects deliberately.

If your test suite needs a flag to stop a callback, the callback is describing an operation, not a record.

Which callbacks are still fine

Not all of them are a trap. Callbacks that keep the record internally consistent are exactly right: normalising an email to lowercase, generating a slug, computing a derived column. These are properties of the record being valid, and you *want* them on every path.

The test is whether the callback touches anything outside the record. Email, HTTP calls, job enqueues, writes to other tables — those are the operation's business, not the row's.

Bulk operations skip them entirely

The mirror-image trap. insert_all and upsert_all write straight to the database and run no callbacks and no validations at all.

That is the point — it is what makes them fast — but if your slug generation or your normalisation lives in a callback, bulk-inserted rows silently arrive without it. You end up with two classes of record in the same table, and the difference only surfaces months later.

Knowing which behaviours are enforced at the database level and which merely by Ruby is the difference. A NOT NULL constraint holds no matter how the row arrived. A before_save does not.

The order problem

When several callbacks exist, they run in declaration order, and after_save runs inside the transaction. An email or job enqueued there fires before the transaction commits — so if it rolls back, you have sent an email about a record that does not exist, or enqueued a job that will look one up and fail.

after_commit exists for exactly this and is the correct hook for anything with an effect outside the database. If you take one thing from this: side effects belong after the commit, not after the save.

All writing Reply by email