Skip to main content

destroy_all vs delete_all in Rails: Performance, Callbacks, and When to Use Each

`destroy_all` loads each record into Ruby and runs callbacks. `delete_all` sends a single `DELETE` SQL statement and Rails never touches the records.

A
Raza Hussain
· 8 min read · 21

You’re cleaning up old records, the table has millions of rows, and Rails gives you two tempting options: destroy_all and delete_all. Pick the wrong one and you either melt your app or silently skip business logic you actually needed.

The One-Line Difference

destroy_all loads each record into Ruby and runs callbacks. delete_all sends a single DELETE SQL statement and Rails never touches the records.

That’s it. Every other difference flows from this.

# destroy_all: instantiates each record, fires before_destroy, after_destroy, dependent callbacks
Order.where(status: :abandoned).destroy_all

# delete_all: one SQL DELETE, no Ruby, no callbacks, no associations touched
Order.where(status: :abandoned).delete_all

The generated SQL for delete_all is exactly what you’d expect:

DELETE FROM "orders" WHERE "orders"."status" = 'abandoned'

destroy_all runs that same delete but only after instantiating every row, running validations and callbacks per record. On 500,000 rows, the difference between these two paths is not academic.

When destroy_all Is the Right Choice

Use destroy_all when Rails needs to do work beyond removing rows.

Dependent associations. If your model has has_many :line_items, dependent: :destroy, destroy_all cascades deletion through Ruby. delete_all skips the association entirely. Your line_items rows remain, orphaned, with a foreign key pointing at nothing.

class Order < ApplicationRecord
  has_many :line_items, dependent: :destroy
  has_many :payments, dependent: :destroy
end

# This respects dependent: :destroy on line_items and payments
Order.where(status: :abandoned).destroy_all

# This leaves line_items and payments rows in the database
Order.where(status: :abandoned).delete_all

Audit callbacks. If you track deletions via after_destroy for compliance, analytics, or an audit log, delete_all silently skips every one of those callbacks.

Soft-delete patterns. Libraries like Discard or Paranoia hook into destroy. delete_all bypasses the soft-delete entirely and hard-deletes the row. The record vanishes from your audit trail as if it never existed.

Counter caches. If a parent model uses counter_cache: true, destroy_all updates the counter. delete_all leaves the counter stale. You’ll be serving incorrect counts until you manually recalculate.

The rule: if any Ruby code needs to run as part of deletion, use destroy_all.

When delete_all Is the Right Choice

Use delete_all when you control the data, the associations are already clean, and you need to not destroy your database under load.

The performance gap is not a rounding error. On large sets, destroy_all can take 400+ seconds where delete_all finishes in under 10. The provided benchmark: destroy_all ~412s vs delete_all ~7s on the same dataset. That’s a 58x difference. At scale, destroy_all also holds row-level locks for each record, one at a time, for the full duration.

Good use cases for delete_all:

  • Cleanup jobs on temporary or cache tables with no meaningful callbacks
  • Removing test fixtures or seed data in a controlled script
  • Deleting join table records (e.g., user_roles) where the join row has no callbacks and no dependents
  • Purging log or event records where the only rule is “delete rows older than N days”
# Safe use of delete_all: join table with no callbacks
UserRole.where(role_id: deprecated_role.id).delete_all

# Safe use of delete_all: cleanup job on a log table
AuditLog.where("created_at < ?", 90.days.ago).delete_all

Before you use delete_all, verify two things: no callbacks that matter, and no dependent: :destroy associations that would leave orphans.

Hidden Trap: delete_all on an Association Collection

This one catches developers off guard.

# This does NOT call delete_all on the association scope the way you might expect
user.orders.delete_all

When you call delete_all on a has_many association, Rails checks the dependent option and may call destroy anyway on certain configurations. The behavior changed between Rails versions and depends on whether you set dependent: :delete_all explicitly on the association.

class User < ApplicationRecord
  has_many :orders, dependent: :destroy        # user.orders.delete_all still goes through destroy
  has_many :sessions, dependent: :delete_all   # user.sessions.delete_all sends raw SQL DELETE
end

If you want raw SQL deletion through an association, you need dependent: :delete_all on the association declaration. Otherwise Rails falls back to the dependent option you set, which may still fire callbacks.

If your deletion depends on Ruby code running, use destroy_all. If it only depends on the database removing rows, delete_all may be the right tool — but verify the association’s dependent option first.

The Middle Path: in_batches + delete_all for Large Tables

