<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ramanan</title>
    <description>The latest articles on DEV Community by Ramanan (@ramanan_90002f0f6a662d3e4).</description>
    <link>https://dev.to/ramanan_90002f0f6a662d3e4</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4202936%2F35f7ca05-6a0d-48f1-a6e3-c0a749e92235.png</url>
      <title>DEV Community: Ramanan</title>
      <link>https://dev.to/ramanan_90002f0f6a662d3e4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ramanan_90002f0f6a662d3e4"/>
    <language>en</language>
    <item>
      <title>bulk_destroyer: destroy_all that fires every callback, without one DELETE per row</title>
      <dc:creator>Ramanan</dc:creator>
      <pubDate>Fri, 23 Oct 2026 15:38:33 +0000</pubDate>
      <link>https://dev.to/ramanan_90002f0f6a662d3e4/bulkdestroyer-destroyall-that-fires-every-callback-without-one-delete-per-row-3979</link>
      <guid>https://dev.to/ramanan_90002f0f6a662d3e4/bulkdestroyer-destroyall-that-fires-every-callback-without-one-delete-per-row-3979</guid>
      <description>&lt;p&gt;A Rails gem that destroys records and their dependent: :destroy trees with set-based SQL while still running every destroy callback, including the children's.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;delete_all&lt;/code&gt; is fast but skips callbacks. &lt;code&gt;destroy_all&lt;/code&gt; runs every callback but loads each record and deletes it with its own query, and does the same for every child. On a tree with a few thousand parents and tens of thousands of children, that is tens of thousands of queries.&lt;/p&gt;

&lt;p&gt;I wanted both: every &lt;code&gt;before_destroy&lt;/code&gt;, &lt;code&gt;around_destroy&lt;/code&gt; and &lt;code&gt;after_destroy&lt;/code&gt; hook, on every record in the tree, with bulk SQL underneath. So I built &lt;strong&gt;&lt;a href="https://github.com/rneelakantan/bulk_destroyer" rel="noopener noreferrer"&gt;bulk_destroyer&lt;/a&gt;&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;author_id: &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bulk_destroy_all&lt;/span&gt;
&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bulk_destroy!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;p&gt;Per batch, it issues one SELECT per association per level and one DELETE per table per level. Every record's destroy callbacks still run, children's included.&lt;/p&gt;

&lt;p&gt;Benchmark (Rails 8.1.3.1, same-machine database, single runs; each post has 5 comments plus likes and other dependents):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Posts / rows&lt;/th&gt;
&lt;th&gt;&lt;code&gt;destroy_all&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;bulk_destroy_all&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Queries&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;1,000 / 17,003&lt;/td&gt;
&lt;td&gt;9.44s&lt;/td&gt;
&lt;td&gt;1.34s&lt;/td&gt;
&lt;td&gt;32,001 → 45&lt;/td&gt;
&lt;td&gt;7.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;5,000 / 85,003&lt;/td&gt;
&lt;td&gt;58.33s&lt;/td&gt;
&lt;td&gt;6.69s&lt;/td&gt;
&lt;td&gt;160,001 → 221&lt;/td&gt;
&lt;td&gt;8.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL 16&lt;/td&gt;
&lt;td&gt;1,000 / 17,003&lt;/td&gt;
&lt;td&gt;19.73s&lt;/td&gt;
&lt;td&gt;1.58s&lt;/td&gt;
&lt;td&gt;32,001 → 45&lt;/td&gt;
&lt;td&gt;12.5x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The hook calls are identical: 84,000 for the 1,000-post case in both. The savings are in queries, not in skipped work. With a network between your app and the database the gap should be larger, since every removed query is a removed round trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tricky part: &lt;code&gt;after_destroy&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Rails runs a record's callbacks as one call, with the DELETE in the middle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;run_callbacks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:destroy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="c1"&gt;# the DELETE happens here&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;before_*&lt;/code&gt; runs before the block and &lt;code&gt;after_*&lt;/code&gt; runs after it. In a bulk destroy there is no per-record DELETE, so each record's chain has to be split: run the before hooks, wait while one DELETE removes everyone, then run the after hooks.&lt;/p&gt;

