skip to content

In Active Record, how do destroy and delete differ, and when would you use destroy_all versus delete_all to purge listings?

level: middleimportance: should knowfreq 70%

answer

  1. callbacks and dependent: run or not
  2. destroy returns frozen self or false
  3. destroy! raises RecordNotDestroyed
  4. destroy_all: one DELETE per row
  5. delete_all: one statement, a count

basics

~20 s

destroy loads the record's callbacks and dependent: associations before deleting and can be stopped; delete sends one DELETE with no callbacks. destroy_all destroys each loaded record; delete_all issues a single DELETE and returns the row count.

solid answer

~40 s

`listing.destroy` runs `before_destroy`/`after_destroy` and the commit callbacks, honours `dependent:` options on its associations, and returns the frozen record, or `false` if a `before_destroy` throws `:abort`; `destroy!` raises `ActiveRecord::RecordNotDestroyed` instead. `listing.delete` sends `DELETE ... WHERE id = ?` and nothing else: no callbacks, no `dependent:` handling, and it even removes readonly records. On relations, `Listing.where(...).destroy_all` loads every matching row and calls `destroy` on each, so it is at least one DELETE per row and returns the destroyed records; `delete_all` builds one DELETE and returns the number of rows. Use `destroy`/`destroy_all` when the model has cleanup rules (attached photos, child rows, cache purges); use `delete_all` for large purges of rows with no such rules, or when the database's foreign keys handle the cascade.

code

ruby · 10 lines
ruby
class Listing < ApplicationRecord
  has_many_attached :photos            # purge enqueued when the listing is destroyed
  has_many :saved_listings, dependent: :delete_all
  before_destroy :ensure_not_under_dispute
end

listing.destroy  # callbacks + cascades; false if the before_destroy aborts
listing.delete   # one DELETE; photos and saved_listings rows left to the database

Listing.where(deleted_by_seller_at: ...1.year.ago).delete_all # => 18_402

go deeper

for a junior

Recall that destroy runs callbacks and dependent: options while delete sends one DELETE and skips them.

for a middle

Explain the return values, destroy! raising RecordNotDestroyed, and why destroy_all loads every row while delete_all returns a count.

for a senior

Choose the purge strategy from the model's cleanup rules and the database's foreign keys, and move large destroys out of the request.

for a principal

Decide where cascade rules live, in model callbacks or in database constraints, so bulk deletes cannot bypass them silently.

## Two ways to remove a row The classified-ads site purges listings that sellers deleted long ago, and lets sellers remove a single listing. Active Record gives two removal paths with very different costs: | | `destroy` / `destroy_all` | `delete` / `delete_all` | |---|---|---| | Loads records | yes (`destroy_all` loads them all) | no (single record already in memory, or none) | | `before_destroy` / `after_destroy` | run | skipped | | `after_commit` on destroy | run | skipped | | `dependent:` on associations | honoured | ignored | | Can be stopped | yes, by `throw :abort` | no | | SQL | one DELETE per record, plus cascades | one DELETE | | Returns | frozen record / `false`; array for `destroy_all` | frozen record; row count for `delete_all` | ## destroy and destroy! `listing.destroy`: 1. raises `ActiveRecord::ReadOnlyRecord` if the record is readonly; 2. handles associations declared with `dependent:` (destroying, deleting or nullifying children); 3. runs `before_destroy`, sends the DELETE, runs `after_destroy`, and later the commit callbacks; 4. marks the object `destroyed?` and **freezes** it, so further assignment raises a `FrozenError`. If a `before_destroy` callback throws `:abort`, `destroy` returns `false` and nothing is deleted; `destroy!` raises `ActiveRecord::RecordNotDestroyed` in the same case. A seller-facing "remove listing" action should use `destroy` (or `destroy!`) so that photo attachments, saved-search notifications and cache entries are cleaned up by the model's own rules. ## delete `listing.delete` sends `DELETE FROM listings WHERE id = ?` and freezes the object. No callbacks, no `dependent:` handling, and, unlike `destroy`, it does not refuse readonly records. Child rows are left behind unless the database itself cascades through a foreign key with `ON DELETE CASCADE`, or a foreign-key constraint rejects the delete. ## destroy_all and delete_all `Listing.where(deleted_by_seller_at: ...1.year.ago).destroy_all`: - loads **every** matching record into memory; - calls `destroy` on each, so each runs its callbacks and cascades; - returns the array of destroyed (frozen) records. For a hundred rows that is fine; for two million it is slow and memory-hungry. `delete_all` on the same relation: - builds **one** `DELETE ... WHERE deleted_by_seller_at < ?`; - returns the **number of rows** deleted; - raises `ActiveRecord::ActiveRecordError` when the relation uses `distinct` or a CTE (`with`), which a DELETE cannot express. `destroy_by(conditions)` and `delete_by(conditions)` are shorthands for `where(conditions).destroy_all` and `where(conditions).delete_all`. ## Choosing for a purge 1. **Does the model have destroy callbacks or `dependent:` associations that matter?** If yes, `delete_all` would orphan or skip them; use `destroy` semantics, processed in batches by a background job (batching belongs to the batches topic). 2. **Are the rules enforced by the database?** If child tables reference listings with `ON DELETE CASCADE`, `delete_all` is safe and fast. 3. **Is it a soft delete?** Archiving often means `update_all(archived_at: Time.current)` rather than removing rows at all. ## After the delete A destroyed or deleted object is frozen: `listing.title = "x"` raises `FrozenError`, and `listing.destroyed?` returns `true`. Other copies of the same row already in memory do not know it is gone; reloading one raises `ActiveRecord::RecordNotFound`. ## Soft delete versus hard delete Many sites never remove listings outright. A **soft delete** marks the row instead: - `listing.update(archived_at: Time.current)` keeps the row for reporting, disputes and undo; - default queries exclude archived rows through a scope; - a later purge job hard-deletes rows archived long ago. The destroy-versus-delete choice then moves to that purge job, where the same questions apply: which callbacks and `dependent:` rules must run, and can the database's foreign keys carry the cascade instead. Interviewers like candidates who ask "do we need the row afterwards?" before choosing any of these calls.

  • A before_destroy callback throws :abort. What do destroy and destroy! return or raise?
    `destroy` returns `false` and deletes nothing; `destroy!` raises `ActiveRecord::RecordNotDestroyed`. The record stays persisted and is not frozen.
  • Why can delete_all on listings fail even though destroy_all on the same relation works?
    If child tables have foreign keys to listings without `ON DELETE CASCADE`, the single DELETE violates them and the database raises. `destroy_all` works because each listing's `dependent:` options remove or nullify the children first.

destroy is checking a tenant out properly: the inventory, the key return and the forwarding address all happen before the room is cleared; delete is clearing the room while nobody watches, which is fast but leaves any follow-up undone.

saying these in an interview costs you the question

  • Saying delete runs before_destroy and after_destroy but skips validations.
  • Expecting delete_all to honour dependent: :destroy on associations.
  • Running destroy_all over millions of rows in one web request.
  • Claiming destroy_all issues a single DELETE statement.
  • Expecting destroy to raise when a before_destroy callback aborts.