Skip to content

CONCEPT Cited by 1 source

Destroy vs delete in ActiveRecord

destroy and delete are ActiveRecord's two row-removal primitives on ActiveRecord::Base (single record) and ActiveRecord::Relation (collection). They differ on a single axis: callback invocation. destroy invokes the full callback chain (validations, before_destroy, after_destroy, dependent cascades); delete skips callbacks and issues a raw SQL DELETE. The collection analogues destroy_all and delete_all apply the same split to bulk ops.

The API matrix

Callbacks Cascades dependent: Underlying SQL
record.destroy yes yes per-row DELETE inside transaction
record.delete no no single DELETE WHERE id = ?
relation.destroy_all yes (per record) yes N×(per-row DELETE) inside transaction
relation.delete_all no no single DELETE WHERE …

When to use each

From sources/2026-04-21-planetscale-ruby-on-rails-3-tips-for-deleting-data-at-scale:

"If you have callbacks setup, then you'll generally want to always use destroy so that they are called. It's important though to be aware of all the activity that could be caused by those callbacks, especially when destroying a large number of records. For example, a cron job for cleaning up old data would be better suited for using delete_all to skip callbacks."

Canonical rule: callbacks are correctness-critical in user-facing flows; overhead in maintenance jobs.

  • User-facing deletion: destroy / destroy_all. The callbacks exist because the application needs them — counter_cache updates, audit logs, dependent cascades, touch propagation, cleanup of associated external state. Skipping them breaks the domain model.
  • Maintenance cleanup: delete_all. A cron- scheduled DeleteOldDataJob that prunes rows older than 3 months by definition doesn't need the callbacks — the rows are past their utility window and nobody is listening for their destruction events. Running 10,000 callback chains for 10,000 cleanup deletes is pure overhead.

Performance delta

For N records:

  • destroy_all: N separate DELETE statements, each preceded by the full object-instantiation + callback chain. Memory proportional to N (all records instantiated), CPU dominated by callback execution per record.
  • delete_all: one DELETE ... WHERE ... statement. No objects instantiated, no callbacks.

At 10,000 records, the gap is 10,000×-Ruby-round-trip vs 1×-DB-round-trip — orders of magnitude faster.

The silent-skip footgun

The cost of skipping callbacks is silent. No warning, no log entry — delete_all just does what it says. If the application relies on after_destroy side effects (audit log entries, cache invalidation, queue notifications, dependent cascade), those side effects don't happen.

Common regression: a maintenance cleanup job uses delete_all because "we don't need the callbacks here" — later, a developer adds a counter_cache on an associated model that relies on after_destroy to decrement. The counter drifts silently over months. The job's delete_all call is the root cause but nothing about the cleanup job points to counter staleness.

Mitigation: document explicitly at the call site which callbacks are being skipped and why. delete_all is appropriate for stable, well-understood maintenance paths where the full side-effect profile is known; use destroy_all anywhere a future callback might matter.

Relationship to dependent: options

The dependent: option on associations composes with these primitives at the cascade layer:

  • dependent: :destroy — parent's destroy triggers children's destroy (callbacks invoked).
  • dependent: :delete_all — parent's destroy triggers a raw DELETE on children (callbacks skipped).
  • dependent: :destroy_async — parent's destroy enqueues an ActiveJob that calls children's destroy later (async-cascade).

So dependent: :delete_all is the cascade-level equivalent of the delete_all instance choice: fast, callback-skipping, useful when children don't need callback processing on parent-delete.

Why the delete/destroy split exists at all

Rails could have unified the API at the slower destroy end (always invoke callbacks) or the faster delete end (never invoke callbacks). Instead it exposes both as a deliberate operator choice. The split forces developers to think about callback cost explicitly at deletion points, which — when used correctly — keeps maintenance code fast and user-facing code correct.

Canonical worked example

The batched deletion pattern from the source post uses delete_all:

deleted = Model.where("created_at < ?", 3.months.ago)
                .limit(limit)
                .delete_all

Chosen because: (a) the cron cleanup doesn't need callbacks, (b) it translates to a single SQL DELETE ... WHERE ... LIMIT ? statement which MySQL executes without per-row callback overhead, (c) it returns the deleted row count directly, which drives the self-requeue decision (if deleted == limit).

Using destroy_all here would be an anti-pattern — 10× to 100× slower, same end state.

Seen in

Last updated · 470 distilled / 1,213 read