LaunchKit
← All posts
· 17 min read · by The LaunchKit team · 0 views

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.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.