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
destroyso 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 usingdelete_allto 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_cacheupdates, audit logs, dependent cascades, touch propagation, cleanup of associated external state. Skipping them breaks the domain model. - Maintenance cleanup:
delete_all. A cron- scheduledDeleteOldDataJobthat 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 separateDELETEstatements, 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: oneDELETE ... 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'sdestroytriggers children'sdestroy(callbacks invoked).dependent: :delete_all— parent'sdestroytriggers a rawDELETEon children (callbacks skipped).dependent: :destroy_async— parent'sdestroyenqueues an ActiveJob that calls children'sdestroylater (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:
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¶
- sources/2026-04-21-planetscale-ruby-on-rails-3-tips-for-deleting-data-at-scale —
canonical wiki introduction. Mike Coutermarsh
(PlanetScale, 2022-08-01) canonicalises the
user-facing-vs-maintenance-job rule and surfaces
delete_allas the canonical primitive for the batched-deletion pattern's inner body.