&lt;p&gt;Plain Ruby can't suspend a method in the middle, but a Fiber can. Each record's &lt;code&gt;run_callbacks(:destroy)&lt;/code&gt; runs in its own Fiber, and the block passed to it calls &lt;code&gt;Fiber.yield&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;start:&lt;/strong&gt; resume each Fiber. Before hooks run, then it pauses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bulk DELETE:&lt;/strong&gt; one statement per table covers the level while the Fibers wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;finish:&lt;/strong&gt; resume each Fiber. After hooks run, now genuinely after the row is gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is not concurrency. Only one Fiber runs at a time and nothing is parallel. Fibers are used as a pause button so Rails' own callback machinery (ordering, conditions, &lt;code&gt;throw :abort&lt;/code&gt;, &lt;code&gt;around_destroy&lt;/code&gt;) runs unmodified.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is supported
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dependent: :destroy&lt;/code&gt;, &lt;code&gt;:delete_all&lt;/code&gt;, &lt;code&gt;:nullify&lt;/code&gt;, &lt;code&gt;:restrict_with_error&lt;/code&gt;, &lt;code&gt;:restrict_with_exception&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;polymorphic associations&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;has_many :through&lt;/code&gt; (join rows only, like Rails) and HABTM&lt;/li&gt;
&lt;li&gt;&lt;code&gt;belongs_to ..., dependent: :destroy / :delete&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;counter caches&lt;/li&gt;
&lt;li&gt;commit callbacks (&lt;code&gt;after_commit on: :destroy&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each batch runs in a &lt;code&gt;transaction(requires_new: true)&lt;/code&gt;: a real transaction on its own, a savepoint inside yours. If a destroy callback raises &lt;code&gt;ActiveRecord::Rollback&lt;/code&gt;, which Rails' &lt;code&gt;transaction&lt;/code&gt; would otherwise swallow silently, you get &lt;code&gt;ActiveRecord::RecordNotDestroyed&lt;/code&gt; instead of a quiet skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fiber ceiling.&lt;/strong&gt; Each record holds one paused Fiber until its level is deleted. Ruby on Linux fails at roughly 31,700 live Fibers (two memory mappings per Fiber and the default &lt;code&gt;vm.max_map_count&lt;/code&gt;). Batches are therefore capped by record count (&lt;code&gt;max_batch_records&lt;/code&gt;, default 10,000) and split in half recursively when a tree is bigger. A single root whose own tree exceeds the cap raises &lt;code&gt;BulkDestroyer::TreeTooLarge&lt;/code&gt; before anything runs, unless you set &lt;code&gt;oversized_tree = :inline&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Splitting isn't atomic across parts.&lt;/strong&gt; Each part is its own transaction. Wrap the call in your own transaction if you need all-or-nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hook_mode = :inline&lt;/code&gt;&lt;/strong&gt; skips Fibers entirely, but then &lt;code&gt;after_destroy&lt;/code&gt; runs before the DELETE rather than after.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dependent: :destroy_async&lt;/code&gt; is not supported.&lt;/li&gt;
&lt;li&gt;Rails 7.0 is not supported. Tested on Rails 7.1 through 8.2 (alpha) with SQLite and PostgreSQL; the code uses portable ActiveRecord APIs and adapter-aware IN-list limits, so other adapters should work, but I haven't tested them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"bulk_destroyer"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source, README and benchmarks: &lt;a href="https://github.com/rneelakantan/bulk_destroyer" rel="noopener noreferrer"&gt;https://github.com/rneelakantan/bulk_destroyer&lt;/a&gt;. Issues and PRs welcome, especially results from MySQL/MariaDB and other Ruby versions.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>activerecord</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
