Deleting a record in Rails
The question is one line of Ruby and the answers differ by an order of magnitude in what they send
to the database and by everything in what they leave behind. record.destroy and record.delete
both remove the row. One of them also runs your callbacks, deletes the children, maintains the
counter caches and can be stopped by one line in a callback; the other issues a single DELETE and
returns. Choosing wrong is not a style question, it is the difference between a comments table with
no orphans in it and one that has been quietly keeping them.
Everything below was run on activerecord 8.1.3.1, Ruby 4.0.5 and PostgreSQL 17.7 (Homebrew), against
a scratch database on localhost:15432, on an Apple M2 Max. The SQL is copied out of an
ActiveSupport::Notifications.subscribe("sql.active_record") subscriber that keeps everything whose
:name is not SCHEMA, so the BEGIN and COMMIT lines are real statements the adapter sent and
not decoration. The claims are also a Minitest file, 14 tests and 64 assertions, which is the only
reason I trust the ones I did not personally stare at.
destroy and delete, printed side by side
Two records, one destroyed and one deleted, with the subscriber printing every statement:
class Post < ActiveRecord::Base
belongs_to :author, counter_cache: true, optional: true
has_many :comments
before_destroy { $log << "before_destroy" }
after_destroy { $log << "after_destroy" }
after_commit(on: :destroy) { $log << "after_destroy_commit" }
end
=== record.destroy ===
BEGIN
DELETE FROM "posts" WHERE "posts"."id" = $1
UPDATE "authors" SET "posts_count" = COALESCE("authors"."posts_count", 0) - $1 WHERE "authors"."id" = $2
COMMIT
callbacks: ["before_destroy", "after_destroy", "after_destroy_commit"]
return: Post (the record) frozen? true destroyed? true persisted? false id still 1
=== record.delete ===
DELETE FROM "posts" WHERE "posts"."id" = $1
callbacks: []
return: #<Post id: 2, title: "two", author_id: 1 frozen? true destroyed? true
destroy opens a transaction because it has more than one statement to run and they have to arrive
together: the row, the counter cache, and anything your own callbacks touch. delete sends the
DELETE on its own with no BEGIN around it, which is not a missing safety feature, it is the
honest shape of a single statement.
The part that surprises people is the last line of each block. delete freezes the object and sets
destroyed? to true, exactly like destroy. There is no in-memory tell. If you are reading a
destroyed? in a log or a test to work out which one ran, it cannot tell you, and neither can
frozen? or persisted?. The id survives both, which is what makes after_destroy_commit able to
bust a cache key.
destroy returns the record. delete returns the record too. Neither returns a count, which is
worth knowing because the class-level versions do not agree with that and are covered further down.
delete leaves the counter cache pointing at nothing
The UPDATE "authors" SET "posts_count" = ... in the destroy block above is the whole of the
counter cache mechanism, and delete does not send it. In the same run: after destroying one post
and deleting the other, a.reload.posts_count was 1 and Post.count was 0.
Nothing repairs that on its own. The column is now a number that was once true. The general version
of this problem, including the update_all that moves a foreign key without touching either
counter, is in counter caches by hand.
Deleting a record that has children
dependent: is declared on the parent and is a before_destroy callback, which is why none of it
runs when you call delete. Here is what each option actually sent when a post with three comments
was destroyed, one option per process so the callbacks could not pile up:
--- dependent: destroy (127.3 ms) ---
BEGIN
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1
DELETE FROM "comments" WHERE "comments"."id" = $1
DELETE FROM "comments" WHERE "comments"."id" = $1
DELETE FROM "comments" WHERE "comments"."id" = $1
DELETE FROM "posts" WHERE "posts"."id" = $1
COMMIT
comments left: 0 posts left: 0
--- dependent: delete_all (8.9 ms) ---
BEGIN
DELETE FROM "comments" WHERE "comments"."post_id" = $1
DELETE FROM "posts" WHERE "posts"."id" = $1
COMMIT
comments left: 0 posts left: 0
--- dependent: nullify (17.7 ms) ---
BEGIN
UPDATE "comments" SET "post_id" = $1 WHERE "comments"."post_id" = $2
DELETE FROM "posts" WHERE "posts"."id" = $1
COMMIT
comments left: 3 posts left: 0
--- dependent: restrict_with_error (17.2 ms) ---
BEGIN
SELECT 1 AS one FROM "comments" WHERE "comments"."post_id" = $1 LIMIT $2
ROLLBACK
comments left: 3 posts left: 1
:destroy loads every child into Ruby and calls destroy on each, which is the only option that
runs the children's own callbacks and the children's own dependent:. That is the reason to pay for
it and the reason it costs one DELETE per row.
The milliseconds in those headers are a fresh process each time, warmup included, so they are the cost of a run and not a benchmark. The benchmark is the next section.
:delete_all is a single statement and skips the children's callbacks entirely, which leaves the
grandchildren behind. A post, one comment, two likes, and Like hanging off
has_many :likes, dependent: :destroy on Comment:
dependent: destroy -> posts 0, comments 0, likes 0
dependent: delete_all -> posts 0, comments 0, likes 2
Two likes now belong to a comment that does not exist. Anything else built on those callbacks is gone the same way, including an audit trail: the row-trigger version of that argument is in a Rails audit log.
:restrict_with_error made destroy return false and put a message on the parent:
destroy returned false
errors.full_messages: ["Cannot delete record because dependent comments exist"]
error details: {base: [{error: :"restrict_dependent_destroy.has_many", record: "comments"}]}
:restrict_with_exception sends the identical SELECT 1 and ROLLBACK and raises
ActiveRecord::DeleteRestrictionError: Cannot delete record because of dependent comments instead.
Note the two messages are not the same sentence, which matters if you are matching on one.
What the foreign key buys, measured
The Ruby loop is the expensive option and it is easy to say so without a number. One post, 5000 comments, destroyed three times each way:
trial: cascade 3.0 ms (3 statements) dependent: :destroy 539.2 ms (5004 statements)
trial: cascade 1.6 ms (3 statements) dependent: :destroy 515.0 ms (5004 statements)
trial: cascade 2.0 ms (3 statements) dependent: :destroy 497.6 ms (5004 statements)
cascade [3.0, 1.6, 2.0] median 2.0
in ruby [539.2, 515.0, 497.6] median 515.0
The cascade side is add_foreign_key :comments, :posts, on_delete: :cascade and no dependent:
option at all. Three statements: BEGIN, the parent DELETE, COMMIT. Postgres removes the 5000
children inside the same transaction and Active Record never hears about it. The Ruby side is 5004
statements for the same outcome, a median 515.0 ms against 2.0 ms, on a laptop that was busy with
other work at the time, which is why the trials are listed and not averaged.
The cost of the fast one is stated plainly in what it skips: no before_destroy on a comment, no
after_destroy_commit to bust a cache key, no audit row, no Active Storage purge. If a child model
does real work on the way out, the cascade will not do it and nothing will tell you.
destroy_all is not one transaction
destroy_all is the claim in this post I would most expect an experienced reader to argue with, so
here is the statement count. 1000 rows, no callbacks, no associations:
destroy_all over 1000 rows: 3001 statements; first three: ["SELECT \"posts\".* FROM \"posts\"", "BEGIN", "DELETE FROM \"posts\" WHERE \"posts\".\"id\" = $1"]; last: "COMMIT"
delete_all over 1000 rows: 1 statements; ["DELETE FROM \"posts\""]
3001 is one SELECT plus a BEGIN, a DELETE and a COMMIT for every row. Each record gets its
own transaction, so destroy_all is not atomic and a failure in the middle is a partial delete that
stays partial:
post = model(:P5, "posts") { before_destroy { raise "boom" if title == "t2" } }
5.times { |i| post.create!(title: "t#{i}") }
assert_raises(RuntimeError) { post.order(:id).destroy_all }
assert_equal %w[t2 t3 t4], post.order(:id).pluck(:title)
That test passes. model(:P5, "posts") is a helper in the test file that defines a uniquely named
class on the posts table, so that a dependent: or a callback declared in one test cannot follow
the class into the next one. t0 and t1 are gone, the exception came out of row three, and rows
three to five are untouched. If you want all or nothing you have to write the transaction do
yourself, and then you are holding a row lock on every matched row for the length of the loop, which
is its own problem on a live table.
Deleting a lot of rows
delete_all is one statement and does not care how many rows it matches, which is the fast answer
and also the one that takes a lock on every matched row until it commits. For a large table the
version I ship is in_batches:
Post.in_batches(of: 1_000).delete_all
Over 2500 rows that sent 7 statements, alternating a key-range SELECT with a bounded DELETE:
SELECT "posts"."id" FROM "posts" ORDER BY "posts"."id" ASC LIMIT $1 OFFSET $2
DELETE FROM "posts" WHERE "posts"."id" <= $1
SELECT "posts"."id" FROM "posts" WHERE "posts"."id" > $1 ORDER BY "posts"."id" ASC LIMIT $2 OFFSET $3
limit works on delete_all too, and it compiles to a subquery rather than a LIMIT on the
DELETE, because DELETE FROM posts LIMIT 1; in psql answers ERROR: syntax error at or near
"LIMIT":
DELETE FROM "posts" WHERE ("posts"."id") IN (SELECT "posts"."id" FROM "posts" ORDER BY "posts"."id" ASC LIMIT $1)
The abort path, and why the scaffold raises
A before_destroy that throws :abort stops the delete. What it sends to the database is nothing:
destroy returned false; SQL sent: []
Not a BEGIN, not a ROLLBACK. Rails opens the transaction lazily and the abort happens before any
statement needs a transaction to be in, so Postgres never hears from it. Compare the
:restrict_with_error block above, which does send BEGIN and ROLLBACK, because its check is a
SELECT and running it materialised the transaction.
destroy! on the same record raises ActiveRecord::RecordNotDestroyed: Failed to destroy Post with
id=1. That matters more than it looks, because the Rails 8 scaffold generates the bang version:
railties-8.1.3.1/lib/rails/generators/rails/scaffold_controller/templates/controller.rb.tt:46 is
@post.destroy! followed by redirect_to posts_path, ..., status: :see_other. So a
before_destroy guard added later turns a scaffolded destroy action into a 500 rather than a
redirect, and the fix is to call destroy and branch on the return value, not to remove the guard.
delete ignores the abort completely, because it never runs the callback that would have thrown.
When the row is already gone, or the delete gets rolled back
Destroying a row another connection has already deleted raises nothing:
=== destroying a row another connection already deleted ===
destroy returned: #<Post id: 3, title: "four", a (no exception)
Rails does not check the affected row count on a plain destroy. Add a lock_version column and the
same sequence raises ActiveRecord::StaleObjectError: Attempted to destroy a stale object: Post.
with attempted_action of "destroy", and the after_commit(on: :destroy) callback does not fire.
The rest of that mechanism is in
optimistic locking in Rails.
A destroy inside a transaction that rolls back puts the object back:
inside the transaction: destroyed? true frozen? true
after the rollback: row exists? true object destroyed? false frozen? false
The freeze is undone and the record is writable again, which is Active Record restoring the
in-memory state it saved before the transaction. What it cannot restore is anything an
after_destroy did outside the database, which is the argument for
after_commit over after_destroy.
dependent: :destroy_async has a foreign key sized hole in it
:destroy_async deletes the parent now and enqueues ActiveRecord::DestroyAssociationAsyncJob to
remove the children later, which is the obvious answer to the 515 ms above. On a table with a
foreign key it does not work:
ActiveRecord::InvalidForeignKey: PG::ForeignKeyViolation: ERROR: update or delete on table "posts" violates foreign key constraint "fk_rails_2fd19c0db7" on table "comments" | DETAIL: Key (id)=(1) is still referenced from table "comments".
["BEGIN", "SELECT \"comments\".* FROM \"comments\" WHERE \"comments\".\"post_id\" = $1", "DELETE FROM \"posts\" WHERE \"posts\".\"id\" = $1", "ROLLBACK"]
enqueued: []
posts 1, comments 2
Drop the foreign key and the same script prints destroy ok, enqueued:
["ActiveRecord::DestroyAssociationAsyncJob"], posts 0, comments 2. The parent is gone and the two
children are still there, pointing at nothing, until a worker picks the job up. That window is the
feature, not a bug, and it is the reason the option is a poor fit for anything a user can see.
The warning is in Rails' own source rather than only in the guides:
activerecord-8.1.3.1/lib/active_record/associations.rb:1511 reads "WARNING: Do not use this
option if the association is backed by foreign key constraints in your database."
Deleting from the database is not the same as deleting the row you meant
Three things go wrong here and they all look like Active Record misbehaving.
Model.destroy(id) loads the record and raises ActiveRecord::RecordNotFound: Couldn't find Post
with 'id'=999999 when there is none. Model.delete(id) sends the DELETE blind and returns 0.
delete_by returns a count, destroy_by returns an array of the destroyed records, and
destroy_all returns an array while delete_all returns an integer. Four methods, three return
types.
Deleting a parent whose children are protected by a foreign key raises, and the error is worth reading in full once:
class: ActiveRecord::InvalidForeignKey
message: PG::ForeignKeyViolation: ERROR: update or delete on table "posts" violates foreign key constraint "fk_rails_2fd19c0db7" on table "comments" | DETAIL: Key (id)=(1) is still referenced from table "comments".
cause: PG::ForeignKeyViolation
post still there? true comments 1
callbacks ran: ["before_destroy"]
frozen? false destroyed? false
before_destroy ran, the transaction rolled back, and the object is not frozen, so you can retry it.
And with no foreign key in the schema, the same p1.delete succeeds, Post.count is 0, and
Comment.where(post_id: p1.id).count is still 1. Reading the leftover comment back:
posts 0, comments 1
comment.post_id = 1, comment.post = nil
comment.post.title -> NoMethodError: undefined method 'title' for nil
The post_id still holds 1, there is no row 1, and the page that renders comment.post.title is
broken for exactly as long as it takes somebody to notice. A polymorphic association cannot carry a
foreign key at all, which is why dependent: :destroy is the entire integrity story there: see
polymorphic associations.
The delete button that quietly does a GET
The model layer is the half that has depth. The view layer has one trap and it catches everyone at least once. These two lines look interchangeable and are not:
view.button_to("Destroy this post", "/posts/1", method: :delete)
view.link_to("Destroy this post", "/posts/1", method: :delete)
They render, in actionview 8.1.3.1:
<form class="button_to" method="post" action="/posts/1"><input type="hidden" name="_method" value="delete" autocomplete="off" /><input type="submit" value="Destroy this post" /></form>
<a rel="nofollow" data-method="delete" href="/posts/1">Destroy this post</a>
button_to produces a real POST form carrying _method=delete, which Rack::MethodOverride
turns back into a DELETE before routing. link_to method: :delete produces data-method, which
is the rails-ujs attribute and not the Turbo one. Grepping the shipped bundle,
turbo-rails-2.0.23/app/assets/javascripts/turbo.min.js, the link handler reads
e.hasAttribute("data-turbo-method") before it will convert a click into a form submission;
data-method appears in the file only inside the predicate that disables prefetching. So the click
is a plain GET, and:
assert_equal({ controller: "posts", action: "show", id: "1" },
Rails.application.routes.recognize_path("/posts/1", method: :get))
assert_equal({ controller: "posts", action: "destroy", id: "1" },
Rails.application.routes.recognize_path("/posts/1", method: :delete))
The delete link shows the record. That is the bug report, every time. Use button_to, which is what
the Rails 8 scaffold does at
railties-8.1.3.1/lib/rails/generators/erb/scaffold/templates/show.html.erb.tt:9, or write
data: { turbo_method: :delete } and accept that a GET from a crawler that ignores JavaScript is
now a live route to your destroy action.
Both paths answered the same way through a real request, one issued as DELETE and one as a POST
with _method=delete: 303 to http://example.org/posts, row gone. Rails.application.middleware
lists Rack::MethodOverride, which is the piece doing the second half of that. The 303 rather
than 302 is what the scaffold emits, and why a redirect after a non-GET has to be a 303 is a
browser question this page did not test: there is no 303 anywhere in turbo.min.js, so whatever
enforces it is not Turbo.
What I got wrong measuring this
My first comparison of dependent: :destroy against dependent: :delete_all redeclared the
association in one script: declare dependent: :destroy, destroy a post, redeclare the same
association as dependent: :delete_all, destroy another. The output had the child DELETE in it
twice and a comments_count going down by one and then back up by one, and I spent a while looking
for the bug in Active Record. The bug is in the script, and here it is on its own:
after the first has_many: 1 destroy callbacks
after redeclaring it: 2 destroy callbacks
BEGIN
DELETE FROM "comments" WHERE "comments"."post_id" = $1
DELETE FROM "comments" WHERE "comments"."post_id" = $1
DELETE FROM "posts" WHERE "posts"."id" = $1
COMMIT
has_many with a dependent: option installs a before_destroy callback and declaring the
association a second time installs another one. The first is never removed. In an application this
is invisible because nobody declares the same association twice, but it is a real consequence of
reopening a model in a console or a test, and every measurement in this post is therefore one option
per process.
The call, and what would change it
Put the foreign key in the schema, always, and let it be the thing that guarantees integrity.
dependent: is a Ruby callback on one code path; delete_all, update_all, a fixture reset, a
psql session and the reporting service that shares your database all go around it. The constraint
is the only rule that holds on all of them, and if it fires in development you have found a bug
rather than been inconvenienced by one.
Then choose dependent: for behaviour rather than for cleanup. :destroy when a child does work on
its way out, and accept 515 ms per 5000 children. :delete_all when it does not. on_delete:
:cascade with no dependent: at all when the children are rows and nothing else, which is the case
for join tables, and is the option I reach for first.
The cost of that position is that a cascade is invisible from Ruby: nothing fires, nothing logs, and
your audit table has a gap it cannot see. If your compliance story is callback-shaped, the cascade
is not available to you and the 515 ms is the price of the audit trail. What would change my mind on
the default is dependent: :destroy_async growing a foreign key aware mode, which would mean
deferring the parent delete rather than the children's, and nothing in Rails 8.1 does that.
What this page does not cover
Soft deletes. deleted_at, the discard gem and default_scope, which is a different subject with
a different failure mode: the row is still there and every uniqueness validation, foreign key and
count in the application has to know it. Active Storage, where has_one_attached already
defaults to dependent: :purge_later (activestorage-8.1.3.1/lib/active_storage/attached/model.rb:108)
and the blob outliving the record is its own subject. has_many :through, where a delete on the
association and a delete on the far side are different statements and I measured neither. ON DELETE
SET NULL and ON DELETE RESTRICT, both of which add_foreign_key accepts and neither of which I
ran. TRUNCATE, which empties a table without going through any of this and which I did not
measure. And MySQL and SQLite: every statement above came out of a PostgreSQL adapter and I ran
nothing against either of them.
Every number here came from one laptop and one Postgres. The scripts are short and the test file is 14 tests; point them at your own database before you quote the ratio.
Comments
No comments yet. Be the first.