You need callbacks to run but the table has 2 million rows. destroy_all will time out or lock the table for minutes.

in_batches with destroy_all per batch is safer:

# Batch destroy with callbacks — safer on large tables
Order.where(status: :abandoned).in_batches(of: 500) do |batch|
  batch.destroy_all
  sleep(0.1) # back off between batches to reduce lock pressure
end

If you don’t need callbacks and just need to avoid a single massive DELETE that locks the table:

# Batch delete without callbacks — fastest safe option on large tables
AuditLog.where("created_at < ?", 90.days.ago).in_batches(of: 1000).delete_all

in_batches returns an ActiveRecord::Batches::BatchEnumerator. Calling .delete_all directly on it (without a block) issues batched DELETE statements. This is the right pattern for large cleanup jobs.

destroy_async: Background Deletion in Rails 6.1+

Rails 6.1 added destroy_async as a dependent option on associations. Instead of destroying associated records inline during the parent’s deletion, Rails enqueues an ActiveJob job to handle it.

class Order < ApplicationRecord
  has_many :line_items, dependent: :destroy_async
end

When you destroy an Order, the line_items deletion is deferred to a background job. This keeps your web request fast and avoids blocking. The tradeoff: there’s a window where the parent is deleted but the children still exist. If anything reads line_items in that window, it sees orphaned records.

Use destroy_async when:

  • The association has many records and inline deletion would block the request
  • You’re running ActiveJob with a reliable backend (Sidekiq, GoodJob)
  • A brief window of orphaned child records is acceptable in your domain

Do not use destroy_async when:

  • Your app queries line_items immediately after the parent is deleted and expects them gone
  • Your job backend is unreliable and failed jobs won’t be retried

Comparison Table

Feature destroy_all delete_all
SQL statements N+1 (one per record) 1
Callbacks fired Yes (before_destroy, after_destroy) No
Dependent associations Respected Ignored
Counter caches updated Yes No
Soft delete support Yes No
Performance on 500k rows ~400s ~7s
Safe for large tables No (without batching) Yes (with in_batches)

Decision Flowchart

Need to delete records?
├── Do callbacks need to run? (audits, soft delete, dependent: :destroy)
│   ├── Yes
│   │   ├── Large table (100k+ rows)? → in_batches { destroy_all }
│   │   └── Small set? → destroy_all
│   └── No
│       ├── Association with dependent: :destroy? → fix association first
│       ├── Large table? → in_batches.delete_all
│       └── Small set, no associations? → delete_all

What to Check Before Choosing

Before writing either method:

  • Check every has_many on the model for dependent: options
  • Check for before_destroy and after_destroy callbacks, including ones added by gems (Paranoia, PaperTrail, Audited, Discard)
  • Check for counter_cache on belongs_to associations
  • Check the record count — anything over 10,000 rows needs batching
  • Check if this runs in a web request or a background job

Final Thoughts

destroy_all is safe by default but slow. delete_all is fast but skips every safety mechanism Rails gives you. For anything touching production data, the decision comes down to whether callbacks and associations matter. When they do, batch your destroy_all. When they don’t, delete_all with in_batches handles large tables without the performance cliff.


Sources

Was this article helpful?

Your feedback helps us improve our content

Be the first to vote!

How We Verify Conversions

Every conversion shown on this site follows a strict verification process to ensure correctness:

  • Compare results on same dataset — We run both SQL and ActiveRecord against identical test data and verify results match
  • Check generated SQL with to_sql — We inspect the actual SQL Rails generates to catch semantic differences (INNER vs LEFT JOIN, WHERE vs ON, etc.)
  • Add regression tests for tricky cases — Edge cases like NOT EXISTS, anti-joins, and predicate placement are tested with multiple scenarios
  • Tested on Rails 8.1.1 — All conversions verified on current Rails version to ensure compatibility

Last updated: May 20, 2026

Try These Queries in Our Converter

See the SQL examples from this article converted to ActiveRecord—and compare the SQL Rails actually generates.

21

Leave a Response

Responses (0)

No responses yet

Be the first to share your thoughts

R

Raza Hussain

Full-stack developer specializing in Ruby on Rails, React, and modern JavaScript. 15+ years upgrading and maintaining production Rails apps. Led Rails 4/5 → 7 upgrades with 40% performance gains, migrated apps from Heroku to Render cutting costs by 35%, and built systems for StatusGator, CryptoZombies, and others. Available for Rails upgrades, performance work, and cloud migrations.

💼 15 years experience 📝 48 posts