<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.fastruby.io/blog/rss.xml" rel="self" type="application/atom+xml" /><link href="https://www.fastruby.io/blog/" rel="alternate" type="text/html" /><updated>2026-09-23T14:53:14-04:00</updated><id>https://www.fastruby.io/blog/rss.xml</id><title type="html">The Rails Tech Debt Blog</title><subtitle>| FastRuby.io</subtitle><author><name>OmbuLabs</name></author><entry><title type="html">Herb on Rails</title><link href="https://www.fastruby.io/blog/herb-on-rails.html" rel="alternate" type="text/html" title="Herb on Rails" /><published>2026-09-22T05:00:00-04:00</published><updated>2026-09-22T05:00:00-04:00</updated><id>https://www.fastruby.io/blog/herb-on-rails</id><content type="html" xml:base="https://www.fastruby.io/blog/herb-on-rails.html"><![CDATA[<p>ERB does not know that it is generating HTML. It finds the <code class="language-plaintext highlighter-rouge">&lt;% %&gt;</code> tags, evaluates the Ruby inside them, and concatenates everything else as plain text. Whether the result is valid markup has never been its problem.</p>

<p>Herb changes that. On August 25, 2026, the Ruby on Rails core team merged <a href="https://github.com/rails/rails/pull/58552">Add Herb as an HTML-aware ERB implementation</a>, which brings in <a href="https://herb-tools.dev">Herb</a>, an ERB implementation that parses HTML and ERB into a single syntax tree and uses <a href="https://github.com/ruby/prism">Prism</a> for the Ruby inside the tags. Broken markup can now fail while the template compiles, instead of reaching a browser.</p>

<p>In this article, you will learn what Herb is, how to audit your own views with it today, what it can fix for you, what it cannot, and what it costs to run. We used the FastRuby.io views as the test subject.</p>

<!--more-->

<h2 id="what-herb-actually-is">What Herb actually is</h2>

<p>Herb is not one tool, it is a parser with a lot of things built on top of it. The parser is written in C, it handles HTML and ERB together, and it hands the Ruby inside the tags to Prism. Everything else in the project is a consumer of that one syntax tree. We have leaned on syntax trees for this kind of work before, when we <a href="https://www.fastruby.io/blog/extracting-rails-deprecation-warnings">extracted every deprecation warning out of the Rails source</a>, and the appeal is the same: a parser knows things a regular expression can only guess at.</p>

<p>That second half is easy to overlook. Every <code class="language-plaintext highlighter-rouge">&lt;% %&gt;</code> tag in your application gets parsed as Ruby, including the ones inside branches that almost never render. GitHub found invalid Ruby this way <a href="https://hawksley.org/2026/05/06/adopting-herb-at-github.html">while adopting Herb</a>, in template code they had never run outside of production. If your views have accumulated conditionals for states nobody has hit in years, that Ruby has probably never been parsed by anything.</p>

<p>The gem installs a CLI that exposes most of it:</p>

<table>
  <thead>
    <tr>
      <th>Command</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">herb analyze</code></td>
      <td>Parse and compile every template in a directory, then report</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">herb lint</code></td>
      <td>Rule-based linting, including autocorrection</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">herb format</code></td>
      <td>Reformat templates</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">herb playground</code></td>
      <td>Open a template’s parse tree in the browser playground</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">herb report</code></td>
      <td>Generate a bug report you can paste into an issue</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">herb diff</code></td>
      <td>Show the minimal set of syntax tree differences between files</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">herb dev</code></td>
      <td>Start a dev server that watches for changes</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">herb actionview check</code></td>
      <td>Check that <code class="language-plaintext highlighter-rouge">render</code> calls resolve to real partials</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">herb actionview graph</code></td>
      <td>Show the render dependency graph</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">herb lsp</code></td>
      <td>Start the language server for your editor</td>
    </tr>
  </tbody>
</table>

<p>Some of those delegate to Node packages under the hood, so the linter and formatter are the same code whether you reach them from Ruby or from <code class="language-plaintext highlighter-rouge">npx</code>.</p>

<p>That shared parser is the part worth understanding, because it is what makes the Rails change more than a swapped dependency. Your editor, your linter, your formatter, and now your template compiler can all agree on what a template means.</p>

<p><a href="https://herb-tools.dev/blog/whats-new-in-herb-v0-9">Herb v0.9</a> added a set of <code class="language-plaintext highlighter-rouge">erb-safety-*</code> rules that cover the security checks from <code class="language-plaintext highlighter-rouge">better-html</code> and <code class="language-plaintext highlighter-rouge">erb_lint</code>, which matters if you have been relying on either of those.</p>

<h2 id="running-herb-on-our-own-views">Running Herb on our own views</h2>

<p>Install the gem and point it at your views:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gem <span class="nb">install </span>herb
herb analyze app/views
</code></pre></div></div>

<p>It parses every template, tries to compile each one, and groups what it finds into sections. The ten possible sections are in the table below, and which ones you see depends on what is wrong with your templates:</p>

<table>
  <thead>
    <tr>
      <th>Seen</th>
      <th>Section</th>
      <th>What it means</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>✅</td>
      <td>Template errors</td>
      <td>Real problems in your markup</td>
    </tr>
    <tr>
      <td>✅</td>
      <td>Strict mode parse errors</td>
      <td>Valid but loose HTML, like omitted closing tags</td>
    </tr>
    <tr>
      <td>✅</td>
      <td>Analyze parse errors</td>
      <td>Problems found while analyzing the tree</td>
    </tr>
    <tr>
      <td>✅</td>
      <td>Validation errors</td>
      <td>Security, nesting, or accessibility issues; the template still compiles</td>
    </tr>
    <tr>
      <td>✅</td>
      <td>Unexpected parse errors</td>
      <td>Probably a parser bug</td>
    </tr>
    <tr>
      <td> </td>
      <td>Strict mode compilation errors</td>
      <td>Compiles only with strict mode off</td>
    </tr>
    <tr>
      <td> </td>
      <td>Parser crashed</td>
      <td>The parser itself blew up</td>
    </tr>
    <tr>
      <td> </td>
      <td>Timed out</td>
      <td>The file took too long to parse</td>
    </tr>
    <tr>
      <td> </td>
      <td>Compilation errors</td>
      <td>Could not be compiled to Ruby at all</td>
    </tr>
    <tr>
      <td> </td>
      <td>Invalid Ruby output</td>
      <td>The engine emitted Ruby that does not parse</td>
    </tr>
  </tbody>
</table>

<p>Our run against the FastRuby.io views identified the five marked in the first column, along with twenty-one templates containing issues. This is a reasonable outcome for an application whose views have been accumulating since 2017.</p>

<p>What caught my attention was the “Reportable issues” block Herb prints after those sections. It pulls out the files that landed in the categories it treats as probable bugs in itself, under a note saying they “likely failed due to issues in Herb, not in your templates”. Two of ours landed there, both from the unexpected parse errors row. That seems like a good way to make the Herb gem more robust over time, and the CLI hands you both halves of the job, one to share as an issue and the other to debug it yourself:</p>

<p><code class="language-plaintext highlighter-rouge">herb report</code> prints a Markdown bug report with the gem, Prism, and <code class="language-plaintext highlighter-rouge">libherb</code> versions, your Ruby and platform, the categorized error list, and the template source, ready to paste into an issue.</p>

<p><code class="language-plaintext highlighter-rouge">herb playground</code> compresses the template into the fragment of a URL and opens it in the <a href="https://herb-tools.dev/playground">playground</a>, where you can inspect the tree the parser built and the diagnostics it produced. Browsers do not send the fragment part of a URL to the web server, so the template travels no further than your own browser, which matters when the file belongs to a client.</p>

<p>As for the errors themselves, they are readable. Here is one of ours:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Analyze parse errors:
 These files have issues detected during analysis. Review the errors and update your templates.

 app/views/static/rails_3_0_vulnerabilities.html.erb:
   ⚠ MissingClosingTagError at 18:2 - Opening tag `&lt;section&gt;` at (18:3) doesn't have a matching closing tag `&lt;/section&gt;` in the same scope.
   ⚠ MissingOpeningTagError at 2131:2 - Found closing tag `&lt;/section&gt;` at (2131:4) without a matching opening tag in the same scope.
   ⚠ MissingOpeningTagError at 2132:0 - Found closing tag `&lt;/div&gt;` at (2132:2) without a matching opening tag in the same scope.
</code></pre></div></div>

<p>The run ends with a summary that counts how many files were checked, how many came out clean, and separate tallies for the parser and the engine. Those two tallies are worth reading separately, since “parses but will not compile” and “will not parse at all” are different problems with different fixes.</p>

<h2 id="what-you-can-fix-automatically">What you can fix automatically</h2>

<p><code class="language-plaintext highlighter-rouge">herb analyze</code> tells you what is broken. Fixing the smaller stuff is the linter’s job:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>herb lint <span class="s2">"app/views/**/*.erb"</span>
herb lint <span class="s2">"app/views/**/*.erb"</span> <span class="nt">--fix</span>
</code></pre></div></div>

<p>On our views that found 1,404 offenses, 547 of them autocorrectable: unescaped entities, indentation, spaces inside tags, missing trailing newlines. What <code class="language-plaintext highlighter-rouge">--fix</code> will not touch is anything structural. The 143 offenses from <code class="language-plaintext highlighter-rouge">parser-no-errors</code>, the rule that overlaps with what stops a template from compiling, are all yours to fix by hand, and that is the right call, since an extra <code class="language-plaintext highlighter-rouge">&lt;/div&gt;</code> could mean “delete this line” or “add the opening tag twenty lines up”.</p>

<p>Both commands run on defaults until you give them a config, which you generate with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>herb lint <span class="nt">--init</span>
</code></pre></div></div>

<p>That writes a <code class="language-plaintext highlighter-rouge">.herb.yml</code> file that pins the Herb version, so a later upgrade does not enable new rules behind your back. The rest of the file is commented out but worth reading through once. You can enable or disable individual rules, exclude paths for specific rules, set the severity that causes the run to fail, toggle the security, nesting, and accessibility validators, and choose the template engine, with <code class="language-plaintext highlighter-rouge">herb</code> as one of the options.</p>

<h2 id="what-you-get-and-what-it-costs">What you get, and what it costs</h2>

<p>From your application’s rendering point of view, Herb and Erubi are the same thing: same public API, same job, no templates to rewrite.</p>

<p>What changes is the tooling around them. Because the <code class="language-plaintext highlighter-rouge">herb</code> gem is now a dependency of <code class="language-plaintext highlighter-rouge">actionview</code>, the analysis, the linter, the formatter, the render graph, and the language server all come along with it. Some of that existed before as third-party gems, <code class="language-plaintext highlighter-rouge">erb_lint</code> and <code class="language-plaintext highlighter-rouge">better-html</code> among them, but none of it shipped with Rails, so nobody has to be talked into adding a gem for good practices.</p>

<p>The review thread points at Herb arriving opt-in first and becoming a framework default in some later version. That is reason enough to get ahead of it. Run the linter now, check that your views compile through Herb, and wire it into CI alongside RuboCop or whatever else you already run there, so the work happens before the release day instead of after it. A missing closing tag stops being something a reader finds for you months later.</p>

<p>The trade is compile time. Herb does more work than Erubi, so a template takes longer to compile, and on our views the difference is measured in milliseconds per template.</p>

<p>Where that lands is the part worth understanding. Rails compiles a template the first time something renders it and caches the compiled method for the life of the process, so the cost is paid once per template. Every request after that is identical, because the Ruby that Herb generates is the same Ruby, byte for byte. Nothing about serving a page is slower once the template has been compiled.</p>

<p>It is also early days. Marco Roth, who built Herb, has noted that it has not had much performance optimization yet, and that he believes it can be improved.</p>

<h2 id="conclusion">Conclusion</h2>

<p>We pointed Herb at an application whose views have been accumulating since 2017, and it found real problems in twenty-one templates, including markup we had been shipping for years. The run took a few seconds and needed nothing beyond a <code class="language-plaintext highlighter-rouge">gem install</code>.</p>

<p>That is what we would suggest doing now, well before any of this becomes a default. Views are where upgrade work tends to hide, and we have written before about <a href="https://www.fastruby.io/blog/handling-erb-syntax-changes-for-form-helpers">ERB syntax changes catching teams mid-upgrade</a>. Herb has earned a spot next to the <a href="https://www.fastruby.io/blog/open-source-tooling">open source tools we already reach for on upgrades</a>. The linter will clear the cosmetic offenses for you, the structural ones are hand work, and knowing which is which is most of the planning. Once you have cleared them, both <code class="language-plaintext highlighter-rouge">herb analyze</code> and <code class="language-plaintext highlighter-rouge">herb lint</code> exit non-zero when something fails, so either one can sit in CI and keep the next broken template out of the branch.</p>

<p>Marco Roth is presenting <a href="https://rubyonrails.org/world/2026/sessions/herb-rails-8-2">Herb in Rails 8.2: Your ERB views, now HTML-aware</a> at Rails World on September 24.</p>

<p>Is your team getting ready for the next version of Rails, or still catching up on the last one? <a href="https://www.fastruby.io/#contactus">We can help!</a></p>]]></content><author><name>juanvqz</name></author><category term="rails" /><summary type="html"><![CDATA[Herb is an HTML-aware ERB engine, now merged into Rails. Learn how to audit your Rails views with the Herb CLI today, what it autocorrects, and what it costs.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/herb-on-rails.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/herb-on-rails.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Rails 8.2: The HTTP QUERY Method</title><link href="https://www.fastruby.io/blog/rails-8-2-the-http-query-method.html" rel="alternate" type="text/html" title="Rails 8.2: The HTTP QUERY Method" /><published>2026-09-18T06:00:00-04:00</published><updated>2026-09-18T06:00:00-04:00</updated><id>https://www.fastruby.io/blog/rails-8-2-the-http-query-method</id><content type="html" xml:base="https://www.fastruby.io/blog/rails-8-2-the-http-query-method.html"><![CDATA[<p>RFC 10008, published in June 2026, defines the QUERY method as safe and idempotent like <code class="language-plaintext highlighter-rouge">GET</code>, and it carries its query in the request body like <code class="language-plaintext highlighter-rouge">POST</code>. Ruby on Rails merged support for QUERY on August 14, 2026, under the 8.2.0 milestone.</p>

<p>I set up a small application on edge Rails to check it working. The routing and the request object are ready, but the parts of the specification that make QUERY more than a <code class="language-plaintext highlighter-rouge">POST</code> with better manners are not quite there yet, since Rails parses only JSON bodies and does not enforce the content type rule RFC 10008 requires. In this article, we will check how the QUERY method works in Rails, how to route and test it, how Puma is handling it, and which pieces of the specification are still missing. Everything below reflects the state of things at the beginning of September 2026, and since Rails, Rack, and Puma all have the interesting parts sitting on unreleased branches, it is worth checking the versions yourself before trusting any of it.</p>

<!--more-->

<h2 id="what-the-query-method-is">What the QUERY method is</h2>

<p><a href="https://www.rfc-editor.org/rfc/rfc10008.html">RFC 10008</a> became a Proposed Standard in June 2026. It defines QUERY as a request method that is safe and idempotent with regard to the target resource, and that carries content in the request body.</p>

<p>That combination is the part neither <code class="language-plaintext highlighter-rouge">GET</code> nor <code class="language-plaintext highlighter-rouge">POST</code> can offer. It is worth noting that a <code class="language-plaintext highlighter-rouge">GET</code> with a body is not actually illegal, it is just that nothing useful is defined for it. RFC 9110 section 9.3.1 puts it plainly: a payload in a <code class="language-plaintext highlighter-rouge">GET</code> “has no defined semantics”, and sending one “might cause some implementations to reject the request”. A <code class="language-plaintext highlighter-rouge">POST</code> carries the body reliably, and it also announces to every cache and every client library that the request may have changed something on the server, so nothing retries it automatically. Its responses are cacheable in principle, but only with the cache control headers that section 9.3.3 requires, which is not something a typical search endpoint sets up.</p>

<p>Back in RFC 10008, a few details are worth knowing before you reach for it. Servers MUST fail the request if the <code class="language-plaintext highlighter-rouge">Content-Type</code> field is missing or is inconsistent with the request content (section 2). Responses are cacheable, but the cache key MUST incorporate the request content (section 2.7), which is a harder problem for a shared cache than hashing a URL. Section 3 defines an <code class="language-plaintext highlighter-rouge">Accept-Query</code> response header so a resource can advertise which query format media types it accepts, although Rails does not implement that part yet.</p>

<p>Those are the mechanics, and the specification is just as explicit about why you would want the query in the body to begin with. Size is the obvious half, since every proxy and CDN in front of you picks its own URL length limit. The other half is exposure, and it is worth being precise about: a URI is “more likely to be logged or otherwise processed by intermediaries than the request content”. This is not an encryption argument, because under TLS the path and query travel inside the same tunnel as the body. It is that a URI ends up in access logs, proxy logs, browser history, and <code class="language-plaintext highlighter-rouge">Referer</code> headers, and a request body usually ends up in none of them. <a href="https://www.rfc-editor.org/rfc/rfc10008.html#name-security-considerations">In section 4</a>, it follows the thought through: if a server mints a URI to represent the results of a QUERY, that URI SHOULD NOT contain sensitive portions of the original request content.</p>

<p>WebDAV defined SEARCH back in 2008 at <a href="https://www.rfc-editor.org/rfc/rfc5323.html">RFC 5323</a>, and <code class="language-plaintext highlighter-rouge">ActionDispatch::Request</code> has recognized it for years, in the same <code class="language-plaintext highlighter-rouge">HTTP_METHODS</code> list that QUERY now joins a few lines below it. And now QUERY has one advantage SEARCH never had: it is a general-purpose HTTP method rather than a WebDAV extension.</p>

<h2 id="routing-a-query-request">Routing a QUERY request</h2>

<p>Rails 8.2 adds a <code class="language-plaintext highlighter-rouge">query</code> route helper that sits alongside <code class="language-plaintext highlighter-rouge">get</code> and <code class="language-plaintext highlighter-rouge">post</code>. It is a thin wrapper that delegates straight to <code class="language-plaintext highlighter-rouge">match</code> with <code class="language-plaintext highlighter-rouge">via: :query</code>, so you can write it either way. Here is the routes file from the small application I set up to test it:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">draw</span> <span class="k">do</span>
  <span class="n">query</span> <span class="s2">"search"</span><span class="p">,</span> <span class="ss">to: </span><span class="s2">"search#index"</span>
  <span class="n">match</span> <span class="s2">"filter"</span><span class="p">,</span> <span class="ss">to: </span><span class="s2">"search#filter"</span><span class="p">,</span> <span class="ss">via: :query</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">bin/rails routes</code> then reports the verb the same way it reports any other:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Prefix Verb  URI Pattern         Controller#Action
search QUERY /search(.:format)   search#index
filter QUERY /filter(.:format)   search#filter
</code></pre></div></div>

<p>These routes are scoped to the verb, which is worth spelling out because it is easy to assume otherwise when you are used to <code class="language-plaintext highlighter-rouge">resources</code> generating several verbs for one path. A <code class="language-plaintext highlighter-rouge">GET</code> to <code class="language-plaintext highlighter-rouge">/search</code> does not fall through to the QUERY action, it just does not match, and neither does a <code class="language-plaintext highlighter-rouge">POST</code>. Both come back as a 404 while the QUERY request returns a 200. The named helpers work as usual, so <code class="language-plaintext highlighter-rouge">search_path</code> is there alongside them.</p>

<p><a href="https://github.com/rails/rails/pull/57973">The pull request</a> notes that the helper also works inside <code class="language-plaintext highlighter-rouge">resources</code> blocks with <code class="language-plaintext highlighter-rouge">on: :collection</code> and <code class="language-plaintext highlighter-rouge">on: :member</code>, but the <code class="language-plaintext highlighter-rouge">resources</code> shortcuts themselves were left out of scope, so there is no generated QUERY route yet.</p>

<h2 id="what-the-request-object-gives-you">What the request object gives you</h2>

<p>Inside the controller, QUERY behaves like any other verb. <code class="language-plaintext highlighter-rouge">request.query?</code> does what you would expect, and <code class="language-plaintext highlighter-rouge">request.request_method_symbol</code> returns <code class="language-plaintext highlighter-rouge">:query</code>. More useful is <code class="language-plaintext highlighter-rouge">request.safe_method?</code>, which now returns true for QUERY alongside <code class="language-plaintext highlighter-rouge">GET</code>, <code class="language-plaintext highlighter-rouge">HEAD</code>, <code class="language-plaintext highlighter-rouge">OPTIONS</code>, and <code class="language-plaintext highlighter-rouge">TRACE</code>, with <code class="language-plaintext highlighter-rouge">request.unsafe_method?</code> as its inverse. If you have written <a href="https://www.fastruby.io/blog/Middleware-in-Rails">a middleware</a> or an audit log that branches on whether a request is allowed to change state, that predicate is a better thing to call than a hand-maintained list of verbs.</p>

<p>The controller I tested does nothing but echo back what the request reports:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/search_controller.rb</span>
<span class="k">class</span> <span class="nc">SearchController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">index</span>
    <span class="n">render</span> <span class="ss">json: </span><span class="p">{</span>
      <span class="ss">method: </span><span class="n">request</span><span class="p">.</span><span class="nf">request_method</span><span class="p">,</span>
      <span class="ss">method_symbol: </span><span class="n">request</span><span class="p">.</span><span class="nf">request_method_symbol</span><span class="p">,</span>
      <span class="ss">query?: </span><span class="n">request</span><span class="p">.</span><span class="nf">query?</span><span class="p">,</span>
      <span class="ss">safe_method?: </span><span class="n">request</span><span class="p">.</span><span class="nf">safe_method?</span><span class="p">,</span>
      <span class="ss">filters: </span><span class="n">params</span><span class="p">[</span><span class="ss">:filters</span><span class="p">]</span>
    <span class="p">}</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Sending it a QUERY request with a JSON body gives back:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"method"</span><span class="p">:</span><span class="w"> </span><span class="s2">"QUERY"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"method_symbol"</span><span class="p">:</span><span class="w"> </span><span class="s2">"query"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"query?"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"safe_method?"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"filters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"active"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Notice where <code class="language-plaintext highlighter-rouge">filters</code> came from, it arrived in the request body rather than the query string, and it landed in <code class="language-plaintext highlighter-rouge">params</code> anyway. That is <code class="language-plaintext highlighter-rouge">ActionDispatch::Http::Parameters#parse_formatted_parameters</code> doing the work, picking a parser out of <code class="language-plaintext highlighter-rouge">DEFAULT_PARSERS</code> based on the request’s content type without ever looking at the verb, and <code class="language-plaintext highlighter-rouge">#parameters</code> then merging what comes back together with the query string and the path parameters.</p>

<p>That dependency on the content type cuts both ways. RFC 10008 says a server MUST fail the request when the <code class="language-plaintext highlighter-rouge">Content-Type</code> is missing or inconsistent with the content, and Rails does not do that, so on this point the implementation is not yet spec compliant. A QUERY request with no <code class="language-plaintext highlighter-rouge">Content-Type</code> at all comes back as a 200 with an empty <code class="language-plaintext highlighter-rouge">params</code> and the body quietly discarded. The only parser Rails registers by default is the JSON one, since form data is handled upstream by Rack, so anything else gets dropped the same way: an <code class="language-plaintext highlighter-rouge">application/sql</code> body never reaches <code class="language-plaintext highlighter-rouge">params</code> at all, which is worth knowing given that the RFC has exactly those structured query formats in mind. A body that contradicts its declared type does fail, but as a 400 from the parameter parser rather than as anything QUERY-specific.</p>

<h2 id="testing-it">Testing it</h2>

<p>Integration tests get a <code class="language-plaintext highlighter-rouge">query</code> helper that mirrors <code class="language-plaintext highlighter-rouge">get</code> and <code class="language-plaintext highlighter-rouge">post</code>, so the test reads like any other request test:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># test/integration/query_method_test.rb</span>
<span class="k">class</span> <span class="nc">QueryMethodTest</span> <span class="o">&lt;</span> <span class="no">ActionDispatch</span><span class="o">::</span><span class="no">IntegrationTest</span>
  <span class="nb">test</span> <span class="s2">"routes a QUERY request and parses the JSON body"</span> <span class="k">do</span>
    <span class="n">query</span> <span class="s2">"/search"</span><span class="p">,</span> <span class="ss">params: </span><span class="p">{</span> <span class="ss">filters: </span><span class="p">{</span> <span class="ss">status: </span><span class="s2">"active"</span> <span class="p">}</span> <span class="p">},</span> <span class="ss">as: :json</span>

    <span class="n">assert_response</span> <span class="ss">:success</span>
    <span class="n">body</span> <span class="o">=</span> <span class="no">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="nf">body</span><span class="p">)</span>
    <span class="n">assert_equal</span> <span class="s2">"QUERY"</span><span class="p">,</span> <span class="n">body</span><span class="p">[</span><span class="s2">"method"</span><span class="p">]</span>
    <span class="n">assert_equal</span> <span class="kp">true</span><span class="p">,</span> <span class="n">body</span><span class="p">[</span><span class="s2">"query?"</span><span class="p">]</span>
    <span class="n">assert_equal</span> <span class="kp">true</span><span class="p">,</span> <span class="n">body</span><span class="p">[</span><span class="s2">"safe_method?"</span><span class="p">]</span>
    <span class="n">assert_equal</span><span class="p">({</span> <span class="s2">"status"</span> <span class="o">=&gt;</span> <span class="s2">"active"</span> <span class="p">},</span> <span class="n">body</span><span class="p">[</span><span class="s2">"filters"</span><span class="p">])</span>
  <span class="k">end</span>

  <span class="nb">test</span> <span class="s2">"the same path does not answer GET or POST"</span> <span class="k">do</span>
    <span class="n">get</span> <span class="s2">"/search"</span>
    <span class="n">assert_response</span> <span class="ss">:not_found</span>
    <span class="n">post</span> <span class="s2">"/search"</span>
    <span class="n">assert_response</span> <span class="ss">:not_found</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Controller tests do not get a dedicated helper, but <code class="language-plaintext highlighter-rouge">process</code> takes the method explicitly with <code class="language-plaintext highlighter-rouge">process :index, method: "QUERY"</code>. One thing to watch there: a controller test written that way sends its params as <code class="language-plaintext highlighter-rouge">application/x-www-form-urlencoded</code> unless you say otherwise, so it will not exercise the JSON parsing path that your actual client uses.</p>

<h2 id="query-and-csrf-protection">QUERY and CSRF protection</h2>

<p>Because QUERY is safe, Rails exempts it from forgery protection the same way it exempts <code class="language-plaintext highlighter-rouge">GET</code> and <code class="language-plaintext highlighter-rouge">HEAD</code>. The exemption is narrower than it first looks, though. As the comment in the source puts it, QUERY requests are exempt “but only when the request actually arrived with the QUERY method”, which is why the predicate checks two things that look like the same thing:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># actionpack/lib/action_controller/metal/request_forgery_protection.rb</span>
<span class="k">def</span> <span class="nf">verified_request?</span>
  <span class="n">request</span><span class="p">.</span><span class="nf">get?</span> <span class="o">||</span> <span class="n">request</span><span class="p">.</span><span class="nf">head?</span> <span class="o">||</span> <span class="n">verified_query_request?</span> <span class="o">||</span> <span class="o">!</span><span class="n">protect_against_forgery?</span> <span class="o">||</span>
    <span class="p">(</span><span class="n">valid_request_origin?</span> <span class="o">&amp;&amp;</span> <span class="n">verified_request_for_forgery_protection?</span><span class="p">)</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">verified_query_request?</span>
  <span class="n">request</span><span class="p">.</span><span class="nf">query?</span> <span class="o">&amp;&amp;</span> <span class="n">request</span><span class="p">.</span><span class="nf">method</span> <span class="o">==</span> <span class="s2">"QUERY"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The difference is that <code class="language-plaintext highlighter-rouge">request_method</code> reflects the method after any override has been applied, while <code class="language-plaintext highlighter-rouge">request.method</code> reads <code class="language-plaintext highlighter-rouge">rack.methodoverride.original_method</code> first and only falls back to <code class="language-plaintext highlighter-rouge">REQUEST_METHOD</code>, so it reports the method the request actually arrived with. The rationale for exempting genuine QUERY requests is that an HTML form cannot emit one, and that a cross-origin QUERY from JavaScript is always preflighted. Neither of those holds for a form <code class="language-plaintext highlighter-rouge">POST</code> carrying an override parameter.</p>

<p>Whether that guard does anything depends on which Rack you are running, which took me a failing test to work out. On Rack 3.2.7, the current release, <code class="language-plaintext highlighter-rouge">Rack::MethodOverride::HTTP_METHODS</code> does not list QUERY, so a form posting <code class="language-plaintext highlighter-rouge">_method=query</code> is ignored outright: the request stays a <code class="language-plaintext highlighter-rouge">POST</code>, <code class="language-plaintext highlighter-rouge">request.query?</code> is false, and it needs a token for the ordinary reason rather than the QUERY-specific one. On Rack <code class="language-plaintext highlighter-rouge">main</code> that constant does list QUERY, and <code class="language-plaintext highlighter-rouge">ALLOWED_METHODS</code> is still just <code class="language-plaintext highlighter-rouge">POST</code>, so the override goes through and the Rails predicate is the thing standing between a plain form submission and a skipped CSRF check.</p>

<p><a href="https://github.com/rack/rack/pull/2502">An open pull request</a> proposes dropping QUERY from the <code class="language-plaintext highlighter-rouge">_method</code> form parameter while keeping it available through the <code class="language-plaintext highlighter-rouge">X-HTTP-Method-Override</code> header, on the grounds that a custom header always forces a CORS preflight and a form parameter does not.</p>

<h2 id="puma-has-to-allow-it-first">Puma has to allow it first</h2>

<p>Everything above works in tests, and tests run the Rack application directly. Rails reads <code class="language-plaintext highlighter-rouge">REQUEST_METHOD</code> out of the Rack env and trusts it, so as far as Rails is concerned the method is whatever the server handed over. That makes the server the real gatekeeper, and the current release of Puma does not let QUERY through.</p>

<p>Puma keeps an allowlist, which it <a href="https://github.com/puma/puma/issues/3014">introduced in Puma 6</a> and made configurable after the WebDAV crowd pointed out what it broke. On Puma 8.0.2, <code class="language-plaintext highlighter-rouge">Puma::Const::SUPPORTED_HTTP_METHODS</code> holds <code class="language-plaintext highlighter-rouge">HEAD</code>, <code class="language-plaintext highlighter-rouge">GET</code>, <code class="language-plaintext highlighter-rouge">POST</code>, <code class="language-plaintext highlighter-rouge">PUT</code>, <code class="language-plaintext highlighter-rouge">DELETE</code>, <code class="language-plaintext highlighter-rouge">OPTIONS</code>, <code class="language-plaintext highlighter-rouge">TRACE</code>, and <code class="language-plaintext highlighter-rouge">PATCH</code>, and nothing else. Send a QUERY request to a default Puma and it never reaches your routes:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 501 Not Implemented

Puma caught this error: QUERY method is not supported (Puma::HttpParserError501)
.../puma-8.0.2/lib/puma/client.rb:305:in 'Puma::Client#parser_execute'
</code></pre></div></div>

<p>We can allow it with one line in <code class="language-plaintext highlighter-rouge">config/puma.rb</code>, using the <code class="language-plaintext highlighter-rouge">supported_http_methods</code> option Puma exposes for exactly this:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/puma.rb</span>
<span class="n">supported_http_methods</span> <span class="no">Puma</span><span class="o">::</span><span class="no">Const</span><span class="o">::</span><span class="no">SUPPORTED_HTTP_METHODS</span> <span class="o">+</span> <span class="p">[</span><span class="s2">"QUERY"</span><span class="p">]</span>
</code></pre></div></div>

<p>With that in place the same request reaches the controller and comes back with a 200. Passing <code class="language-plaintext highlighter-rouge">supported_http_methods :any</code> also works and turns the check off completely, which is worth avoiding if you can just name the methods you serve.</p>

<p>Treat that line as a stopgap rather than something to keep, because Puma has already fixed this upstream. On <code class="language-plaintext highlighter-rouge">main</code>, both <code class="language-plaintext highlighter-rouge">SUPPORTED_HTTP_METHODS</code> and <code class="language-plaintext highlighter-rouge">IANA_HTTP_METHODS</code> list QUERY, annotated <code class="language-plaintext highlighter-rouge">with QUERY added based on https://www.rfc-editor.org/rfc/rfc10008.html</code>, which means version 8.0.3 will accept QUERY with no configuration at all.</p>

<p>That fix is also a good illustration of how the pieces had to line up. In the Rails pull request, Sean Doyle raised the point that support needs coordinating across Rack, Puma, and nginx, and then wrote it: <a href="https://github.com/rack/rack/pull/2474">the Rack change</a> landed on July 12, 2026 and <a href="https://github.com/puma/puma/pull/3973">the Puma change</a> the day before. The part nobody can patch for you is the rest of the path. If you have a CDN, a load balancer, or an nginx in front of the application, each one has its own opinion about methods it does not recognize.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article we talked about the QUERY method from RFC 10008, the <code class="language-plaintext highlighter-rouge">query</code> route helper and its <code class="language-plaintext highlighter-rouge">match via: :query</code> equivalent, the <code class="language-plaintext highlighter-rouge">request.query?</code> and <code class="language-plaintext highlighter-rouge">request.safe_method?</code> predicates, the <code class="language-plaintext highlighter-rouge">query</code> integration test helper, and why Rails exempts genuine QUERY requests from CSRF protection but not the ones tunneled through a form <code class="language-plaintext highlighter-rouge">POST</code>.</p>

<p>The support was merged on August 14, 2026 and sits on <code class="language-plaintext highlighter-rouge">main</code> under the 8.2.0 milestone.</p>

<p>SEARCH got recognized and then went nowhere: Action Dispatch has accepted it for years and Puma lists it among the IANA methods, but it never made Puma’s default allowlist. QUERY has had Rack, Puma, and Rails all land support inside about five weeks, and Puma took it straight into the defaults.</p>

<p>Is your application several versions behind, with even <a href="https://www.fastruby.io/blog/upgrade-rails-8-0-to-8-1.html">the upgrade from Rails 8.0 to 8.1</a> still ahead of you? <a href="https://www.fastruby.io/#contactus">Send us a message, we can help!</a></p>]]></content><author><name>hmdros</name></author><category term="rails" /><summary type="html"><![CDATA[Rails 8.2 adds early support for the HTTP QUERY method from RFC 10008. Learn what the method is for, how to route and test it, and which parts of the spec Rails does not implement yet.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/rails-8-2-the-http-query-method.png" /><media:content medium="image" url="https://www.fastruby.io/blog/rails-8-2-the-http-query-method.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Irish Chess Union Upgrades to Rails 8.1</title><link href="https://www.fastruby.io/blog/claude-code-rails-upgrade-irish-chess-union.html" rel="alternate" type="text/html" title="Irish Chess Union Upgrades to Rails 8.1" /><published>2026-09-14T00:00:00-04:00</published><updated>2026-09-14T00:00:00-04:00</updated><id>https://www.fastruby.io/blog/claude-code-rails-upgrade-irish-chess-union</id><content type="html" xml:base="https://www.fastruby.io/blog/claude-code-rails-upgrade-irish-chess-union.html"><![CDATA[<p>Founded in 1912, the Irish Chess Union (ICU) is the governing body for chess across the island of Ireland. It has about 2,500 members
and run entirely by volunteers.</p>

<p>Its website, <a href="https://www.icu.ie/">icu.ie</a>, has been maintained by webmaster Jonathan O’Connor since 2015. Earlier this year, Jonathan
reached out to us. He wanted help taking the ICU’s Rails application from 7.0 to 8.1.</p>

<p>We used Claude Code and our open source <a href="https://github.com/ombulabs/claude-code_rails-upgrade-skill">Claude Code Rails Upgrade Skill</a> to
get it done. We also recorded the sessions as a video series, so other teams could see the methodology in practice.</p>

<p>In this article, we’ll walk through the upgrade, one version at a time, and share what Jonathan thought of the process.</p>

<!--more-->

<p><img src="/blog/assets/images/icu-website-homepage.png" alt="The Irish Chess Union homepage at icu.ie" /></p>

<h2 id="here-was-our-challenge">Here was our challenge</h2>

<p>The ICU’s application was built and is maintained by professionals, just not on the clock. Everyone involved works on it nights and weekends,
as volunteers. It was written mostly by Mark Orr, a ratings officer who had just retired and wanted to give back to the community.</p>

<p>That project became learning Ruby on Rails and building the ICU a website from scratch. He wrote about 90% of the code that’s still running
today. Jonathan took over as webmaster in 2015.</p>

<p>He’d learned Ruby a few years earlier, on a six-week trip to Rwanda building credit card payment systems. Rails’ conventions weren’t as disorienting
to him as they are to developers who come to it cold.</p>

<p>He kept the application current on his own for a while. When he upgraded it from Rails 3 to 5, he did it the patient way: bumping the Gemfile one
patch version at a time, running the test suite, and fixing whatever the deprecation warnings in the logs pointed at.</p>

<p>His colleague Ben Murray later carried it from 5.0 to 7.0. Then Jonathan tried to take it from 7.0 to 7.1 by himself. The same approach that had
worked for years stopped working.</p>

<p>He’d been away from daily Rails work since 2016. The deprecation warnings piled up faster than he could make sense of them.</p>

<p>“It was a lot harder,” he said. Eventually it became impossible to tell which warnings actually mattered.</p>

<h2 id="heres-how-we-solved-it">Here’s how we solved it</h2>

<p>We used the same methodology we’ve documented for years: <a href="/blog/upgrade-rails/dual-boot/dual-boot-with-rails-6-0-beta.html">dual-boot the app</a> with
the <a href="https://github.com/fastruby/next_rails"><code class="language-plaintext highlighter-rouge">next_rails</code></a> gem, and upgrade one minor Rails version at a time, never skipping a hop.</p>

<p>For the ICU, that meant an unhurried climb from 7.0 to 7.1, then 7.2, then 8.0, and finally 8.1. Each hop got its own pull request. Each one had
a green test suite against both Rails versions before we merged it and moved to the next.</p>

<p>Here’s what that looks like under the hood, for anyone who hasn’t dual-booted a Ruby app before:</p>

<p><img src="/blog/assets/images/dual-boot-diagram.png" alt="How dual booting works: two Gemfiles, two Rails versions, verified independently" /></p>

<p>Before we touched the Rails version at all, we spent some time on groundwork.</p>

<p>We set up GitHub Actions, so the ICU had CI for the first time. We patched the Docker configuration, so the app could build against either
Gemfile.</p>

<p>That kind of scaffolding is easy to underestimate. It’s usually what makes the rest of the upgrade boring, in a good way.</p>

<p>This was also a great opportunity to share with the community how we leverage our open source Claude Code Rails Upgrade Skill, instead of just
publishing it and hoping people would try it themselves. In 3 different pairing sessions, we drove Claude to use the skill to handle
the mechanical work that happens in every upgrade project.</p>

<iframe width="560" height="315" src="https://www.youtube.com/embed/hr2VGizrK2Y" frameborder="0" allowfullscreen=""></iframe>

<p>Claude handled a lot of the mechanical work in each hop: previewing what <code class="language-plaintext highlighter-rouge">rails app:update</code> would change, running the test suite against both
Gemfiles, and flagging deprecation warnings before they piled up.</p>

<p>Our engineers, <a href="/blog/authors/hmdros">Henrique Medeiros</a>, <a href="/blog/authors/arieljuod">Ariel Juodziukynas</a>, <a href="/blog/authors/aisayo">Aysan Isayo</a>,
and <a href="/blog/authors/juanvqz">Juan Vasquez</a>, made the judgment calls the skill is deliberately not built to make on its own.</p>

<p>One of those calls was a small shim in the ICU’s codebase. Even Jonathan, after nearly a decade of maintaining the app, said he never would
have found it on his own.</p>

<blockquote>
  <p>“There’s one thing I would never have found, even if I kept working at trying to figure out how to do the upgrade myself, and that was this
shim in our code,” Jonathan said. “I think that’s one of the places where you earn your keep, because these are tricks that people won’t know,
since they’re not doing upgrades every day.”</p>
</blockquote>

<p>Part of why the rest of the upgrade went smoothly is that the ICU’s codebase is, in Jonathan’s own words, unusually well-behaved.</p>

<p>No monkeypatches. No reopened core classes. No weird code in weird places.</p>

<p>And on top of that: A real test suite with both model and integration coverage.</p>

<p>None of that is guaranteed for an application built by a single volunteer. But Mark Orr and Jonathan had both stuck close to the Rails way, and
it showed in how predictable each hop turned out to be.</p>

<p>The ICU never had to pause its own work, either.</p>

<p>Jonathan’s small team of volunteer contributors kept shipping features throughout the upgrade. The app’s Stripe integration, the dependency that
worried us most going in since it handles ICU’s membership payments, never broke stride.</p>

<h2 id="see-the-results">See the results</h2>

<p>Now the ICU is current on Rails 8.1.</p>

<blockquote>
  <p>“I didn’t notice any issues at all. There were no breakages at all. You made maybe seven or eight pull requests, probably thirty or forty commits
over that time, and they just fitted seamlessly into all our changes. Nothing ever broke. And thank you so much for setting up our Docker, because
now we have our tests running on GitHub, which is brilliant.”</p>

  <p><br /></p>

  <p>Jonathan O’Connor, Webmaster, Irish Chess Union</p>
</blockquote>

<p>Here’s one of those pull requests, upgrading the app to Rails 7.2:</p>

<p><img src="/blog/assets/images/icu-pr-157-rails-7-2.png" alt="Pull request #157, upgrading the ICU application to Rails 7.2" /></p>

<p>Eight pull requests, somewhere around thirty to forty commits, and <strong>zero breakages</strong>.</p>

<p>That’s not a coincidence. It’s what a sequential, dual-booted upgrade against a clean codebase and a stable test suite looks like when it goes right.</p>

<p>There’s also a quieter story here, about who can afford this kind of work.</p>

<p>A volunteer-run federation with no engineering budget to speak of got a five-version Rails upgrade done. Not because the work got easier, but because
one senior engineer driving Claude Code and our upgrade skill can now get much closer to what it used to take two.</p>

<p>This is a real shift we’re seeing across our upgrade engagements, and it’s a big part of why an organization like the ICU can now afford this kind of
work at all.</p>

<p>If you’re trying to make this case internally, <a href="/blog/rails/upgrade/how-to-pitch-an-upgrade-to-your-boss.html">here’s some advice on how to pitch a Rails upgrade to your boss</a>.</p>

<h2 id="watch-the-pairing-sessions">Watch the pairing sessions</h2>

<p>We recorded four sessions from this project, so you can watch the actual pairing work instead of just reading about it.</p>

<h3 id="part-1-setting-up-ci">Part 1: Setting up CI</h3>

<p>Henrique Medeiros and I spend about an hour setting up CI for the ICU’s application: wiring up GitHub Actions and using
Claude Code to work through the setup issues that came up along the way:</p>

<iframe width="560" height="315" src="https://www.youtube.com/embed/hr2VGizrK2Y" frameborder="0" allowfullscreen=""></iframe>

<h3 id="part-2-dual-booting-and-api-compatibility">Part 2: Dual booting and API compatibility</h3>

<p>Henrique and I dig into dual booting and the API differences between Rails 7.0 and 7.1.</p>

<p>We set up <code class="language-plaintext highlighter-rouge">next_rails</code> and a <code class="language-plaintext highlighter-rouge">Gemfile</code> symlink strategy, chase down a few gem compatibility issues using <a href="https://www.railsbump.org/">RailsBump.org</a>, and configure GitHub Actions
and Docker to run the test suite against both Rails versions:</p>

<iframe width="560" height="315" src="https://www.youtube.com/embed/CbFkIGXxt98" frameborder="0" allowfullscreen=""></iframe>

<h3 id="part-3-upgrading-to-rails-72-and-getting-ready-for-80">Part 3: Upgrading to Rails 7.2, and getting ready for 8.0</h3>

<p>Juan Vasquez and I carry the app from Rails 7.1 to 7.2, while preparing the codebase for Rails 8.0.</p>

<p>We walk through how the upgrade skill checks the current patch version, confirms dual boot is set up, and generates an upgrade report,
then track down a <a href="https://github.com/irishchessunion/icu_www_app/pull/157">Capybara and Selenium WebDriver compatibility issue</a> that turns out to
have nothing to do with Rails itself:</p>

<iframe width="560" height="315" src="https://www.youtube.com/embed/XSfsM70uVEc" frameborder="0" allowfullscreen=""></iframe>

<h3 id="recap-a-quick-recap-of-the-entire-upgrade-project">Recap: A quick recap of the entire upgrade project</h3>

<p>Jonathan and I talk about the project end to end: the ICU’s volunteer culture, why the upgrade stalled
at Rails 7.1, the results, and Jonathan’s own take on where AI helps and where he still won’t let it near ICU’s data.</p>

<p>Keep an eye out for the Easter egg: a shim in the ICU’s codebase that Jonathan says he’d never have thought of himself.</p>

<iframe width="560" height="315" src="https://www.youtube.com/embed/sI38ccCHXAA" frameborder="0" allowfullscreen=""></iframe>

<h2 id="conclusion">Conclusion</h2>

<p>The ICU’s upgrade worked because the methodology did the heavy lifting, not the AI on its own. Our AI tooling certainly help
speed things up! 🚀</p>

<p>Dual booting, sequential hops, a deprecation-first approach: none of that is new.</p>

<p>What’s new is that Claude Code, guided by a skill that encodes those opinions, can now execute most of it, while our engineers focus
on the handful of judgment calls, like that one shim, that still need a human who’s done this before.</p>

<p>It’s worth saying plainly: this went smoothly in part because the ICU’s codebase was clean and well-tested.</p>

<p>A messier application, full of monkeypatches and flaky tests, would have taken longer.</p>

<p>Has your team been putting off a Rails upgrade? <a href="https://www.fastruby.io/contact">Let’s talk</a> and get your up to the latest
stable versions of Ruby and Rails!</p>

<h2 id="references">References</h2>

<p>All of the pull requests below were merged into the ICU’s <code class="language-plaintext highlighter-rouge">master</code> branch, and the full history is public:</p>

<ul>
  <li><a href="https://github.com/irishchessunion/icu_www_app/pull/117">#117: Remove deprecation warnings</a></li>
  <li><a href="https://github.com/irishchessunion/icu_www_app/pull/134">#134: Set up GitHub Actions</a></li>
  <li><a href="https://github.com/irishchessunion/icu_www_app/pull/145">#145: Docker setup configuration patches</a></li>
  <li><a href="https://github.com/irishchessunion/icu_www_app/pull/150">#150: Dual boot the application (Rails 7.0 and 7.1)</a></li>
  <li><a href="https://github.com/irishchessunion/icu_www_app/pull/152">#152: Upgrade to Rails 7.1</a></li>
  <li><a href="https://github.com/irishchessunion/icu_www_app/pull/157">#157: Upgrade to Rails 7.2</a></li>
  <li><a href="https://github.com/irishchessunion/icu_www_app/pull/165">#165: Upgrade to Rails 8.0</a></li>
  <li><a href="https://github.com/irishchessunion/icu_www_app/pull/184">#184: Upgrade to Rails 8.1</a></li>
</ul>

<p>You can find the ICU’s full application source code at <a href="https://github.com/irishchessunion/icu_www_app">github.com/irishchessunion/icu_www_app</a>,
and our own upgrade skill at <a href="https://github.com/ombulabs/claude-code_rails-upgrade-skill">github.com/ombulabs/claude-code_rails-upgrade-skill</a>.</p>

<p>If you want the version-by-version details behind each hop, our <a href="/blog/rails/upgrade/rails-upgrade-series.html">Rails Upgrade Series</a> covers every jump
from 2.3 onward.</p>]]></content><author><name>etagwerker</name></author><category term="case-study" /><summary type="html"><![CDATA[How we used Claude Code and AI to upgrade the Irish Chess Union's Ruby on Rails app from 7.0 to 8.1, using our open source upgrade skill, without pausing feature work.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/irish-chess-union-case-study.png" /><media:content medium="image" url="https://www.fastruby.io/blog/irish-chess-union-case-study.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Upgrading a Deprecated Postgres on Heroku</title><link href="https://www.fastruby.io/blog/upgrading-a-deprecated-heroku-postgres-database.html" rel="alternate" type="text/html" title="Upgrading a Deprecated Postgres on Heroku" /><published>2026-09-10T00:00:00-04:00</published><updated>2026-09-10T00:00:00-04:00</updated><id>https://www.fastruby.io/blog/upgrading-a-deprecated-heroku-postgres-database</id><content type="html" xml:base="https://www.fastruby.io/blog/upgrading-a-deprecated-heroku-postgres-database.html"><![CDATA[<p>Heroku sent us an email about the database behind <a href="https://ips.fastruby.io">ips.fastruby.io</a>, a small app we run for sharing <a href="https://github.com/evanphx/benchmark-ips">benchmark-ips</a> results. That database runs Postgres 15, and the email gave it an end-of-life date on Heroku of January 20, 2027. If we do nothing before December 20, 2026, they will upgrade it to Postgres 18 for us.</p>

<p>Staying in control of when that happens beats finding out on Heroku’s schedule, so I upgraded it that same night. I skipped the method Heroku recommends for a documented one that suited this database better, and still finished the job with commands that are not in their documentation.</p>

<p>In this article, you will learn how we moved a Heroku Postgres database off a deprecated version, why we copied the data into a new database instead of upgrading in place, how much downtime to plan for, and which parts of Heroku’s tooling failed on us. We upgraded from Postgres 15 to 18, but the steps are much the same whichever version you are leaving behind.</p>

<!--more-->

<h2 id="the-email-that-started-it">The email that started it</h2>

<p>Here is the email Heroku sent us:</p>

<p><img src="/blog/assets/images/heroku-postgres-15-eol-email.png" alt="Heroku email warning that a Postgres 15 database reaches end-of-life on 2027-Jan-20" /></p>

<blockquote>
  <p>That date is Heroku retiring the version on their platform, not upstream PostgreSQL dropping support for it. It also travels with the notice rather than with the version: Heroku’s <a href="https://devcenter.heroku.com/articles/heroku-postgres-version-support">Postgres version support page</a> lists Postgres 15 reaching end-of-life on February 28, 2027, later than the date in our email. Go by whichever comes first for your database.</p>
</blockquote>

<p>The email names one database, and the dashboard has no aggregate view of versions, so the first thing to find out is whether your other apps are in the same position. There is no single command for that, but <code class="language-plaintext highlighter-rouge">heroku apps</code> speaks JSON, which is enough to ask each one:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>app <span class="k">in</span> <span class="si">$(</span>heroku apps <span class="nt">--team</span> your-team <span class="nt">--json</span> | jq <span class="nt">-r</span> <span class="s1">'.[].name'</span><span class="si">)</span><span class="p">;</span> <span class="k">do
  </span><span class="nv">version</span><span class="o">=</span><span class="si">$(</span>heroku pg:info <span class="nt">-a</span> <span class="s2">"</span><span class="nv">$app</span><span class="s2">"</span> 2&gt;/dev/null | <span class="nb">grep</span> <span class="s2">"PG Version"</span> | <span class="nb">awk</span> <span class="s1">'{print $NF}'</span><span class="si">)</span>
  <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$version</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$app</span><span class="s2">: </span><span class="nv">$version</span><span class="s2">"</span>
<span class="k">done</span>
</code></pre></div></div>

<blockquote>
  <p>Drop <code class="language-plaintext highlighter-rouge">--team</code> if your apps are personal. Apps without a database print nothing.</p>
</blockquote>

<p>The reason to run this yourself, rather than letting the deadline arrive, is the downtime. Replacing a database version means putting the app in maintenance mode and taking it offline for a few minutes, and after the deadline Heroku picks that moment for you. I would rather schedule that outage myself than have Heroku cut requests off mid-flight.</p>

<h2 id="choosing-how-to-upgrade">Choosing how to upgrade</h2>

<p>Heroku documents <a href="https://devcenter.heroku.com/articles/upgrading-heroku-postgres-databases">three ways to upgrade a database version</a>, and they mostly differ in how long your app is unavailable.</p>

<p>A direct upgrade with <code class="language-plaintext highlighter-rouge">pg:upgrade:run</code> takes “around 10 minutes for most use cases, although this amount can vary”.</p>

<p>A follower failover needs 20 to 30 minutes. Copying the data into a new database needs “approximately 3 minutes of app downtime per GB of your current database, although this amount can vary substantially depending on your schema and database plan”.</p>

<p>I went with the data copy, for two reasons that had nothing to do with speed. The old database stays exactly where it is, so you can point the app back at it with <code class="language-plaintext highlighter-rouge">heroku pg:promote</code> if the new one misbehaves. And since you choose the plan when you provision a database, an upgrade is a good moment to move to a plan that matches how the app behaves today rather than when the database was created.</p>

<blockquote>
  <p>None of the three avoid downtime. The documentation is explicit that “all methods require some application downtime to ensure that no data is lost during the upgrade”, so pick a low-traffic window.</p>
</blockquote>

<p>New databases are created on the current default version, 18.3 as of this writing, so provisioning one gets you the version bump for free:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>heroku addons:create heroku-postgresql:&lt;your-plan&gt; <span class="nt">-a</span> example-app
heroku pg:wait <span class="nt">-a</span> example-app
heroku pg:info <span class="nt">-a</span> example-app
</code></pre></div></div>

<h2 id="freezing-writes-and-the-part-where-pgcopy-did-not-run">Freezing writes, and the part where <code class="language-plaintext highlighter-rouge">pg:copy</code> did not run</h2>

<p>Before copying anything, the app has to stop writing. Maintenance mode alone is not enough, because it does not scale down your dynos, and a running dyno can still open connections and commit rows:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>heroku maintenance:on <span class="nt">-a</span> example-app
heroku ps:scale <span class="nv">web</span><span class="o">=</span>0 <span class="nt">-a</span> example-app
</code></pre></div></div>

<p>While the app is down, check whether <code class="language-plaintext highlighter-rouge">DATABASE_URL</code> is a real attachment or a config var somebody set by hand years ago. Ours was the second kind, so <code class="language-plaintext highlighter-rouge">pg:promote</code> would have created the attachment while the hand-set variable kept pointing at the old database:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>heroku addons <span class="nt">-a</span> example-app
</code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">DATABASE_URL</code> does not show up there as an attachment, unset it before promoting, and let Heroku manage it from then on:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>heroku config:unset DATABASE_URL <span class="nt">-a</span> example-app
</code></pre></div></div>

<blockquote>
  <p>Heroku names each database attachment after a color, so yours will be something like <code class="language-plaintext highlighter-rouge">HEROKU_POSTGRESQL_GOLD_URL</code>. I have relabeled them <code class="language-plaintext highlighter-rouge">OLD</code> and <code class="language-plaintext highlighter-rouge">NEW</code> in the commands below, including in the output, so it stays obvious which database is which.</p>
</blockquote>

<p>With writes frozen and a fresh database waiting, the documented copy command failed:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>heroku pg:copy HEROKU_POSTGRESQL_OLD_URL HEROKU_POSTGRESQL_NEW_URL <span class="nt">-a</span> example-app <span class="nt">--confirm</span> NEW
Starting copy of OLD to NEW... <span class="o">!</span>
 ›   Error: Internal server error.
 ›   Error ID: internal_server_error
</code></pre></div></div>

<p>It failed twice, and <code class="language-plaintext highlighter-rouge">heroku pg:backups</code> showed no copy job at all, so nothing had started on Heroku’s side. The target was still empty and the source untouched, the good version of this failure.</p>

<p>So I fell back to a backup and a restore, which is the same idea through a different subsystem:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>heroku pg:backups:capture HEROKU_POSTGRESQL_OLD_URL <span class="nt">-a</span> example-app
heroku pg:backups:restore b011 HEROKU_POSTGRESQL_NEW_URL <span class="nt">-a</span> example-app <span class="nt">--confirm</span> example-app
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">b011</code> is the id the capture prints when it finishes, and <code class="language-plaintext highlighter-rouge">heroku pg:backups</code> lists them if you lose it. Pass it explicitly: leaving it out shifts the arguments along, so the target becomes <code class="language-plaintext highlighter-rouge">DATABASE_URL</code> and you restore the old database over itself.</p>

<p>The restore takes a <code class="language-plaintext highlighter-rouge">--confirm</code> because it destroys something: it wipes the target database before loading into it. The capture just writes a new backup, so it has no confirmation flag at all. Look closely at that value, though. Both destructive commands ask you to confirm, but they want different things: <code class="language-plaintext highlighter-rouge">pg:copy</code> wants the target database, <code class="language-plaintext highlighter-rouge">pg:backups:restore</code> wants the app. Passing the database name to the restore gets you this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Error: Confirmation NEW did not match example-app. Aborted.
</code></pre></div></div>

<p>The restore aborts before it starts and leaves nothing under the Restores section of <code class="language-plaintext highlighter-rouge">heroku pg:backups</code>, so it looks like you never ran it. With the app name, it went through on the first try.</p>

<h2 id="falling-back-to-pg_dump-and-pg_restore">Falling back to <code class="language-plaintext highlighter-rouge">pg_dump</code> and <code class="language-plaintext highlighter-rouge">pg_restore</code></h2>

<p>Later that night the restore started failing the same way on another database, so I moved the data myself. This is an escape hatch, not the path I would recommend.</p>

<p>These commands run on your own machine, not on a Heroku dyno. <code class="language-plaintext highlighter-rouge">pg_dump</code> and <code class="language-plaintext highlighter-rouge">pg_restore</code> are ordinary Postgres clients that connect over the network like your app does, so a full copy of your production database lands on your laptop before going back up to the new one.</p>

<p>That is a problem that has nothing to do with Postgres. If your database holds personal data, health records, payment details, or anything else covered by an agreement you have signed, downloading it to a development machine may violate that agreement no matter how careful you are with the file afterwards. Heroku’s copy and restore commands keep the data inside their infrastructure, and the in-place <code class="language-plaintext highlighter-rouge">pg:upgrade:run</code> never moves it at all. When the data is sensitive those are the better answers, and a support ticket beats a local dump when they fail.</p>

<p>Ours holds public benchmark results and no personal data, which is the only reason I was comfortable doing this. Size matters too: the round trip is quick for a small database and slow for a large one.</p>

<p>The clients have to be at least as new as the server you are dumping from: “pg_dump cannot dump from PostgreSQL servers newer than its own major version; it will refuse to even try”. On macOS, <code class="language-plaintext highlighter-rouge">brew install libpq</code> is enough.</p>

<p>Get both connection strings first, then dump and restore:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">OLD_URL</span><span class="o">=</span><span class="si">$(</span>heroku config:get HEROKU_POSTGRESQL_OLD_URL <span class="nt">-a</span> example-app<span class="si">)</span>
<span class="nv">NEW_URL</span><span class="o">=</span><span class="si">$(</span>heroku config:get HEROKU_POSTGRESQL_NEW_URL <span class="nt">-a</span> example-app<span class="si">)</span>

pg_dump <span class="nt">-Fc</span> <span class="nt">--no-owner</span> <span class="nt">--no-privileges</span> <span class="nt">-f</span> old.dump <span class="s2">"</span><span class="nv">$OLD_URL</span><span class="s2">?sslmode=require"</span>
pg_restore <span class="nt">--no-owner</span> <span class="nt">--no-privileges</span> <span class="nt">--no-comments</span> <span class="nt">-d</span> <span class="s2">"</span><span class="nv">$NEW_URL</span><span class="s2">?sslmode=require"</span> old.dump
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">-Fc</code> asks for the custom archive format, which is compressed and is what <code class="language-plaintext highlighter-rouge">pg_restore</code> expects; a plain SQL dump works too but goes through <code class="language-plaintext highlighter-rouge">psql</code> instead. <code class="language-plaintext highlighter-rouge">--no-owner</code> and <code class="language-plaintext highlighter-rouge">--no-privileges</code> drop ownership and grant statements you cannot apply anyway, because Heroku gives you no superuser.</p>

<p>This worked on the first attempt, after two of Heroku’s own commands had not. Expect one harmless error about not owning <code class="language-plaintext highlighter-rouge">pg_stat_statements</code>, for the same superuser reason. Delete the dump file afterwards.</p>

<p>Then promote the new database, bring the app back, and check the data:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>heroku pg:promote HEROKU_POSTGRESQL_NEW_URL <span class="nt">-a</span> example-app
heroku ps:scale <span class="nv">web</span><span class="o">=</span>1 <span class="nt">-a</span> example-app
heroku maintenance:off <span class="nt">-a</span> example-app
</code></pre></div></div>

<p>Row counts are the obvious check, and the lesson I keep relearning, including <a href="https://www.fastruby.io/blog/migrating-from-heroku-to-railway">the time I moved another app from Heroku to Railway</a>. The easier one to forget is the sequence behind each primary key, because a stale sequence starts handing out ids that already exist:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>heroku pg:psql <span class="nt">-a</span> example-app <span class="nt">-c</span> <span class="s2">"select last_value from reports_id_seq"</span>
</code></pre></div></div>

<p>That shells out to a local <code class="language-plaintext highlighter-rouge">psql</code>, so it fails with “The local psql command could not be located” if you skipped the client tools. <code class="language-plaintext highlighter-rouge">heroku run rails runner</code> works instead.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article we moved a Heroku Postgres database off a deprecated version by copying it into a newly provisioned database, which also gave us a moment to reconsider the plan, and we went through the commands that failed on the way. If both <code class="language-plaintext highlighter-rouge">pg:copy</code> and <code class="language-plaintext highlighter-rouge">pg:backups:restore</code> fail for you, the local dump is there, but when the data is sensitive a support ticket is the better next step.</p>

<p>A few things worth keeping in mind. The table counts in <code class="language-plaintext highlighter-rouge">pg:info</code> lag by several minutes, so a fresh restore can report zero tables while the data is already there, and only a direct query is worth trusting. Keep the old database for a few days before destroying it, since it is your cheapest rollback. And update the Postgres image in your CI workflow, because one pinned to <code class="language-plaintext highlighter-rouge">postgres:15.17-alpine</code> keeps testing the version you just left.</p>

<p>The app whose database started all this is at <a href="https://ips.fastruby.io">ips.fastruby.io</a>. Point your <code class="language-plaintext highlighter-rouge">benchmark-ips</code> script at it and you get a shareable link for your results instead of a wall of terminal output.</p>

<p>Is your team carrying deadlines like this one behind a product roadmap that never has room for them? <a href="https://www.fastruby.io/#contactus">We can help you deal with that maintenance work</a>.</p>]]></content><author><name>juanvqz</name></author><category term="devops" /><summary type="html"><![CDATA[Got an end-of-life email from Heroku about your Postgres version? Here is how we upgraded one of our databases, how much downtime to expect, and the commands that failed on the way.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/upgrading-a-deprecated-heroku-postgres-database.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/upgrading-a-deprecated-heroku-postgres-database.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Rails Deprecations You Missed This Summer</title><link href="https://www.fastruby.io/blog/rails-deprecations-you-missed-this-summer.html" rel="alternate" type="text/html" title="The Rails Deprecations You Missed This Summer" /><published>2026-09-09T00:00:00-04:00</published><updated>2026-09-09T00:00:00-04:00</updated><id>https://www.fastruby.io/blog/rails-deprecations-you-missed-this-summer</id><content type="html" xml:base="https://www.fastruby.io/blog/rails-deprecations-you-missed-this-summer.html"><![CDATA[<p>Ruby on Rails keeps changing between releases. Five <a href="https://world.hey.com/this.week.in.rails/">This Week in Rails</a> issues rolled in eight Active Record deprecations and behavior changes on Rails main over the last few weeks. Most are small renames. One is a real bug fix that stops a write from leaking outside its association. None of it has shipped in a tagged release yet, <code class="language-plaintext highlighter-rouge">main</code> is currently versioned <code class="language-plaintext highlighter-rouge">8.2.0.alpha</code>. Rails has shipped a new minor version roughly every year (8.0 in November 2024, 8.1 in October 2025), so 8.2 landing sometime around the end of 2026 is a reasonable bet, well ahead of <code class="language-plaintext highlighter-rouge">binds</code>, which has a committed removal date of 8.3. <code class="language-plaintext highlighter-rouge">uniq!</code> is the one to not expect soon: its removal is tied to 9.0, and Rails hasn’t announced a timeline for that one yet. Nothing in your Gemfile breaks today, but you’ll want to know about it before it does. If you’ve been tracking this kind of thing, you might remember <a href="https://www.fastruby.io/blog/rails-8-1-deprecated-associations">we covered deprecated associations in Rails 8.1</a> back in July. This is the next batch.</p>

<p>I’ll get into what’s changing in each of the eight, why the Rails team made the change, and what to update in your own code once it ships. If you want the general playbook for handling deprecation warnings during an upgrade, <a href="https://www.fastruby.io/blog/rails/upgrades/deprecation-warnings-rails-guide.html">we have a guide for that too</a>.</p>

<!--more-->

<h2 id="attribute-writes-write_attributeid--is-deprecated">Attribute writes: <code class="language-plaintext highlighter-rouge">write_attribute(:id, ...)</code> is deprecated</h2>

<p>If you’re using a custom primary key, <code class="language-plaintext highlighter-rouge">write_attribute(:id, value)</code> used to translate <code class="language-plaintext highlighter-rouge">:id</code> into your actual primary key column behind the scenes:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Order</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="nb">self</span><span class="p">.</span><span class="nf">primary_key</span> <span class="o">=</span> <span class="s2">"legacy_id"</span>
<span class="k">end</span>

<span class="n">order</span> <span class="o">=</span> <span class="no">Order</span><span class="p">.</span><span class="nf">new</span>
<span class="n">order</span><span class="p">.</span><span class="nf">write_attribute</span><span class="p">(</span><span class="ss">:id</span><span class="p">,</span> <span class="mi">42</span><span class="p">)</span>
<span class="n">order</span><span class="p">.</span><span class="nf">legacy_id</span> <span class="c1"># =&gt; 42</span>
</code></pre></div></div>

<p>That’s going away. <a href="https://github.com/rails/rails/pull/58347">Pull request #58347</a> deprecates the translation. Eventually <code class="language-plaintext highlighter-rouge">write_attribute(:id, value)</code> will write straight to the <code class="language-plaintext highlighter-rouge">id</code> column instead of your custom primary key. It’s a follow-up to an <a href="https://github.com/rails/rails/pull/49019">earlier change to <code class="language-plaintext highlighter-rouge">read_attribute(:id)</code></a>, which stopped returning the custom primary key value a few versions back. The Rails team calls the old write-side behavior “most likely an oversight”, since it’s never gotten the same treatment as the read side.</p>

<p>The PR doesn’t spell out an official migration path, but one way to keep the old behavior is to write to your actual primary key attribute by name instead of through <code class="language-plaintext highlighter-rouge">:id</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">order</span><span class="p">.</span><span class="nf">write_attribute</span><span class="p">(</span><span class="ss">:legacy_id</span><span class="p">,</span> <span class="mi">42</span><span class="p">)</span>
</code></pre></div></div>

<p>Treat that as a starting point, not an official fix, and check whether <code class="language-plaintext highlighter-rouge">order.id = 42</code> (Active Record’s own primary key alias) does the job for your case too. This only affects apps with a custom or composite primary key. If your models use Rails’ default <code class="language-plaintext highlighter-rouge">id</code> column, there’s nothing to change.</p>

<h2 id="sql-internals-cleanup-positional-insert-args-binds-and-the-create-alias">SQL internals cleanup: positional <code class="language-plaintext highlighter-rouge">#insert</code> args, <code class="language-plaintext highlighter-rouge">binds</code>, and the <code class="language-plaintext highlighter-rouge">create</code> alias</h2>

<p>Four separate pull requests share the same focus: pieces of Active Record’s low-level connection API that come before Arel’s current way of handling bind parameters (the <code class="language-plaintext highlighter-rouge">?</code> placeholders in a query that carry their values separately, like <code class="language-plaintext highlighter-rouge">WHERE id = ?</code> paired with <code class="language-plaintext highlighter-rouge">1</code>) are getting deprecated.</p>

<p><a href="https://github.com/rails/rails/pull/58297">PR #58297</a> deprecates three positional arguments to <code class="language-plaintext highlighter-rouge">#insert</code>: <code class="language-plaintext highlighter-rouge">pk</code> (primary key), <code class="language-plaintext highlighter-rouge">id_value</code>, and <code class="language-plaintext highlighter-rouge">sequence_name</code>. Each gets its own warning and its own replacement:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># before: pk selects which column #insert returns</span>
<span class="n">connection</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="n">sql</span><span class="p">,</span> <span class="nb">name</span><span class="p">,</span> <span class="s2">"id"</span><span class="p">)</span>

<span class="c1"># after: use the returning: keyword instead</span>
<span class="n">connection</span><span class="p">.</span><span class="nf">insert</span><span class="p">(</span><span class="n">sql</span><span class="p">,</span> <span class="nb">name</span><span class="p">,</span> <span class="ss">returning: </span><span class="s2">"id"</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">id_value</code> used to let <code class="language-plaintext highlighter-rouge">#insert</code> echo back a value the caller already had, for cases where the database couldn’t compute the last inserted ID. If you’re passing it in, you already have the value, you don’t need it returned. <code class="language-plaintext highlighter-rouge">sequence_name</code> only mattered for a PostgreSQL <code class="language-plaintext highlighter-rouge">currval()</code> fallback tied to a config path that’s already deprecated on its own.</p>

<p>The <code class="language-plaintext highlighter-rouge">binds</code> argument (the separate array holding those placeholder values) is going away in two places. <a href="https://github.com/rails/rails/pull/58310">PR #58310</a> deprecates passing <code class="language-plaintext highlighter-rouge">binds</code> to <code class="language-plaintext highlighter-rouge">to_sql</code>, and <a href="https://github.com/rails/rails/pull/58323">PR #58323</a> deprecates it as a positional argument to <code class="language-plaintext highlighter-rouge">insert</code>, <code class="language-plaintext highlighter-rouge">update</code>, and <code class="language-plaintext highlighter-rouge">delete</code>. Both come down to the same root cause: since Rails 5.2, Arel has tracked bind values as part of the query itself, so passing <code class="language-plaintext highlighter-rouge">binds</code> in separately hasn’t done anything for years, <code class="language-plaintext highlighter-rouge">to_sql</code> never even looked at the value you gave it. The fix is to build the bind values into the SQL itself with <code class="language-plaintext highlighter-rouge">Arel.sql</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># before</span>
<span class="n">connection</span><span class="p">.</span><span class="nf">update</span><span class="p">(</span><span class="s2">"UPDATE topics SET title = ? WHERE id = 1"</span><span class="p">,</span> <span class="p">[</span><span class="n">title</span><span class="p">])</span>

<span class="c1"># after</span>
<span class="n">connection</span><span class="p">.</span><span class="nf">update</span><span class="p">(</span><span class="no">Arel</span><span class="p">.</span><span class="nf">sql</span><span class="p">(</span><span class="s2">"UPDATE topics SET title = ? WHERE id = 1"</span><span class="p">,</span> <span class="n">title</span><span class="p">))</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Arel.sql</code> can now wrap a SQL string and its bind values together, the same pattern <code class="language-plaintext highlighter-rouge">Model.where</code> already uses internally. The old positional <code class="language-plaintext highlighter-rouge">binds</code> argument is planned to be removed in Rails 8.3, so you have more time to update this one than the others.</p>

<p>Last in this group, <a href="https://github.com/rails/rails/pull/58426">PR #58426</a> deprecates the <code class="language-plaintext highlighter-rouge">create</code> alias for <code class="language-plaintext highlighter-rouge">insert</code> on connection adapters. <code class="language-plaintext highlighter-rouge">insert</code>, <code class="language-plaintext highlighter-rouge">update</code>, and <code class="language-plaintext highlighter-rouge">delete</code> map to their SQL verbs, but <code class="language-plaintext highlighter-rouge">create</code> reads like DDL (<code class="language-plaintext highlighter-rouge">create_table</code>, <code class="language-plaintext highlighter-rouge">create_database</code>) when it’s actually running an <code class="language-plaintext highlighter-rouge">INSERT</code>. If you’re calling <code class="language-plaintext highlighter-rouge">connection.create(...)</code> directly, switch to <code class="language-plaintext highlighter-rouge">connection.insert(...)</code>. Same behavior, clearer name.</p>

<h2 id="relation-changes-uniq-deprecated-and-updateupdate-now-respect-scope">Relation changes: <code class="language-plaintext highlighter-rouge">uniq!</code> deprecated, and <code class="language-plaintext highlighter-rouge">update</code>/<code class="language-plaintext highlighter-rouge">update!</code> now respect scope</h2>

<p>Two changes here, one minor cleanup and one real behavior fix, both about making a <code class="language-plaintext highlighter-rouge">Relation</code>’s behavior more consistent. This is the same family of change as <a href="https://www.fastruby.io/blog/rails/upgrades/rails-merge-deprecation.html">the Rails 6.1 <code class="language-plaintext highlighter-rouge">merge</code> deprecation</a> we ran into a while back.</p>

<p><a href="https://github.com/rails/rails/pull/58525">PR #58525</a> deprecates <code class="language-plaintext highlighter-rouge">Relation#uniq!</code>. Rails added it back to help with the move to automatic deduplication of multi-value query methods (<code class="language-plaintext highlighter-rouge">SELECT DISTINCT</code> and friends), a Rails 7.0 feature. Deduplication happens automatically now, so the method doesn’t do anything useful anymore. Rails plans to remove it in 9.0. If you’re on Rails 7.0 or newer and still have <code class="language-plaintext highlighter-rouge">uniq!</code> calls lying around, they’re safe to delete today.</p>

<p>The bigger one is <a href="https://github.com/rails/rails/pull/58320">PR #58320</a>, which changes how <code class="language-plaintext highlighter-rouge">update</code> and <code class="language-plaintext highlighter-rouge">update!</code> behave when you call them with an ID on a scoped relation:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">post</span><span class="p">.</span><span class="nf">comments</span><span class="p">.</span><span class="nf">update!</span><span class="p">(</span><span class="n">comment_id</span><span class="p">,</span> <span class="ss">body: </span><span class="s2">"edited"</span><span class="p">)</span>
</code></pre></div></div>

<p>Before this change, that call delegated to the model class method (in this case <code class="language-plaintext highlighter-rouge">Comment.update!</code>), which resolved <code class="language-plaintext highlighter-rouge">comment_id</code> against the whole <code class="language-plaintext highlighter-rouge">comments</code> table, not just the ones belonging to <code class="language-plaintext highlighter-rouge">post</code>. If <code class="language-plaintext highlighter-rouge">comment_id</code> pointed at a comment on a different post, the update would go through anyway. The Rails team’s commit message says it directly: an update through an association could silently write to a record that belonged to a different post, even though the association was supposed to scope it.</p>

<p>After this change, <code class="language-plaintext highlighter-rouge">update</code> and <code class="language-plaintext highlighter-rouge">update!</code> move to the <code class="language-plaintext highlighter-rouge">Relation</code> class and respect its scope the same way <code class="language-plaintext highlighter-rouge">update_all</code>, <code class="language-plaintext highlighter-rouge">delete</code>, and <code class="language-plaintext highlighter-rouge">destroy</code> already do. If the ID isn’t in the relation’s scope, Rails raises <code class="language-plaintext highlighter-rouge">ActiveRecord::RecordNotFound</code> instead of writing to a record you didn’t mean to touch.</p>

<p>The fix cuts both ways. The Rails team calls the second half “the same bug seen from the other side”: <code class="language-plaintext highlighter-rouge">Model.unscoped.update(id, attributes)</code> now actually stops applying your model’s default scope too. Before, <code class="language-plaintext highlighter-rouge">update</code>/<code class="language-plaintext highlighter-rouge">update!</code> ignored whatever scope you called it through, whether that scope was meant to narrow your options (an association) or remove them (<code class="language-plaintext highlighter-rouge">unscoped</code>). Now it honors the relation’s real scope either way, so it’s also worth testing any <code class="language-plaintext highlighter-rouge">unscoped.update</code> call that was relying on a default scope to still filter things out.</p>

<p>This is the one worth actually testing for. Does your app call <code class="language-plaintext highlighter-rouge">update</code>/<code class="language-plaintext highlighter-rouge">update!</code> with an ID through an association anywhere, where that ID could realistically belong to a different parent record? That call will start raising once this ships. It’s the right behavior, but it’s a behavior change, not just a rename.</p>

<h2 id="schema-config-consolidation-schema_ignored_tables">Schema config consolidation: <code class="language-plaintext highlighter-rouge">schema_ignored_tables</code></h2>

<p>If you’ve ever needed to keep a table out of <code class="language-plaintext highlighter-rouge">schema.rb</code> and out of the schema cache, you’ve probably set two separate options:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">config</span><span class="p">.</span><span class="nf">active_record</span><span class="p">.</span><span class="nf">schema_cache_ignored_tables</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"audit_logs"</span><span class="p">]</span>
<span class="no">ActiveRecord</span><span class="o">::</span><span class="no">SchemaDumper</span><span class="p">.</span><span class="nf">ignore_tables</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"audit_logs"</span><span class="p">]</span>
</code></pre></div></div>

<p><a href="https://github.com/rails/rails/pull/58554">PR #58554</a> merges both into one:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">config</span><span class="p">.</span><span class="nf">active_record</span><span class="p">.</span><span class="nf">schema_ignored_tables</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"audit_logs"</span><span class="p">]</span>
</code></pre></div></div>

<p>The reasoning is simple: the two old options were sort of doing the same job, and the PR description asks the obvious question, why would you want to ignore a table from schema caching but not from the dumper, or the other way around? Both old configs still work for now, they delegate to the new one and print a deprecation warning, but if you set both, only the last one you assign wins.</p>

<p>One thing to watch: table matching now happens against the actual database table name, not the logical name with your <code class="language-plaintext highlighter-rouge">table_name_prefix</code>/<code class="language-plaintext highlighter-rouge">table_name_suffix</code> removed. If your app uses either of those and you’re ignoring a table by its logical name, double check the entry still matches once you switch to <code class="language-plaintext highlighter-rouge">schema_ignored_tables</code>. If schema config in general feels unfamiliar, <a href="https://www.fastruby.io/blog/dealing-with-schema-changes-in-rails-7">we’ve covered the other schema changes that landed in Rails 7</a> too.</p>

<h2 id="conclusion">Conclusion</h2>

<p>All eight of these are sitting on Rails main right now, not in a tagged release, so nothing in your app breaks today. <code class="language-plaintext highlighter-rouge">write_attribute(:id, ...)</code>, the <code class="language-plaintext highlighter-rouge">#insert</code>/<code class="language-plaintext highlighter-rouge">to_sql</code>/<code class="language-plaintext highlighter-rouge">create</code> cleanup, <code class="language-plaintext highlighter-rouge">uniq!</code>, and the <code class="language-plaintext highlighter-rouge">schema_ignored_tables</code> merge are all low-risk renames you can get ahead of whenever you have a few minutes. The one to actually go test for is the <code class="language-plaintext highlighter-rouge">update</code>/<code class="language-plaintext highlighter-rouge">update!</code> scope change. If any of your code writes to a record by ID through an association, check now whether that ID could ever belong to something outside the association’s scope, before Rails starts raising on it for you. Once you’ve fixed these, <a href="https://www.fastruby.io/blog/custom-deprecation-behavior">here’s how to keep the fixed ones from creeping back into your codebase</a>.</p>

<p>Has your team been struggling with an upgrade? <a href="https://www.fastruby.io/#contactus">We can help.</a></p>]]></content><author><name>juliolucero</name></author><category term="upgrades" /><summary type="html"><![CDATA[A round-up of eight Active Record deprecations landing on Rails main, from a write_attribute(:id) follow-up to a scope-enforcement fix on update and update!]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/the-rails-deprecations-you-missed-this-summer.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/the-rails-deprecations-you-missed-this-summer.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Turning Audit Findings into CI Checks</title><link href="https://www.fastruby.io/blog/turning-audit-findings-into-ci-checks.html" rel="alternate" type="text/html" title="Turning Audit Findings into CI Checks" /><published>2026-09-03T06:30:00-04:00</published><updated>2026-09-03T06:30:00-04:00</updated><id>https://www.fastruby.io/blog/turning-audit-findings-into-ci-checks</id><content type="html" xml:base="https://www.fastruby.io/blog/turning-audit-findings-into-ci-checks.html"><![CDATA[<p>You get a site audit report and it looks manageable. A few dozen findings, most of them small: a page with barely any text on it, a link whose text is just “here”, a page whose title tag is a copy of its H1, a hero image heavy enough to hurt the <a href="https://www.fastruby.io/blog/lcp">largest contentful paint</a>. None of it is that hard. You spend an afternoon on it, close the tickets, and move on.</p>

<p>Then a few months pass, a dozen new pages ship, and the next audit reports the same findings again. Not because anyone ignored the first round, but because the first round fixed pages instead of fixing the process that produces pages.</p>

<p>That happened to us, on this site and on <a href="https://www.ombulabs.ai">OmbuLabs.ai</a>. So the second time around we spent the effort somewhere else. Instead of just fixing the pages, we wrote checks that run on every build and say when a new page has the same problem. It is roughly the same idea as <a href="https://www.fastruby.io/blog/tech-debt-audit-with-claude-code">automating a tech debt audit</a>: most of the value is not in the report, it is in being able to produce the report again for free. No two of them wanted the same kind of check.</p>

<p>The goal here is search traffic, not a clean report. Thin pages, vague link text, and duplicated title tags are the things that hold a page back in search results, and a page that ships with them costs us traffic until the next audit finds it. A check on every build moves that discovery from months later to the pull request.</p>

<p>In this article, you will learn how we turned three kinds of audit findings into checks that run in CI.</p>

<!--more-->

<h2 id="word-count-text-ratio-and-semantic-ratio">Word count, text ratio, and semantic ratio</h2>

<p>The first group of findings was about the content itself, the pages a crawler treats as thin. By crawler I mean Googlebot, Bing and the AI crawlers that feed answer engines. There are three metrics that matter here:</p>

<p><strong>Word count.</strong> How many words the page actually says.</p>

<p><strong>Text ratio.</strong> The size of the visible text divided by the size of the whole HTML file. A page can look full to you and still be 5% words and 95% tags, and a crawler reads that as a page built to hold a layout rather than to say something.</p>

<p><strong>Semantic ratio.</strong> How many of the page’s tags are ones that carry meaning (<code class="language-plaintext highlighter-rouge">h2</code>, <code class="language-plaintext highlighter-rouge">p</code>, <code class="language-plaintext highlighter-rouge">ul</code>, <code class="language-plaintext highlighter-rouge">article</code>, <code class="language-plaintext highlighter-rouge">footer</code>) instead of plain <code class="language-plaintext highlighter-rouge">div</code> and <code class="language-plaintext highlighter-rouge">span</code>. Semantic tags are what tell a crawler which part is a heading and which part is the body. A page made of nested <code class="language-plaintext highlighter-rouge">div</code>s says nothing about its own structure.</p>

<p>We put all three in one class, <code class="language-plaintext highlighter-rouge">ContentQualityChecker</code>, that parses the page with Nokogiri, with the thresholds at the top:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ContentQualityChecker</span>
  <span class="no">WORD_COUNT_MIN</span> <span class="o">=</span> <span class="mi">200</span>
  <span class="no">TEXT_RATIO_MIN</span> <span class="o">=</span> <span class="mf">0.10</span>
  <span class="no">SEMANTIC_RATIO_MIN</span> <span class="o">=</span> <span class="mf">0.115</span>

  <span class="no">SEMANTIC_ELEMENTS</span> <span class="o">=</span> <span class="sx">%w[
    header nav main article section aside footer figure figcaption time mark details summary
    h1 h2 h3 h4 h5 h6 ul ol li p blockquote table tr td th
  ]</span><span class="p">.</span><span class="nf">freeze</span>
</code></pre></div></div>

<p>Those numbers are not set in stone. They are in the range the common audit tools complain about, and they are low enough that a real article never trips them, which is what you actually want from a threshold. If yours flag half your posts, they are set too high for your layout.</p>

<p>The one number that needs care is the word count. Counting the whole <code class="language-plaintext highlighter-rouge">&lt;body&gt;</code> sounds right but it is not, because every page carries the same nav, sidebar, and footer, and on our pages that added roughly 40% to the number. Thin pages looked fine. So the count cuts out everything but the main content area, <code class="language-plaintext highlighter-rouge">doc.at("main")</code>, falling back to the body for anything without one.</p>

<p>With the checker in place the rest is two rake tasks, and they do not work the same way. Our blog is a Jekyll build, so the pages are already HTML on disk and that task just globs the files and needs nothing but Nokogiri. Rails pages only exist once something renders them, so that task opens an <code class="language-plaintext highlighter-rouge">ActionDispatch::Integration::Session</code> and requests each path the way a visitor would. It is the cheapest way we found to measure a rendered page without a browser in the loop.</p>

<p>The report prints one line per flagged page, worst first:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/blog/tags/railsconf                  195w  text  11.0%  semantic  16.2%  low word count
/blog/fortify-rails-security-webinar  280w  text   9.7%  semantic  25.8%  low text ratio
</code></pre></div></div>

<p>Today it flags 20 pages out of the 304 it measures on the blog, and most of them are edge cases we are fine with. Tag and author listings are thin because they are navigation, not articles. A group of long posts from years ago trips the semantic ratio because their markup is older than the current layout. Expect a tail like that of your own, every site has one. The report is there to tell the pages that are thin on purpose from the one somebody shipped last week without noticing.</p>

<h2 id="anchor-text-that-describes-the-destination">Anchor text that describes the destination</h2>

<p>The second group is the interesting one, because the audit finding and an accessibility bug turn out to be the same bug.</p>

<p>Anchor text is the visible words inside a link. A crawler just extracts that text, so <code class="language-plaintext highlighter-rouge">click here</code> gets read as exactly that, “click here”, with no information about where the link takes you. A screen reader user gets the same nothing, out of context, from a list of links on the page.</p>

<p>Two findings came up. Links with no anchor text at all, and links whose anchor text describes nothing: <code class="language-plaintext highlighter-rouge">here</code>, <code class="language-plaintext highlighter-rouge">click here</code>, <code class="language-plaintext highlighter-rouge">read more</code>, <code class="language-plaintext highlighter-rouge">learn more</code>, <code class="language-plaintext highlighter-rouge">details</code>, <code class="language-plaintext highlighter-rouge">download</code>, <code class="language-plaintext highlighter-rouge">continue</code>. If you have read anything about accessibility, and I covered some of this in <a href="https://www.fastruby.io/blog/usability-and-accessibility-for-better-user-experience">Usability Meets Accessibility</a> and <a href="https://www.fastruby.io/blog/testing-for-accessibility">From Code to Compliance: Accessibility Testing</a>, that list is familiar. The way people usually try to fix the first one misses the point.</p>

<p>Take an icon-only link, a social icon in the footer for example. It has no text node at all. The first thing people try is an <code class="language-plaintext highlighter-rouge">aria-label</code> on the <code class="language-plaintext highlighter-rouge">&lt;a&gt;</code>, or <code class="language-plaintext highlighter-rouge">alt</code> text on the image inside it, and then they consider it handled. For a screen reader that mostly works. For a crawler it does not, because it reads the anchor’s text content, and neither an attribute on the link nor an attribute on a child image is text content. The fix that works for both readers is a real text node that happens to be invisible: a <code class="language-plaintext highlighter-rouge">&lt;span&gt;</code> with the <code class="language-plaintext highlighter-rouge">sr-only</code> class, a CSS convention that pushes text off screen while leaving it in the document. The name says “screen reader only”, but it fixes crawlers too: both read text content, not what is visible on screen.</p>

<p>Our scroll-to-top link is the smallest example. Before, an image and an attribute:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">class=</span><span class="s">"scrollto"</span> <span class="na">href=</span><span class="s">"#top"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"circle-black-lg.svg"</span> <span class="na">alt=</span><span class="s">"Scroll to top"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<p>After, the image is marked as decoration and the words live in the document:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">class=</span><span class="s">"scrollto"</span> <span class="na">href=</span><span class="s">"#top"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"circle-black-lg.svg"</span> <span class="na">alt=</span><span class="s">""</span> <span class="na">aria-hidden=</span><span class="s">"true"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"sr-only"</span><span class="nt">&gt;</span>Scroll to top<span class="nt">&lt;/span&gt;</span>
<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<p>Nothing changes on screen. The difference is that the second link has anchor text and the first one has none.</p>

<p>Then there was the part that gave me the most headaches, and it came from one of our own gems. Our blog builds with <a href="https://github.com/fastruby/jekyll-external-link-accessibility">jekyll-external-link-accessibility</a>, a plugin we maintain that appends a note to every external link so screen reader users know it opens a new tab. That is a good thing to do, and it also means that by the time the checker sees the HTML, a link reading “here” reads “here opens a new window”, which matches nothing. Every non-descriptive link on the blog passed clean. So the text has to be cleaned up before it is judged:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">DECORATION</span> <span class="o">=</span> <span class="sr">/\s*opens a new window\s*\z/i</span>

<span class="k">def</span> <span class="nf">visible_text</span><span class="p">(</span><span class="n">anchor</span><span class="p">)</span>
  <span class="n">stripped</span> <span class="o">=</span> <span class="n">anchor</span><span class="p">.</span><span class="nf">dup</span>
  <span class="n">stripped</span><span class="p">.</span><span class="nf">css</span><span class="p">(</span><span class="s2">"[aria-hidden='true']"</span><span class="p">).</span><span class="nf">each</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:remove</span><span class="p">)</span>
  <span class="n">stripped</span><span class="p">.</span><span class="nf">text</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="sr">/\s+/</span><span class="p">,</span> <span class="s2">" "</span><span class="p">).</span><span class="nf">strip</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="no">DECORATION</span><span class="p">,</span> <span class="s2">""</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">aria-hidden</code> removal is the same idea running the other way. An icon marked <code class="language-plaintext highlighter-rouge">aria-hidden="true"</code> is decoration nobody hears, so it should not count as anchor text either.</p>

<h2 id="a-baseline-spec-for-page-metadata">A baseline spec for page metadata</h2>

<p>The third group was metadata. A rake task is the wrong tool here. There is no threshold to report on, the tags are either present or they are not. So this one is a request spec. It requests each public page and asserts the tags a crawler and a link preview need:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">aggregate_failures</span><span class="p">(</span><span class="s2">"SEO/social baseline for </span><span class="si">#{</span><span class="n">path</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">expect</span><span class="p">(</span><span class="n">body</span><span class="p">).</span><span class="nf">to</span> <span class="n">match</span><span class="p">(</span><span class="sr">/&lt;meta\s+name="description"\s+content="[^"]+"/</span><span class="p">),</span>
    <span class="sx">%(Missing or empty &lt;meta name="description"&gt; on </span><span class="si">#{</span><span class="n">path</span><span class="si">}</span><span class="sx">. Add static.&lt;action&gt;.description in config/locales/en.yml.)</span>

  <span class="n">expect</span><span class="p">(</span><span class="n">body</span><span class="p">).</span><span class="nf">not_to</span> <span class="n">match</span><span class="p">(</span><span class="sr">/translation missing/i</span><span class="p">),</span>
    <span class="sx">%(Found an unresolved I18n key ("translation missing") on </span><span class="si">#{</span><span class="n">path</span><span class="si">}</span><span class="sx">. Add the missing key in config/locales/en.yml.)</span>

  <span class="n">expect</span><span class="p">(</span><span class="n">body</span><span class="p">).</span><span class="nf">to</span> <span class="n">match</span><span class="p">(</span><span class="sr">/&lt;meta\s+property="og:url"\s+content="http[^"]+"/</span><span class="p">),</span>
    <span class="sx">%(Missing or non-canonical og:url on </span><span class="si">#{</span><span class="n">path</span><span class="si">}</span><span class="sx">. It should be the full URL of the page, not a hardcoded value.)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>There are a dozen more like that, covering the rest of the <code class="language-plaintext highlighter-rouge">og:</code> tags and the JSON-LD blocks. <code class="language-plaintext highlighter-rouge">aggregate_failures</code> reports everything wrong with the page in one run, and every message names where the fix goes, down to the locale key. A spec that fails with “expected false to equal true” gets skipped by the next person who hits it.</p>

<p>None of that matters if the spec never visits the page. Keep a list of the paths left out on purpose, with a reason for each one, so a new page never slips through unnoticed:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">IGNORED_PATHS</span> <span class="o">=</span> <span class="p">{</span>
  <span class="s2">"/search"</span> <span class="o">=&gt;</span> <span class="s2">"redirects to /blog when query is blank, so does not render HTML"</span><span class="p">,</span>
  <span class="s2">"/success"</span> <span class="o">=&gt;</span> <span class="s2">"redirects to /roadmap after Stripe checkout"</span><span class="p">,</span>
  <span class="s2">"/robots.txt"</span> <span class="o">=&gt;</span> <span class="s2">"non-HTML"</span><span class="p">,</span>
  <span class="s2">"/sitemap.xml"</span> <span class="o">=&gt;</span> <span class="s2">"non-HTML"</span>
<span class="p">}.</span><span class="nf">freeze</span>
</code></pre></div></div>

<p>Writing the reason down is worth more than it looks. An ignore list without reasons becomes a place to hide failures, and six months later nobody remembers whether <code class="language-plaintext highlighter-rouge">/success</code> is skipped because it redirects or because someone was in a hurry.</p>

<p>The list only works if it cannot go stale, so one example asks the router what exists and compares. Trimmed a little:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">it</span> <span class="s2">"covers every public GET route or lists it in IGNORED_PATHS"</span> <span class="k">do</span>
  <span class="n">discovered</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">flat_map</span> <span class="k">do</span> <span class="o">|</span><span class="n">route</span><span class="o">|</span>
    <span class="k">next</span> <span class="p">[]</span> <span class="k">unless</span> <span class="no">Array</span><span class="p">(</span><span class="n">route</span><span class="p">.</span><span class="nf">verb</span><span class="p">).</span><span class="nf">first</span><span class="p">.</span><span class="nf">to_s</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="s2">"GET"</span><span class="p">)</span>

    <span class="n">spec</span> <span class="o">=</span> <span class="n">route</span><span class="p">.</span><span class="nf">path</span><span class="p">.</span><span class="nf">spec</span><span class="p">.</span><span class="nf">to_s</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="sr">/\(\.:format\)\z/</span><span class="p">,</span> <span class="s2">""</span><span class="p">)</span>
    <span class="k">next</span> <span class="p">[]</span> <span class="k">if</span> <span class="n">spec</span><span class="p">.</span><span class="nf">match?</span><span class="p">(</span><span class="sr">/[:*]/</span><span class="p">)</span>

    <span class="n">controller</span> <span class="o">=</span> <span class="n">route</span><span class="p">.</span><span class="nf">defaults</span><span class="p">[</span><span class="ss">:controller</span><span class="p">].</span><span class="nf">to_s</span>
    <span class="k">next</span> <span class="p">[]</span> <span class="k">if</span> <span class="n">ignored_controller_prefixes</span><span class="p">.</span><span class="nf">any?</span> <span class="p">{</span> <span class="o">|</span><span class="n">prefix</span><span class="o">|</span> <span class="n">controller</span><span class="p">.</span><span class="nf">start_with?</span><span class="p">(</span><span class="n">prefix</span><span class="p">)</span> <span class="p">}</span>

    <span class="p">[</span><span class="n">spec</span><span class="p">]</span>
  <span class="k">end</span><span class="p">.</span><span class="nf">uniq</span>

  <span class="n">expect</span><span class="p">(</span><span class="n">discovered</span> <span class="o">-</span> <span class="p">(</span><span class="no">PUBLIC_PAGE_PATHS</span> <span class="o">+</span> <span class="no">IGNORED_PATHS</span><span class="p">.</span><span class="nf">keys</span><span class="p">)).</span><span class="nf">to</span> <span class="n">be_empty</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Routes with parameters are skipped, since you cannot request them without making up a record, and so are admin, API, and framework controllers, since nothing there is meant to be indexed. Everything else has to be in one list or the other. Add a public page and the spec fails with the path in the message. It fails the other way too, when a listed path no longer exists in <code class="language-plaintext highlighter-rouge">routes.rb</code>, so old pages cannot stay in the list.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article, we went through three ways to keep an audit finding from coming back, so the pages we ship keep earning search traffic instead of losing it a little at a time. By setting a concrete metric or rule for each kind of finding, a word count and markup ratio for content, a matching tag for metadata, we could check it automatically in CI on every pull request. That let us tell which problems needed fixing right away and which ones were fine to track and fix over time.</p>

<p>Automate the metadata spec first. It pays for itself the first time somebody adds a page in a hurry.</p>

<p>Is your team carrying a backlog of findings that nobody has time to keep fixed? <a href="https://www.fastruby.io/monthly-rails-maintenance">We can take that off your hands</a>, send us a message.</p>]]></content><author><name>juliolucero</name></author><category term="best-practices" /><summary type="html"><![CDATA[A site audit is a snapshot. Here is how we turned thin-content, anchor-text, and page-metadata findings into rake tasks and specs, so the same findings stop coming back.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/turning-audit-findings-into-ci-checks.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/turning-audit-findings-into-ci-checks.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">How to Avoid APM Bill Surprises</title><link href="https://www.fastruby.io/blog/how-to-avoid-apm-bill-surprises.html" rel="alternate" type="text/html" title="How to Avoid APM Bill Surprises" /><published>2026-09-01T16:01:29-04:00</published><updated>2026-09-01T16:01:29-04:00</updated><id>https://www.fastruby.io/blog/how-to-avoid-apm-bill-surprises</id><content type="html" xml:base="https://www.fastruby.io/blog/how-to-avoid-apm-bill-surprises.html"><![CDATA[<p>You approved an APM tool at a modest monthly rate. A year later, the renewal invoice bears little resemblance to what you signed, and nobody remembers deciding to spend that much more.</p>

<p>APM is worth having when it’s used well: it resolves incidents faster by showing you where in the stack a problem started, it gives you warning before a threshold alert turns into an outage, and it gives you a defensible answer when a client or stakeholder asks whether you hit your SLA (often measured through <a href="https://www.fastruby.io/blog/performance/response-times-and-what-to-make-of-their-percentile-values.html">percentile response times</a> like p95 and p99). None of that is in question here. What tends to go unexamined is whether the bill still matches what you’re getting for it.</p>

<p>In this article, we’ll walk through where APM spend typically concentrates, the warning signs that a bill has drifted from usage-driven growth into unmanaged creep, and a concrete checklist for getting ahead of it before your next renewal.</p>

<!--more-->

<h2 id="where-the-money-goes">Where the Money Goes</h2>

<p>Annual APM spend scales with organization size, and the gap between the low end and the high end is wide. As a rough illustration: a small startup running a handful of hosts might land in the low five figures a year, a mid market company running dozens of hosts often lands in the mid five to low six figures, and a large enterprise running hundreds of hosts across multiple environments can move well into six or seven figures. Treat these as illustrative bands, not a quote; your own host count, environment count, and data volume are what actually set the number.</p>

<p>APM monthly spend usually concentrates in a few areas. The exact split depends on the size of your organization and how log or host heavy your workload is, so treat the following as a description of where the money tends to go, not shares that add up to 100%.</p>

<p>Host and container monitoring is the steady, predictable baseline, since it scales directly with host count and rarely surprises anyone. Data ingestion for metrics and traces adds a smaller share on top, and scales with traffic. Log ingestion is where the real growth happens, and it often ends up the largest line on the bill, because it’s priced at a premium and billed by volume rather than by host.</p>

<p>The rest are smaller but add up: custom metrics (billed per metric per host, and easy to accumulate without anyone noticing), retention that multiplies ingestion cost rather than showing up on its own line, unused user seats sitting as dead cost, and synthetic or RUM (Real User Monitoring) checks that can spike if uncapped.</p>

<p>None of this is unreasonable on its own. The trouble starts when nobody is responsible for where those costs compound over time.</p>

<h2 id="where-the-cost-hides">Where the Cost Hides</h2>

<p>This is the part that catches executives off guard, because it rarely shows up as a single suspicious charge. It shows up as gradual drift, and it tends to hide in the same handful of places.</p>

<p>The data itself is the usual culprit. Custom metrics get priced at a premium and added incrementally without anyone tracking the cumulative bill, and high cardinality tags (user IDs, request IDs, and the like) multiply billing under per series pricing (each unique combination of tags counted as its own billable metric), often invisibly. Logs are the big one: priced far higher than metrics or traces and billed by volume rather than by host, so a single verbose service, especially one left on DEBUG level logging in production, can generate a disproportionate share of your billable data relative to its actual footprint. This is one of the most common places log spend runs away from teams.</p>

<p>The contract and the environment hide the rest. Usage beyond your committed or budgeted volume typically bills at a higher rate, whether that’s an on demand rate above a negotiated price or the next pricing tier on a self-serve plan. The exact multiplier is contract and vendor specific; check yours rather than assuming a standard rate. Uncapped auto scaling settings let billing grow with no ceiling at all, negotiated or otherwise. Meanwhile the waste you have stopped noticing adds up: non production environments instrumented at the production tier, agents (the small process each host runs to report data back to the vendor) left running on decommissioned services and still billing for data nobody uses, and redundant coverage across APM, logging, and infrastructure platforms that means paying twice for the same signal.</p>

<p>Individually, each of these is minor. Together, over a year, they are the difference between a tool that pays for itself and one that becomes a budget problem nobody can explain.</p>

<h2 id="cost-leak-warning-signs">Cost Leak Warning Signs</h2>

<p>Whoever manages the tool day to day usually spots the drift first, since it shows up as more logs enabled, more custom metrics added, or an extra environment instrumented. Whoever pays the bill only sees the invoice. If those aren’t the same person, and they aren’t comparing notes when they’re not, nobody can tell a legitimate increase from a creeping one until the bill is already high enough to raise a question. You don’t need to be an APM expert to catch it, you need whoever’s watching usage and whoever’s watching spend looking at the same signals.</p>

<p>The financial tells come first: invoice variance with no matching change in infrastructure or business growth, and a flat host count next to a rising bill, which points to volume or feature growth rather than scale.</p>

<p>The contractual and structural ones are just as telling: plan or contract terms with no ceiling or advance notice clause on overages, licensed seats or modules nobody uses, APM spend sitting next to overlapping logging or observability tooling, and production tier instrumentation still running on non production environments. Underneath all of them is the same root cause: when no single person is accountable for this line item, it goes unmanaged by default, not by decision.</p>

<h2 id="apm-renewal-preparation">APM Renewal Preparation</h2>

<p>These costs go unnoticed because no single vantage point sees the whole picture. Someone needs to know the instrumentation scope, retention settings, and which environments are monitored. Someone needs to track spend against usage over time. Someone needs to know the contract terms, renewal date, and any negotiated caps. And someone needs to reconcile the invoice against budget and catch variance early. At a larger company those are four different people, in engineering, FinOps, procurement, and finance. At a smaller one, it might be the same engineer or founder wearing all four hats, which makes it easier to miss the gap between what one hat knows and what another hat is tracking.</p>

<p>In practice, whoever owns the budget for this line item usually drives the renewal, whether that’s a dedicated FinOps team or the person who first approved the tool. Either way, preparing usage data on a regular cadence, quarterly if you can manage it, beats scrambling at contract time. The preparation itself comes down to six steps:</p>

<ol>
  <li>Audit actual usage against contract terms, starting 60 to 90 days out (or ahead of your next plan review if you’re on a self-serve plan without a fixed renewal date). Pull 12 months of usage (host count at peak, not average; ingestion volume by category for metrics, traces, and logs; and retention actually used versus contracted) and flag any gap between what you’re paying for and what you’re actually using, in either direction.</li>
  <li>Identify what is inflating the bill. Check log ingestion volume specifically, look for orphaned agents on decommissioned hosts still reporting data, watch custom metric count and high cardinality tags for creep, and confirm non production environments are not running production tier instrumentation.</li>
  <li>Right size before renewing, not after. Cut unused seats, disable unused modules, reduce retention windows that exceed actual need, and turn off verbose logging left on in production, so you negotiate, or simply re-subscribe, from your actual usage number rather than the inflated one.</li>
  <li>Review the terms themselves, not just the price, whether or not you have a negotiated contract. Look at the overage rate versus the base rate (push for a cap or a lower multiplier if you’re in a position to negotiate one), any auto scaling clause and whether it carries advance notice or a ceiling, how often actual usage is reconciled against the plan, and termination and data export terms that confirm you are not locked in if you switch later.</li>
  <li>Benchmark before deciding. Get at least one competitive quote (Datadog, New Relic, Grafana Cloud, and so on), even if you do not intend to switch, and bring your actual usage number to the table, not the vendor’s projected one. If the bill has fully outgrown what you’re getting from it, <a href="https://www.fastruby.io/blog/open-source-apms">self-hosting your observability stack</a> is also worth pricing out, not just switching vendors.</li>
  <li>Assign an owner. One person, whether that’s a dedicated FinOps lead or simply whoever manages the account, owns the renewal outcome and stays accountable for usage monitoring afterward.</li>
</ol>

<p>That’s the same group as the quarterly cost review, however many people that group actually is; a renewal is just its highest leverage version.</p>

<h2 id="conclusion">Conclusion</h2>

<p>APM cost creep is rarely about APM. It’s a symptom of a familiar pattern in infrastructure decisions generally: tools get adopted quickly under real pressure, nobody assigns ongoing ownership, and by the time the cost or the technical debt is visible, it’s expensive to unwind.</p>

<p>If your APM bill has grown faster than your infrastructure, that’s worth a look. The tool itself is probably fine; the growth was likely never a decision at all, just the default outcome of nobody watching it.</p>

<p>That’s usually true of the surrounding infrastructure too. If you’re re-examining monitoring spend, it’s a good moment to ask the same question about your broader stack: what’s running today because someone chose it, and what’s running today because nobody revisited it?</p>

<p><em>Is your APM bill outgrowing your infrastructure? <a href="https://www.fastruby.io/#contactus">We can help</a> you audit where it’s leaking.</em></p>]]></content><author><name>shpm55</name></author><category term="best-practices" /><summary type="html"><![CDATA[APM bills grow because usage creeps up unmanaged, not because the tool changed. See where the spend actually goes, the warning signs to catch, and a renewal-prep checklist.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/image-fast-ruby-blog-real-cost-apm.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/image-fast-ruby-blog-real-cost-apm.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Repay Tech Debt with the Strangler Fig Pattern</title><link href="https://www.fastruby.io/blog/how-the-strangler-fig-pattern-helps-teams-pay-down-technical-debt.html" rel="alternate" type="text/html" title="Repay Tech Debt with the Strangler Fig Pattern" /><published>2026-08-28T05:23:08-04:00</published><updated>2026-08-28T05:23:08-04:00</updated><id>https://www.fastruby.io/blog/how-the-strangler-fig-pattern-helps-teams-pay-down-technical-debt</id><content type="html" xml:base="https://www.fastruby.io/blog/how-the-strangler-fig-pattern-helps-teams-pay-down-technical-debt.html"><![CDATA[<p>Replacing a business-critical legacy system does not have to mean committing to a risky, all-at-once rewrite. The strangler fig pattern offers a practical alternative: migrate one capability at a time, run the old and new systems side by side, and gradually retire the legacy code as each replacement is validated. This post walks through how it works, where it fits, and a practical Rails-based example of migrating one piece of functionality without stopping the business to do it.</p>

<!--more-->

<h2 id="the-problem-a-business-critical-system-youre-afraid-to-touch">The Problem: A Business-Critical System You’re Afraid to Touch</h2>

<p>Most organizations don’t wake up one day and decide to replace a legacy system. It happens gradually, as the cost and <a href="/blog/hidden-costs-of-technical-debt">risk of maintaining the system tends to increase over time</a>.</p>

<p>One common problem is that the application becomes difficult to change. As a result, teams may avoid making necessary improvements because the risk of introducing regressions is too high. Older dependencies may no longer be supported and infrastructure may require specialized operational knowledge. Security vulnerabilities accumulate. Old dependencies stop receiving patches, frameworks fall out of active support, and the gap between “known vulnerable” and “actually fixed” widens.</p>

<p>Despite these problems, replacing the application can be risky. The system may contain years of accumulated business logic, including edge cases that are not documented anywhere else. A complete replacement must account for those behaviors while continuing to support current users, integrations, and operational requirements. This makes a full rewrite difficult to estimate and potentially disruptive.</p>

<p>Doing nothing, however, is not a sustainable strategy. Technical debt continues to accumulate as dependencies age, knowledge is lost, and temporary workarounds become permanent parts of the application. Over time, the organization has fewer safe options and less flexibility in deciding how and when to replace the system.</p>

<h2 id="why-just-rewrite-it-doesnt-work">Why “Just Rewrite It” Doesn’t Work</h2>

<p>Faced with a system like this, the instinct is often to start fresh: freeze the old system, build a new one properly, and cut over once it’s ready. In practice, this approach carries its own set of serious risks, which is why full rewrites so often stall, run over budget, or get abandoned partway through.</p>

<p>Instead of a series of small, reversible changes, the business is betting on one large release, often after months (or years) of work, with no real-world validation until the very end. If something is wrong it surfaces all at once, after the cost of building the replacement has already been paid.</p>

<p>It is also rarely practical to freeze feature development while the replacement is being built. The existing application still supports an active business, and users continue to request changes. Regulations, market conditions, internal processes, and customer expectations may also change during the rewrite. As a result, the legacy system continues to evolve while the new system is under development.</p>

<p>If the old system keeps changing while the new one is being built, then the team is effectively maintaining two systems in parallel (one in production, one in development). This work grows more difficult the longer the rewrite takes, and it’s easy for parity to quietly erode without anyone noticing until launch.</p>

<h2 id="the-strangler-fig-pattern">The Strangler Fig Pattern</h2>

<p>The name comes from a real botanical phenomenon: strangler fig vines take root in the branches of a host tree, gradually growing around it. Over years, the fig develops its own root and trunk system, until eventually it no longer needs the host at all. Applied to software, this idea gives us a middle path between keeping a legacy system indefinitely and replacing it all at once.</p>

<p>The mechanics are straightforward:</p>

<ol>
  <li>First <strong>create a seam</strong>, a well-defined point where requests or behavior can be intercepted, such as a router, API gateway, or facade layer.</li>
  <li>Next <strong>intercept requests at that seam</strong> and decide, for each one, whether it should be handled by the legacy system or the new implementation.</li>
  <li>Then <strong>redirect traffic to the new implementation gradually</strong>, one slice of functionality at a time, validating each piece before moving to the next.</li>
  <li>Finally <strong>decommission the corresponding old code path</strong> once a piece has been fully and reliably replaced.</li>
</ol>

<p>The old and new systems therefore operate together for a period of time. As each replacement is tested and proven, more requests are routed to the new system and the corresponding legacy code path is decommissioned. This cycle continues until the legacy application has few or no remaining responsibilities and can be retired.</p>

<h2 id="how-this-helps-with-technical-debt">How This Helps With Technical Debt</h2>

<p>Before we get to the mechanics, it’s worth connecting this pattern to the problem it’s actually solving. The strangler fig pattern allows teams to reduce technical debt without stopping product development. The process can also improve the structure of the application. Creating seams between capabilities encourages clearer architectural boundaries, while migrating individual areas often leads to better automated tests, monitoring, and operational visibility. If you’re also looking to improve the code you’re keeping, our post on <a href="https://www.fastruby.io/blog/how-we-approach-refactoring-projects">refactoring Rails applications</a> covers how we approach that work alongside debt reduction.</p>

<p>However, creating a modern system is not enough on its own. Technical debt is only reduced when the corresponding legacy capability is removed. If the old code path remains active, the organization must maintain both implementations and may end up increasing complexity instead. For that reason, decommissioning should be part of the definition of done. Over time, this turns technical-debt reduction from a one-time project into an ongoing delivery practice: identify a costly area, replace it safely, remove the old implementation, and repeat.</p>

<h2 id="running-the-old-and-new-systems-together">Running the Old and New Systems Together</h2>

<p>The strangler fig pattern requires the legacy and modern systems to operate at the same time. A routing mechanism decides which system should handle each request.</p>

<p><strong>The routing mechanism can take a few different forms</strong>, depending on where the seam naturally falls in the architecture. This mechanism might be a reverse proxy, an API gateway, an application façade, or routing logic inside the application itself. For asynchronous workflows, a message broker or event stream can serve a similar purpose by directing events to legacy or modern consumers.</p>

<p>The other key decision we need to make is how to divide the system into pieces that can be migrated independently. There are two common approaches. Divide the system by <strong>route or endpoint</strong>, migrating one API endpoint or URL path at a time, well suited to systems with a clear request/response structure. Alternativly, use <strong>modules or domains</strong> as boundaries, then migrate one area of business logic at a time (billing, authentication, reporting), better suited to systems where the seams are conceptual rather than purely technical.</p>

<h2 id="managing-data-during-the-transition">Managing Data During the Transition</h2>

<p>Data is often the most difficult part of running the legacy and modern systems together. The key question is not only where data is stored, but: <strong>which system is the source of truth for it, right now?</strong></p>

<p>A few common approaches:</p>

<ul>
  <li>The <strong>legacy system remains the source of truth</strong>, even for capabilities that have already been migrated. The new system reads from or defers to the legacy system’s data, which keeps ownership simple but limits how independently the new system can operate.</li>
  <li>The <strong>new system takes ownership of data for capabilities it has already migrated</strong>, with the legacy system deferring to it instead. This is usually the direction migrations move toward over time.</li>
  <li>The two systems’ <strong>data is kept synchronized</strong>, with neither fully deferring to the other. This can work as a transitional state, but it requires clear rules about how conflicts are resolved when both systems can write.</li>
  <li>Changes are <strong>propagated via events</strong>, so that whichever system makes a change publishes it, and the other system consumes and applies it. This decouples the two systems more cleanly than direct synchronization, at the cost of eventual (rather than immediate) consistency.</li>
</ul>

<p>Whichever combination of these is used, the risk of leaving ownership ambiguous is the same. The main safeguard is explicit ownership. For each category of data, the team should define which system can write it, how changes are propagated, and how inconsistencies are detected and corrected. Without those restrictions, both systems can begin modifying the same information independently, making errors difficult to trace and resolve.</p>

<blockquote>
  <p>The data strategy may change as the migration progresses, but ownership should never be ambiguous.</p>
</blockquote>

<h2 id="mitigating-risk-along-the-way">Mitigating Risk Along the Way</h2>

<p>The strangler fig pattern reduces risk by design, simply by breaking a migration into smaller steps. But smaller steps need to be paired with safeguards that catch problems early and make it easy to back out when something isn’t working.</p>

<p>Feature flags and incremental traffic routing allow the team to control what traffic to send to the new implementation. A new capability can first be enabled for internal users, a small customer segment, or a limited percentage of requests. If problems appear, traffic can be redirected to the legacy implementation without needing to redeploy or reverse the entire migration.</p>

<p>Automated tests are important, but they are not sufficient on their own. The new system must also be <strong>observed in production</strong>, at minimum, this means tracking completed transactions, processing times, and mismatched records between the two systems.</p>

<p>Reconciliation processes help detect issues that normal monitoring may miss. For example, a scheduled comparison can verify that both systems processed the same orders, produced the same totals, or reached the same final state.</p>

<p>Every migration step should also have a documented <strong>rollback procedure</strong>. The team should know how to restore the previous routing, what happens to data created by the new system, and whether any actions need to be reconciled after the rollback.</p>

<h2 id="a-practical-game-plan">A Practical Game Plan</h2>

<p>A strangler fig migration usually follows a repeatable sequence.</p>

<ol>
  <li>Identify a seam. Look for a boundary that’s already relatively clean such as a single API endpoint, a self-contained module, or a domain with limited dependencies on the rest of the system.</li>
  <li>Build the interception point. Stand up whatever routing mechanism fits the seam (a proxy rule, a facade method, a conditional branch) so that traffic for this piece <em>can</em> be redirected, without yet redirecting all of it.</li>
  <li>Migrate one capability. The first candidate should be meaningful enough to provide value, but contained enough to limit risk. Moving one well-defined capability also gives the team a chance to test the migration approach before applying it more broadly.</li>
  <li>Validate before fully switching over. Tests confirm the new implementation behaves correctly in isolation. Shadow traffic sends real production requests to the new implementation without using its response, so its behavior can be observed under real conditions before it’s trusted. Finally we compare logging outputs of both systems side by side, looking for differences between the legacy and new implementations.</li>
  <li>Expand. Once the new capability behaves reliably, traffic can be shifted gradually. The same process can then be repeated for the next capability.</li>
  <li>Know what “done” looks like. It is critical that we define what “done” means for both the migration as a whole and each individual slice. We must know what capabilities need to move, what the legacy system’s retirement looks like, and how success will be measured along the way.</li>
</ol>

<h2 id="when-it-goes-wrong">When It Goes Wrong</h2>

<p>The strangler fig pattern is straightforward to describe, but easy to execute poorly. Most failures come from losing sight of the discipline the pattern requires, rather than from the pattern itself being wrong for the job. One common mistake is building the new system without retiring the old one, but there are other important missteps worth being aware of.</p>

<p>One such misstep is choosing the wrong migration boundaries. Moving technical components, such as a database table or utility module, may not remove a complete responsibility from the legacy system. Migrating an end-to-end business capability usually creates a clearer ownership boundary and makes decommissioning more practical.</p>

<p>A newer technology stack does not automatically produce a better system. Teams can reproduce the same coupling and unclear boundaries if they copy the legacy architecture too closely. At the same time, they may underestimate undocumented behavior and discover late in the process that important edge cases were not included in the replacement.</p>

<p>Trying to migrate too many areas at once can make these problems harder to control. Each stream introduces its own routing, data, testing, and operational concerns. A smaller number of focused migrations makes it easier to learn from each step and apply those lessons to the next one.</p>

<p>Modernization also needs business ownership. If it is treated as a side project, feature work will usually take priority and the legacy system will remain in place. The migration is more likely to succeed when each step has a business reason, a responsible owner, and a specific decommissioning outcome.</p>

<h2 id="a-practical-example">A Practical Example</h2>

<p>To make this concrete, consider a hypothetical order-management application, imagine a Rails monolith that’s been in production for years. It handles order creation, payment processing, shipment tracking, customer notifications, and reporting, all in one codebase.</p>

<p>The team decides to migrate customer notifications first. Notifications have a clear boundary, depend on a limited set of order data, and can be validated without impacting the critical path for order or payment processing.</p>

<p>Initially, the legacy application sends notifications directly after an order changes state:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/order.rb</span>

<span class="k">class</span> <span class="nc">Order</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">after_update</span> <span class="ss">:send_status_notification</span><span class="p">,</span> <span class="ss">if: :saved_change_to_status?</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">send_status_notification</span>
    <span class="no">LegacyOrderMailer</span><span class="p">.</span><span class="nf">status_changed</span><span class="p">(</span><span class="nb">self</span><span class="p">).</span><span class="nf">deliver_later</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Step 1: The legacy system publishes an event.</strong></p>

<p>The first step is to introduce an event at the point where the order status changes. The specific mechanism depends on what’s already in the stack, this could be <a href="/blog/rails-event-notify.html"><code class="language-plaintext highlighter-rouge">ActiveSupport::EventReporter</code></a>, <code class="language-plaintext highlighter-rouge">ActiveSupport::Notifications</code>, a Sidekiq job if the codebase already leans on background jobs, or a message broker like Kafka or SQS if the team wants stronger decoupling from the start. The example below uses <code class="language-plaintext highlighter-rouge">ActiveSupport::Notifications</code>, since it requires no new infrastructure and is available in any Rails app.</p>

<p>The legacy application is changed to publish an event describing the change:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/order.rb</span>

<span class="k">class</span> <span class="nc">Order</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">after_update</span> <span class="ss">:send_status_notification</span><span class="p">,</span> <span class="ss">if: :saved_change_to_status?</span>
  <span class="n">after_update</span> <span class="ss">:publish_status_event</span><span class="p">,</span> <span class="ss">if: :saved_change_to_status?</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">send_status_notification</span>
    <span class="no">LegacyOrderMailer</span><span class="p">.</span><span class="nf">status_changed</span><span class="p">(</span><span class="nb">self</span><span class="p">).</span><span class="nf">deliver_later</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">publish_status_event</span>
    <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="p">.</span><span class="nf">instrument</span><span class="p">(</span>
        <span class="s2">"order.status_changed"</span><span class="p">,</span>
        <span class="ss">order_id: </span><span class="nb">id</span><span class="p">,</span>
        <span class="ss">status: </span><span class="n">status</span><span class="p">,</span>
        <span class="ss">customer_id: </span><span class="n">customer_id</span><span class="p">,</span>
        <span class="ss">occurred_at: </span><span class="no">Time</span><span class="p">.</span><span class="nf">current</span>
        <span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This is the seam: a single, well-defined point where order status changes become observable to other systems, without yet changing who acts on them.</p>

<p><strong>Step 2: The new notification service consumes the event.</strong></p>

<p>For this example migration, the new notification logic lives in an isolated module within the same repository (<code class="language-plaintext highlighter-rouge">app/services/notifications_v2/</code>). A subscriber listens for <code class="language-plaintext highlighter-rouge">order.status_changed</code> and hands the payload off to a builder, which is defined in Step 3:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/services/notifications_v2/order_status_subscriber.rb</span>

<span class="k">module</span> <span class="nn">NotificationsV2</span>
  <span class="k">class</span> <span class="nc">OrderStatusSubscriber</span>
    <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="p">.</span><span class="nf">subscribe</span><span class="p">(</span><span class="s2">"order.status_changed"</span><span class="p">)</span> <span class="k">do</span> <span class="o">|*</span><span class="n">args</span><span class="o">|</span>
      <span class="n">event</span> <span class="o">=</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="o">::</span><span class="no">Event</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="o">*</span><span class="n">args</span><span class="p">)</span>
      <span class="c1"># MessageBuilder#build_and_log is shown in Step 3.</span>
      <span class="c1"># Here we construct and log a message, it does not send anything.</span>
      <span class="no">NotificationsV2</span><span class="o">::</span><span class="no">MessageBuilder</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">event</span><span class="p">.</span><span class="nf">payload</span><span class="p">).</span><span class="nf">build_and_log</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>At this point, nothing customer-facing has changed. The new service is simply listening and building notifications for its own internal record.</p>

<p><strong>Step 3: Notifications are generated by both systems, but sent only by the legacy system.</strong></p>

<p>The legacy application continues to send every notification, exactly as before. The new service does not send anything, instead it generates what it <em>would</em> send, but its output is logged rather than delivered:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/services/notifications_v2/message_builder.rb</span>

<span class="k">module</span> <span class="nn">NotificationsV2</span>
  <span class="k">class</span> <span class="nc">MessageBuilder</span>
    <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">payload</span><span class="p">)</span>
      <span class="vi">@payload</span> <span class="o">=</span> <span class="n">payload</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">build_and_log</span>
      <span class="n">message</span> <span class="o">=</span> <span class="n">build_message</span>
      <span class="no">ComparisonLog</span><span class="p">.</span><span class="nf">record</span><span class="p">(</span>
        <span class="ss">order_id: </span><span class="vi">@payload</span><span class="p">[</span><span class="ss">:order_id</span><span class="p">],</span>
        <span class="ss">source: </span><span class="s2">"notifications_v2"</span><span class="p">,</span>
        <span class="ss">message: </span><span class="n">message</span>
      <span class="p">)</span>
      <span class="n">message</span>
    <span class="k">end</span>

    <span class="kp">private</span>

    <span class="k">def</span> <span class="nf">build_message</span>
      <span class="c1"># message construction logic</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>For this comparison to mean anything, the legacy mailer also needs to log what it actually sends. A small change records that:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/mailers/order_mailer.rb</span>

<span class="k">class</span> <span class="nc">LegacyOrderMailer</span> <span class="o">&lt;</span> <span class="no">ApplicationMailer</span>
  <span class="k">def</span> <span class="nf">status_notification</span><span class="p">(</span><span class="n">order</span><span class="p">)</span>
    <span class="no">LegacyNotificationLog</span><span class="p">.</span><span class="nf">record</span><span class="p">(</span>
      <span class="ss">order_id: </span><span class="n">order</span><span class="p">.</span><span class="nf">id</span><span class="p">,</span>
      <span class="ss">message: </span><span class="n">notification_body</span><span class="p">(</span><span class="n">order</span><span class="p">)</span>
    <span class="p">)</span>
    <span class="n">mail</span><span class="p">(</span><span class="ss">to: </span><span class="n">order</span><span class="p">.</span><span class="nf">customer</span><span class="p">.</span><span class="nf">email</span><span class="p">,</span> <span class="ss">subject: </span><span class="s2">"Order update"</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Step 4: Outputs are compared.</strong></p>

<p>A comparison job checks the legacy system’s sent notifications against the new service’s logged output for the same events, flagging any mismatches in content, timing, or recipients:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/jobs/notification_comparison_job.rb</span>

<span class="k">class</span> <span class="nc">NotificationComparisonJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span>
    <span class="n">legacy</span> <span class="o">=</span> <span class="no">LegacyNotificationLog</span><span class="p">.</span><span class="nf">for_order</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span>
    <span class="n">new_service</span> <span class="o">=</span> <span class="no">ComparisonLog</span><span class="p">.</span><span class="nf">for_order</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span>

    <span class="k">unless</span> <span class="n">legacy</span><span class="p">.</span><span class="nf">message</span> <span class="o">==</span> <span class="n">new_service</span><span class="p">.</span><span class="nf">message</span>
      <span class="no">MismatchReporter</span><span class="p">.</span><span class="nf">report</span><span class="p">(</span><span class="n">order_id</span><span class="p">:,</span> <span class="n">legacy</span><span class="p">:,</span> <span class="n">new_service</span><span class="p">:)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This shadow period gives the team production evidence without changing customer-facing behavior. Differences can be reviewed to determine whether there are defects in the new service or undocumented behavior in the legacy application.</p>

<p><strong>Step 5: The new service starts sending notifications.</strong></p>

<p>Once mismatches have dropped to zero (or an accepted, understood baseline), a feature flag controls which implementation sends the notification. Before the flag is enabled, the new service is updated to actually deliver notifications. The <code class="language-plaintext highlighter-rouge">MessageBuilder</code> gains a <code class="language-plaintext highlighter-rouge">build_and_send</code> path alongside the existing <code class="language-plaintext highlighter-rouge">build_and_log</code>, using the same message construction logic already validated during the shadow period. The subscriber checks the feature flag to decide which path to call.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/order.rb</span>

<span class="k">class</span> <span class="nc">Order</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">after_update</span> <span class="ss">:send_status_notification</span><span class="p">,</span> <span class="ss">if: :saved_change_to_status?</span>
  <span class="n">after_update</span> <span class="ss">:publish_status_event</span><span class="p">,</span> <span class="ss">if: :saved_change_to_status?</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">send_status_notification</span>
    <span class="c1"># Legacy mailer runs when the flag is off; new system takes over when enabled</span>
    <span class="no">LegacyOrderMailer</span><span class="p">.</span><span class="nf">status_notification</span><span class="p">(</span><span class="nb">self</span><span class="p">).</span><span class="nf">deliver_later</span> <span class="k">unless</span> <span class="no">Feature</span><span class="p">.</span><span class="nf">enabled?</span><span class="p">(</span><span class="ss">:modern_order_notifications</span><span class="p">,</span> <span class="n">customer</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">publish_status_event</span>
    <span class="c1"># Always fires; the subscriber decides whether to act or just log</span>
    <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="p">.</span><span class="nf">instrument</span><span class="p">(</span>
        <span class="s2">"order.status_changed"</span><span class="p">,</span>
        <span class="ss">order_id: </span><span class="nb">id</span><span class="p">,</span>
        <span class="ss">status: </span><span class="n">status</span><span class="p">,</span>
        <span class="ss">customer_id: </span><span class="n">customer_id</span><span class="p">,</span>
        <span class="ss">occurred_at: </span><span class="no">Time</span><span class="p">.</span><span class="nf">current</span>
        <span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The flag can first be enabled for internal accounts, then for a small percentage of customers, and eventually for all traffic. During the rollout, the team monitors delivery failures, processing times, duplicate notifications, and differences in message content.</p>

<p><strong>Step 6: Notification code is removed from the legacy application.</strong></p>

<p>The legacy notification-sending code is deleted, not just disabled. This includes everything from templates, delivery logic and related configuration. This is the step that actually pays down technical debt, everything before it was preparation.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/order.rb</span>

<span class="k">class</span> <span class="nc">Order</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">after_update</span> <span class="ss">:publish_status_event</span><span class="p">,</span> <span class="ss">if: :saved_change_to_status?</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">publish_status_event</span>
    <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="p">.</span><span class="nf">instrument</span><span class="p">(</span>
        <span class="s2">"order.status_changed"</span><span class="p">,</span>
        <span class="ss">order_id: </span><span class="nb">id</span><span class="p">,</span>
        <span class="ss">status: </span><span class="n">status</span><span class="p">,</span>
        <span class="ss">customer_id: </span><span class="n">customer_id</span><span class="p">,</span>
        <span class="ss">occurred_at: </span><span class="no">Time</span><span class="p">.</span><span class="nf">current</span>
        <span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Step 7: The team moves on to shipment tracking.</strong></p>

<p>With one capability fully migrated and one seam retired, the same process (publish an event, build the new implementation, compare outputs, cut over, remove the old code) is applied to the next capability. Order creation, payment processing, and reporting remain untouched in the legacy system for now, each waiting its turn.</p>

<p>Nothing in this example required stopping the order-management system, freezing feature work, or making a single high-stakes cutover. The legacy and new systems coexisted for exactly as long as it took to validate one piece of functionality.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Replacing a legacy system does not require a single high-risk launch. As this post has covered, the strangler fig pattern offers a way to modernize gradually. It allows us to validate each replacement in production before committing to it, while keeping the business operating throughout the transition.</p>

<p>It is particularly useful when the legacy system is large enough that a full rewrite is not practical, must remain available, and cannot be replaced without interrupting ongoing feature development. It also works best when the application contains distinct capabilities that can be separated and migrated independently.</p>

<p>The pattern may be unnecessary for a small system that can be replaced safely in a short period. For a large, business-critical application, however, spreading risk across a series of controlled releases is often more practical than concentrating it in one cutover.</p>

<p>Technical debt reduction, approached this way, stops being a single high-stakes project and becomes something closer to routine engineering practice. Done well, modernization becomes a sequence of measurable and reversible improvements. Each step reduces the scope of the legacy system, lowers a specific source of technical debt, and creates a safer foundation for the next migration.</p>

<p>Is your Rails application carrying more legacy code than it should? <a href="/#contactus">We can help</a>.</p>]]></content><author><name>fbuys</name></author><category term="technical-debt" /><summary type="html"><![CDATA[Learn how the strangler fig pattern lets teams modernize legacy Rails apps gradually, reducing technical debt without a risky all-at-once rewrite.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/how-the-strangler-fig-pattern-helps-teams-pay-down-technical-debt.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/how-the-strangler-fig-pattern-helps-teams-pay-down-technical-debt.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">What Replacing React with Hotwire Really Costs</title><link href="https://www.fastruby.io/blog/what-replacing-react-with-hotwire-really-costs.html" rel="alternate" type="text/html" title="What Replacing React with Hotwire Really Costs" /><published>2026-08-25T05:00:00-04:00</published><updated>2026-08-25T05:00:00-04:00</updated><id>https://www.fastruby.io/blog/what-replacing-react-with-hotwire-really-costs</id><content type="html" xml:base="https://www.fastruby.io/blog/what-replacing-react-with-hotwire-really-costs.html"><![CDATA[<p>It’s common to see a Rails app using React to handle front-end interactions, with a Redux store that mostly mirrors the database and <code class="language-plaintext highlighter-rouge">react-router</code> re-declaring routes Rails already knows about. React does its job well enough, but every user-facing feature now costs twice, once in Ruby and once in JavaScript.</p>

<p>So eventually the question will appear: <em>what would it take to delete all of this and use a Rails-way solution like <a href="https://hotwired.dev/">Hotwire</a>?</em> The honest answer is “it depends.” In this article, we will go through what the code difference actually looks like, what you can expect from bundle size, which pain points to plan for, and how to scope the work before committing to it.</p>

<!--more-->

<h2 id="the-code-difference">The code difference</h2>

<p>Creating a plain list of todos in classic React + Redux takes an action creator, a reducer, and a connected component. That is three JS files, plus an API controller and serializer on the Rails side. Redux Toolkit collapses a lot of this boilerplate, but the apps that raise this question are rarely on it:</p>

<div class="language-jsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// actions/todos.js</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">fetchTodos</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">async </span><span class="p">(</span><span class="nx">dispatch</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nf">dispatch</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">FETCH_TODOS_REQUEST</span><span class="dl">"</span> <span class="p">});</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">/api/todos</span><span class="dl">"</span><span class="p">);</span>
    <span class="nf">dispatch</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">FETCH_TODOS_SUCCESS</span><span class="dl">"</span><span class="p">,</span> <span class="na">payload</span><span class="p">:</span> <span class="k">await</span> <span class="nx">res</span><span class="p">.</span><span class="nf">json</span><span class="p">()</span> <span class="p">});</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">dispatch</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">FETCH_TODOS_FAILURE</span><span class="dl">"</span><span class="p">,</span> <span class="na">error</span><span class="p">:</span> <span class="nx">e</span><span class="p">.</span><span class="nx">message</span> <span class="p">});</span>
  <span class="p">}</span>
<span class="p">};</span>

<span class="c1">// reducers/todos.js</span>
<span class="kd">const</span> <span class="nx">initialState</span> <span class="o">=</span> <span class="p">{</span> <span class="na">items</span><span class="p">:</span> <span class="p">[],</span> <span class="na">loading</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="na">error</span><span class="p">:</span> <span class="kc">null</span> <span class="p">};</span>
<span class="k">export</span> <span class="k">default</span> <span class="kd">function</span> <span class="nf">todos</span><span class="p">(</span><span class="nx">state</span> <span class="o">=</span> <span class="nx">initialState</span><span class="p">,</span> <span class="nx">action</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">switch </span><span class="p">(</span><span class="nx">action</span><span class="p">.</span><span class="nx">type</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">case</span> <span class="dl">"</span><span class="s2">FETCH_TODOS_REQUEST</span><span class="dl">"</span><span class="p">:</span> <span class="k">return</span> <span class="p">{</span> <span class="p">...</span><span class="nx">state</span><span class="p">,</span> <span class="na">loading</span><span class="p">:</span> <span class="kc">true</span> <span class="p">};</span>
    <span class="k">case</span> <span class="dl">"</span><span class="s2">FETCH_TODOS_SUCCESS</span><span class="dl">"</span><span class="p">:</span> <span class="k">return</span> <span class="p">{</span> <span class="p">...</span><span class="nx">state</span><span class="p">,</span> <span class="na">loading</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="na">items</span><span class="p">:</span> <span class="nx">action</span><span class="p">.</span><span class="nx">payload</span> <span class="p">};</span>
    <span class="k">case</span> <span class="dl">"</span><span class="s2">FETCH_TODOS_FAILURE</span><span class="dl">"</span><span class="p">:</span> <span class="k">return</span> <span class="p">{</span> <span class="p">...</span><span class="nx">state</span><span class="p">,</span> <span class="na">loading</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="na">error</span><span class="p">:</span> <span class="nx">action</span><span class="p">.</span><span class="nx">error</span> <span class="p">};</span>
    <span class="nl">default</span><span class="p">:</span> <span class="k">return</span> <span class="nx">state</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// components/TodoList.jsx</span>
<span class="kd">class</span> <span class="nc">TodoList</span> <span class="kd">extends</span> <span class="nc">Component</span> <span class="p">{</span>
  <span class="nf">componentDidMount</span><span class="p">()</span> <span class="p">{</span> <span class="k">this</span><span class="p">.</span><span class="nx">props</span><span class="p">.</span><span class="nf">fetchTodos</span><span class="p">();</span> <span class="p">}</span>
  <span class="nf">render</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="p">{</span> <span class="nx">items</span><span class="p">,</span> <span class="nx">loading</span> <span class="p">}</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">props</span><span class="p">;</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">loading</span><span class="p">)</span> <span class="k">return</span> <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>Loading…<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;;</span>
    <span class="k">return</span> <span class="p">&lt;</span><span class="nt">ul</span><span class="p">&gt;</span><span class="si">{</span><span class="nx">items</span><span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">t</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">&lt;</span><span class="nt">li</span> <span class="na">key</span><span class="p">=</span><span class="si">{</span><span class="nx">t</span><span class="p">.</span><span class="nx">id</span><span class="si">}</span><span class="p">&gt;</span><span class="si">{</span><span class="nx">t</span><span class="p">.</span><span class="nx">title</span><span class="si">}</span><span class="p">&lt;/</span><span class="nt">li</span><span class="p">&gt;)</span><span class="si">}</span><span class="p">&lt;/</span><span class="nt">ul</span><span class="p">&gt;;</span>
  <span class="p">}</span>
<span class="p">}</span>
<span class="k">export</span> <span class="k">default</span> <span class="nf">connect</span><span class="p">((</span><span class="nx">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="na">items</span><span class="p">:</span> <span class="nx">s</span><span class="p">.</span><span class="nx">todos</span><span class="p">.</span><span class="nx">items</span><span class="p">,</span> <span class="na">loading</span><span class="p">:</span> <span class="nx">s</span><span class="p">.</span><span class="nx">todos</span><span class="p">.</span><span class="nx">loading</span> <span class="p">}),</span> <span class="p">{</span> <span class="nx">fetchTodos</span> <span class="p">})(</span><span class="nx">TodoList</span><span class="p">);</span>
</code></pre></div></div>

<p>The same feature in Hotwire would require only a controller and a view, no store, no serializer, no loading state, because the server sends HTML that’s already populated:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/todos_controller.rb</span>
<span class="k">class</span> <span class="nc">TodosController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">index</span>
    <span class="vi">@todos</span> <span class="o">=</span> <span class="n">current_user</span><span class="p">.</span><span class="nf">todos</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/todos/index.html.erb %&gt;</span>
<span class="nt">&lt;ul&gt;</span>
  <span class="cp">&lt;%</span> <span class="vi">@todos</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">todo</span><span class="o">|</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;li&gt;</span><span class="cp">&lt;%=</span> <span class="n">todo</span><span class="p">.</span><span class="nf">title</span> <span class="cp">%&gt;</span><span class="nt">&lt;/li&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/ul&gt;</span>
</code></pre></div></div>

<p>That gap is why the migration is worth doing, and it’s also where the cost comes from. Porting a screen means deleting a whole layer and moving its responsibilities back to the server, which is slower work than translating code line for line. That cost lands on developers, and it’s paid during the migration rather than after.</p>

<p>It’s hard to port a screen mechanically. Each one has to be rethought: where its state lived, what the server can render directly, and what genuinely needs to stay in JavaScript. The client-side tests won’t carry over either, so every flow has to be re-covered.</p>

<h2 id="interactivity">Interactivity</h2>

<p>Hotwire covers interactivity in two ways. For server-driven updates, Turbo Streams replace just the piece that changed, no reducer, no re-rendering the whole list. A simple toggle action that used to be a Redux action and a connected component becomes a single controller action and a Turbo Stream view that re-renders the todo partial:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/todos/toggle.turbo_stream.erb %&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">turbo_stream</span><span class="p">.</span><span class="nf">replace</span> <span class="vi">@todo</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>For client-side behavior, a small Stimulus controller replaces what used to be a stateful component. The mental shift is the whole point: a Redux store that mirrors the database makes the client the source of truth and syncs it to the server; Hotwire keeps the server as the source of truth.</p>

<p>The case for the second model isn’t speed. React can be fast, and on most screens users won’t feel the difference either way. The real cost is keeping two copies of the truth in sync: every mutation updates the server, then reconciles the local store, invalidates its cache, and manages its own loading, error, and rollback states. A data layer like React Query or RTK Query automates much of that, but the apps that prompt this question are usually on a hand-rolled Redux store where all of it is yours to maintain.</p>

<p>Hotwire deletes most of that work: with one source of truth, there’s no client store to reconcile. Cache invalidation is still yours, and Turbo’s page cache can still show a stale preview on a restoration visit, but you maintain one copy of the truth instead of two. Where a screen genuinely needs rich client state, React earns that overhead; most screens don’t, and that’s the trade the migration is really making.</p>

<h2 id="bundle-size">Bundle size</h2>

<p>This is the number stakeholders actually feel. <code class="language-plaintext highlighter-rouge">react</code> + <code class="language-plaintext highlighter-rouge">react-dom</code> alone are about 45 KB gzipped before Redux, the router, and your own code, and a mature SPA bundle usually lands in the low hundreds of KB gzipped. Turbo and Stimulus together are about 35 KB gzipped, and with <a href="https://www.fastruby.io/blog/the-assets-pipeline-history">importmaps</a> you can often drop the JS build step entirely. That leaves a hundred KB or more of JavaScript that never has to be downloaded, parsed, and executed on every page load.</p>

<h2 id="what-drives-the-cost-and-what-bites">What drives the cost, and what bites</h2>

<p>Two apps with the same page count can differ in effort by an order of magnitude. The estimate is driven by how much <em>genuine client-side state</em> and <em>app-like behavior</em> you have (optimistic UI, wizards, real-time dashboards, drag-and-drop), far more than by raw page count.</p>

<p>A handful of pain points show up in this work every time, and they are worth planning for before the first screen moves.</p>

<p>Most of the Redux store turns out to be server state in disguise, and it disappears along with the API that fed it. The hard cases are optimistic UI and unsaved multi-step forms, and for each of those you have to decide deliberately whether it becomes a server round trip, a bit of Stimulus state, or a screen you leave in JavaScript for now. Routing is a similar story. Anything stored <em>in</em> the front-end route, a modal as a URL or a wizard step, needs a home on the server, so it’s worth verifying deep links, the back button, and scroll restoration as you go.</p>

<p>The complex widgets deserve to be identified first. They are usually the wrong thing to rebuild in Hotwire and the right thing to keep as islands, since reimplementing a mature JS date picker or drag-and-drop grid in Stimulus is where effort tends to blow up. The JSON API is easier to reason about: if it only feeds React, you delete it along with the front end, but if a mobile app or a partner also consumes it, you will be maintaining two paradigms for the length of the transition.</p>

<p>Testing and authentication are the two slices teams tend to underestimate. The React app’s component tests won’t translate, and end-to-end coverage of the flows you touch is what makes this safe, so expect to <a href="https://www.fastruby.io/blog/how-to-make-tests-better.html">write tests</a> ideally <em>before</em> each migration rather than after. And if the SPA authenticates with tokens, moving to Rails sessions and cookies touches login, session expiry, and CSRF, which is enough surface area to plan as its own slice instead of folding it into a screen.</p>

<h2 id="how-to-scope-it">How to scope it</h2>

<p>A practical approach is to turn the unknown into a spreadsheet: inventory every screen, Redux slice, and front-end-only API endpoint, then bucket each screen. <strong>A</strong> trivially server-rendered, <strong>B</strong> needs a Stimulus sprinkle, <strong>C</strong> needs a real JS island, <strong>D</strong> stays as React for now.</p>

<p>Starting with the A/B buckets gets the cheapest wins first, and builds the team’s Hotwire fluency before anything hard gets touched. It’s the same logic we apply to <a href="https://www.fastruby.io/blog/can-you-upgrade-in-increments">upgrading Rails in increments</a>, where small shippable slices beat a long-lived branch. To keep the work fundable, track numbers people can watch: lines of JavaScript deleted, dependencies removed, bundle size, and screens migrated out of the total.</p>

<h2 id="conclusion">Conclusion</h2>

<p>One caveat before you start: this work pays off least when the app is genuinely application-like, such as heavy real-time collaboration, serious offline support, or rich client state a round trip would ruin. There the SPA earns its keep, and the better investment is modernizing it.</p>

<p>For everything else, the honest estimate comes out of the inventory: how many screens land in the A and B buckets, how many complex widgets you have to keep as islands, and how much of the Redux store turns out to be server state in disguise. Start with the cheap buckets, track the numbers people can watch, and let the hard screens wait until the team has the Hotwire fluency to handle them. One thing worth keeping in mind: the D bucket is allowed to stay a D bucket. An app that serves most of its screens with Hotwire and keeps three React islands is a perfectly reasonable place to stop, and stopping there is usually cheaper than chasing the last few screens.</p>

<p>Weighing a move off React and Redux and want a realistic read on the cost and the right incremental path? <a href="https://www.fastruby.io/#contactus">Talk to us today!</a></p>]]></content><author><name>hmdros</name></author><category term="javascript" /><summary type="html"><![CDATA[Replacing a legacy React and Redux layer with Hotwire sounds tempting. Here is an honest look at the effort, the trade-offs, and the pain points to weigh first.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/what-replacing-react-with-hotwire-really-costs.png" /><media:content medium="image" url="https://www.fastruby.io/blog/what-replacing-react-with-hotwire-really-costs.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">SimpleCov is now version 1!</title><link href="https://www.fastruby.io/blog/simplecov-1-0-release.html" rel="alternate" type="text/html" title="SimpleCov is now version 1!" /><published>2026-08-20T15:05:36-04:00</published><updated>2026-08-20T15:05:36-04:00</updated><id>https://www.fastruby.io/blog/simplecov-1-0-release</id><content type="html" xml:base="https://www.fastruby.io/blog/simplecov-1-0-release.html"><![CDATA[<p>Here at FastRuby.io we work with test suites and test coverage reports every single day, given our specialty. In the Ruby world that means
we interact with SimpleCov every day. Not only that, we explicitely rely on SimpleCov reports in two of the open source projects we maintain,
<a href="https://github.com/whitesmith/rubycritic">RubyCritic</a> and <a href="https://github.com/fastruby/skunk">Skunk</a>.</p>

<p>Which is why, after more than a decade at 0.x, we were super excited to see <a href="https://github.com/simplecov-ruby/simplecov">SimpleCov</a> ship its first stable release on July 12, 2026.
In this article we’d like to share the details on the breaking changes, the deprecations and how to address them, if you’re impacted by them.</p>

<!--more-->

<p>Everything below refers only to the <a href="https://github.com/simplecov-ruby/simplecov/blob/main/CHANGELOG.md#100-2026-07-12">1.0.0 entry in the changelog</a>.</p>

<p>Also, it’s worth noting that the minimum required Ruby version for this release is 3.2.</p>

<h2 id="breaking-changes">Breaking changes</h2>

<h3 id="how-filters-match-paths">How filters match paths</h3>

<p>Two related changes can quietly alter which files end up in your report.</p>

<p>The first is that <code class="language-plaintext highlighter-rouge">SourceFile#project_filename</code> now returns a relative path, <code class="language-plaintext highlighter-rouge">lib/foo.rb</code> instead of <code class="language-plaintext highlighter-rouge">/lib/foo.rb</code>, which affects any anchored <code class="language-plaintext highlighter-rouge">RegexFilter</code> that relied on the leading slash. Because this breaks without a warning, you need to grep your codebase for patterns relying on the leading slash:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"add_filter</span><span class="se">\|</span><span class="s2">remove_filter</span><span class="se">\|</span><span class="s2">add_group"</span> .simplecov spec/spec_helper.rb <span class="nb">test</span>/test_helper.rb
</code></pre></div></div>

<p>Any pattern that anchors on a slash needs rewriting:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="n">add_filter</span> <span class="sr">%r{^/lib/}</span>

<span class="c1"># After</span>
<span class="n">add_filter</span> <span class="sr">%r{</span><span class="se">\A</span><span class="sr">lib/}</span>
</code></pre></div></div>

<p>The second change is that <code class="language-plaintext highlighter-rouge">StringFilter</code> now matches at path-segment boundaries, so <code class="language-plaintext highlighter-rouge">"lib"</code> matches <code class="language-plaintext highlighter-rouge">/lib/</code> but no longer matches <code class="language-plaintext highlighter-rouge">/library/</code>. If you were deliberately relying on substring behavior, like saying <code class="language-plaintext highlighter-rouge">add_filter "_spec"</code> to catch <code class="language-plaintext highlighter-rouge">user_spec.rb</code>, switch that filter to a <code class="language-plaintext highlighter-rouge">Regexp</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before: matched anything containing the substring</span>
<span class="n">add_filter</span> <span class="s2">"generated"</span>

<span class="c1"># After: explicit substring matching</span>
<span class="n">add_filter</span><span class="p">(</span><span class="sr">/generated/</span><span class="p">)</span>
</code></pre></div></div>

<p>This change has one very important observation to note, however: An argument containing a dot is still treated as a filename pattern and still matches as a substring, so <code class="language-plaintext highlighter-rouge">add_filter "test.rb"</code> continues to catch <code class="language-plaintext highlighter-rouge">faked_test.rb</code>. A trailing slash likewise still means directory-only matching. Only dot-free arguments like <code class="language-plaintext highlighter-rouge">"lib"</code> or <code class="language-plaintext highlighter-rouge">"_spec"</code> became segment-anchored.</p>

<h3 id="the-status-line-moved-to-stderr">The status line moved to stderr</h3>

<p>The “Coverage report generated for X to Y” line, and the per-criterion totals under it, now go to stderr instead of stdout. If a CI script of yours parsed that line from stdout, it will now see nothing. You can suppress the message entirely on the formatter:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">formatter</span> <span class="o">=</span> <span class="no">SimpleCov</span><span class="o">::</span><span class="no">Formatter</span><span class="o">::</span><span class="no">HTMLFormatter</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">silent: </span><span class="kp">true</span><span class="p">)</span>
</code></pre></div></div>

<p>Or you can restore the old behavior at the call site:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">exec </span>rspec 2&gt;&amp;1
</code></pre></div></div>

<h3 id="htmlformatter-now-outputs-coveragejson">HTMLFormatter now outputs coverage.json</h3>

<p>SimpleCov used to automatically activate <code class="language-plaintext highlighter-rouge">JSONFormatter</code> when the <code class="language-plaintext highlighter-rouge">CC_TEST_REPORTER_ID</code> environment variable was set, and that special case is gone. It is no longer needed, because the default <code class="language-plaintext highlighter-rouge">HTMLFormatter</code> now writes <code class="language-plaintext highlighter-rouge">coverage.json</code> alongside the HTML report, serializing the same payload <code class="language-plaintext highlighter-rouge">JSONFormatter</code> would. If you relied on the old auto-activation and you <em>weren’t</em> using the HTML formatter, you’ll need to add either the <code class="language-plaintext highlighter-rouge">JSONFormatter</code> or the <code class="language-plaintext highlighter-rouge">HTMLFormatter</code> explicitly. Note that only one of them is needed
and using <code class="language-plaintext highlighter-rouge">HTMLFormatter</code> gives you the <code class="language-plaintext highlighter-rouge">JSONFormatter</code> output plus the HTML report.</p>

<h3 id="coveragejson-schema-change"><code class="language-plaintext highlighter-rouge">coverage.json</code> schema change</h3>

<p><code class="language-plaintext highlighter-rouge">coverage.json</code> changed shape, from <code class="language-plaintext highlighter-rouge">{ "covered_percent": 80.0 }</code> to the full <code class="language-plaintext highlighter-rouge">{ "covered": 8, "missed": 2, "total": 10, "percent": 80.0, "strength": 0.0 }</code>, with <code class="language-plaintext highlighter-rouge">covered_percent</code> renamed to <code class="language-plaintext highlighter-rouge">percent</code>. On a related note, both <code class="language-plaintext highlighter-rouge">simplecov_json_formatter</code> and <code class="language-plaintext highlighter-rouge">simplecov-html</code> have been merged into the main gem. The old requires still work through a shim, so you can drop the separate gems from your <code class="language-plaintext highlighter-rouge">Gemfile</code> whenever it is convenient.</p>

<h3 id="simplecovstart-now-loads-the-test_frameworks-profile-by-default">SimpleCov.start now loads the test_frameworks profile by default</h3>

<p>Calling <code class="language-plaintext highlighter-rouge">SimpleCov.start</code> with no arguments now loads the <code class="language-plaintext highlighter-rouge">test_frameworks</code> profile by default, which filters out paths under <code class="language-plaintext highlighter-rouge">test/</code>, <code class="language-plaintext highlighter-rouge">spec/</code>, <code class="language-plaintext highlighter-rouge">features/</code>, and
<code class="language-plaintext highlighter-rouge">autotest/</code>. Rails applications usually call <code class="language-plaintext highlighter-rouge">SimpleCov.start "rails"</code> instead, and the <code class="language-plaintext highlighter-rouge">rails</code> profile has loaded <code class="language-plaintext highlighter-rouge">test_frameworks</code> for as long as the profile has
existed, so those projects need no change.</p>

<p>If you do call <code class="language-plaintext highlighter-rouge">start</code> with no arguments, your report will drop its overall coverage percentage, and how much depends on the ratio of test code to application
code. Should you also set the <code class="language-plaintext highlighter-rouge">minimum_coverage</code> attribute and the new number fall below that threshold, you’ll need to either establish a new acceptable
threshold or remove the filters:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Option A: accept the more honest number and re-baseline the threshold</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">minimum_coverage</span> <span class="mi">80</span>

<span class="c1"># Option B: keep the old behavior by dropping the new filter</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">remove_filter</span> <span class="sr">%r{</span><span class="se">\A</span><span class="sr">(test|features|spec|autotest)/}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Option B is genuinely useful if you want to surface dead test helpers that nothing calls anymore. For everything else, re-baselining is the better move. If the
number you land on is lower than you are comfortable with, we put together <a href="https://www.fastruby.io/blog/10-strategies-for-upgrading-ruby-or-rails-applications-with-low-test-coverage">10 strategies for upgrading apps with low test coverage</a>
that should help.</p>

<h3 id="parallel-waits-and-two-removals">Parallel waits and two removals</h3>

<p>Under <code class="language-plaintext highlighter-rouge">parallel_tests</code>, SimpleCov now waits in the first process rather than the last, using <code class="language-plaintext highlighter-rouge">ParallelTests.first_process?</code>, which matches what the <code class="language-plaintext highlighter-rouge">parallel_tests</code> README recommends. For most people this fixes a deadlock, and the old <code class="language-plaintext highlighter-rouge">PARALLEL_TEST_GROUPS=1</code> workaround is no longer needed. The rare project that wired up its own <code class="language-plaintext highlighter-rouge">wait_for_other_processes_to_finish</code> in an <code class="language-plaintext highlighter-rouge">after(:suite)</code> hook keyed on <code class="language-plaintext highlighter-rouge">last_process?</code> now hits the symmetric deadlock and has to switch to <code class="language-plaintext highlighter-rouge">first_process?</code>.</p>

<p>Two removals round out this section. <code class="language-plaintext highlighter-rouge">SimpleCov.coverage_criterion</code> is gone and <code class="language-plaintext highlighter-rouge">primary_coverage</code> (or <code class="language-plaintext highlighter-rouge">coverage :branch, primary: true</code>) replaces it and the <code class="language-plaintext highlighter-rouge">docile</code> dependency is gone too, with <code class="language-plaintext highlighter-rouge">SimpleCov.configure</code> blocks now evaluated via <code class="language-plaintext highlighter-rouge">instance_exec</code> and instance variable proxying, which needs no action on your side unless you were doing something exotic inside a configure block.</p>

<h2 id="deprecations">Deprecations</h2>

<p>Below we give a small description of the deprecations that were created in this version. While these don’t break now, we recommend effecting these changes
so that future upgrades go smoothly.</p>

<h3 id="the-configuration-api-was-redesigned">The configuration API was redesigned</h3>

<p>The config API has been reorganized around a smaller, more consistent set of verbs:</p>

<table>
  <thead>
    <tr>
      <th>Legacy</th>
      <th>Replacement</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">add_filter</code></td>
      <td><code class="language-plaintext highlighter-rouge">skip</code></td>
      <td>Identical matcher grammar, no behavior change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">add_group</code></td>
      <td><code class="language-plaintext highlighter-rouge">group</code></td>
      <td>Identical matcher grammar, no behavior change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">track_files</code></td>
      <td><code class="language-plaintext highlighter-rouge">cover</code></td>
      <td>Behavior differs, see below</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">use_merging</code></td>
      <td><code class="language-plaintext highlighter-rouge">merging</code></td>
      <td>No behavior change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">enable_for_subprocesses</code></td>
      <td><code class="language-plaintext highlighter-rouge">merge_subprocesses</code></td>
      <td>No behavior change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">enable_coverage_for_eval</code></td>
      <td><code class="language-plaintext highlighter-rouge">enable_coverage :eval</code></td>
      <td>Folds into the same call as <code class="language-plaintext highlighter-rouge">:line</code> / <code class="language-plaintext highlighter-rouge">:branch</code> / <code class="language-plaintext highlighter-rouge">:method</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">print_error_status</code> (reader)</td>
      <td><code class="language-plaintext highlighter-rouge">print_errors</code></td>
      <td>The <code class="language-plaintext highlighter-rouge">print_error_status=</code> writer is unaffected for now</td>
    </tr>
  </tbody>
</table>

<p>Most of these are a straight rename:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">add_filter</span> <span class="s2">"/vendor/"</span>
  <span class="n">add_group</span> <span class="s2">"Services"</span><span class="p">,</span> <span class="s2">"app/services"</span>
  <span class="n">use_merging</span> <span class="kp">true</span>
<span class="k">end</span>

<span class="c1"># After</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">skip</span> <span class="s2">"/vendor/"</span>
  <span class="n">group</span> <span class="s2">"Services"</span><span class="p">,</span> <span class="s2">"app/services"</span>
  <span class="n">merging</span> <span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The one to pay attention to is <code class="language-plaintext highlighter-rouge">cover</code>. It includes unloaded files the way <code class="language-plaintext highlighter-rouge">track_files</code> did, but it also restricts the report to the matching set given to it, so a single <code class="language-plaintext highlighter-rouge">cover "lib/**/*.rb"</code> will drop <code class="language-plaintext highlighter-rouge">app/</code> from your report entirely. To keep the old behavior, pass every directory you want reported:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="n">track_files</span> <span class="s2">"lib/**/*.rb"</span>

<span class="c1"># After: list everything, not just the previously-tracked glob</span>
<span class="n">cover</span> <span class="s2">"lib/**/*.rb"</span><span class="p">,</span> <span class="s2">"app/**/*.rb"</span>
</code></pre></div></div>

<h3 id="simplecovstart-cannot-be-called-from-simplecov">SimpleCov.start cannot be called from .simplecov</h3>

<p>Calling <code class="language-plaintext highlighter-rouge">SimpleCov.start</code> from <code class="language-plaintext highlighter-rouge">.simplecov</code> is deprecated. Tracking still begins for backward compatibility, but you get a one-time warning, and a future release will require the explicit call to live in <code class="language-plaintext highlighter-rouge">spec_helper.rb</code> or <code class="language-plaintext highlighter-rouge">test_helper.rb</code>. Treat <code class="language-plaintext highlighter-rouge">.simplecov</code> as configuration only:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .simplecov</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span>
  <span class="n">skip</span> <span class="s2">"/spec/"</span>
  <span class="n">minimum_coverage</span> <span class="mi">90</span>
<span class="k">end</span>

<span class="c1"># spec/spec_helper.rb, at the very top, before anything else is required</span>
<span class="nb">require</span> <span class="s2">"simplecov"</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="s2">"rails"</span>
</code></pre></div></div>

<h3 id="nocov-comments-and-associated-configurations-are-deprecated">:nocov: comments and associated configurations are deprecated</h3>

<p>This means specifically the <code class="language-plaintext highlighter-rouge">SimpleCov.nocov_token</code> and <code class="language-plaintext highlighter-rouge">SimpleCov.skip_token</code> configurations. Every file still using <code class="language-plaintext highlighter-rouge"># :nocov:</code> emits a one-time warning to stderr at load time. Instances of <code class="language-plaintext highlighter-rouge"># :nocov:</code> must be replaced with the new directive comments, <code class="language-plaintext highlighter-rouge"># simplecov:enable</code> and <code class="language-plaintext highlighter-rouge"># simplecov:disable</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="c1"># :nocov:</span>
<span class="k">def</span> <span class="nf">hard_to_test</span>
  <span class="n">legacy_thing</span>
<span class="k">end</span>
<span class="c1"># :nocov:</span>

<span class="c1"># After</span>
<span class="c1"># simplecov:disable</span>
<span class="k">def</span> <span class="nf">hard_to_test</span>
  <span class="n">legacy_thing</span>
<span class="k">end</span>
<span class="c1"># simplecov:enable</span>
</code></pre></div></div>

<p>It’s worth mentioning that the directive comments also allow you to specify if you want to disable only line, branch or method coverage, like so: <code class="language-plaintext highlighter-rouge"># simplecov:disable line</code>. By default, all 3 are enabled.</p>

<h3 id="branches_coverage_percent-and-methods_coverage_percent-are-deprecated"><code class="language-plaintext highlighter-rouge">branches_coverage_percent</code> and <code class="language-plaintext highlighter-rouge">methods_coverage_percent</code> are deprecated</h3>

<p>This will likely affect you only if you have custom formatters. <code class="language-plaintext highlighter-rouge">SimpleCov::SourceFile#branches_coverage_percent</code> and <code class="language-plaintext highlighter-rouge">#methods_coverage_percent</code> are now replaced by <code class="language-plaintext highlighter-rouge">covered_percent</code> which take a criterion argument that defaults to <code class="language-plaintext highlighter-rouge">:line</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="n">file</span><span class="p">.</span><span class="nf">branches_coverage_percent</span>
<span class="n">file</span><span class="p">.</span><span class="nf">methods_coverage_percent</span>

<span class="c1"># After</span>
<span class="n">file</span><span class="p">.</span><span class="nf">covered_percent</span><span class="p">(</span><span class="ss">:branch</span><span class="p">)</span>
<span class="n">file</span><span class="p">.</span><span class="nf">covered_percent</span><span class="p">(</span><span class="ss">:method</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="minimum_coverage_by_-setters-deprecated"><code class="language-plaintext highlighter-rouge">minimum_coverage_by_*</code> setters deprecated</h3>

<p>Finally, the <code class="language-plaintext highlighter-rouge">minimum_coverage_by_file</code> and <code class="language-plaintext highlighter-rouge">minimum_coverage_by_group</code> setters give way to the new <code class="language-plaintext highlighter-rouge">coverage</code> method’s <code class="language-plaintext highlighter-rouge">minimum_per_file</code> and <code class="language-plaintext highlighter-rouge">minimum_per_group</code> verbs:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">minimum_coverage_by_file</span> <span class="ss">line: </span><span class="mi">70</span><span class="p">,</span> <span class="s2">"app/x.rb"</span> <span class="o">=&gt;</span> <span class="mi">100</span>

<span class="c1"># After</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">coverage</span><span class="p">(</span><span class="ss">:line</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">minimum_per_file</span> <span class="mi">70</span>
  <span class="n">minimum_per_file</span> <span class="mi">100</span><span class="p">,</span> <span class="ss">only: </span><span class="s2">"app/x.rb"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The no-arg getters are unchanged, and only the setter forms warn.</p>

<h2 id="new-features-worth-your-time">New features worth your time</h2>

<p>Once you are migrated, there is a real payoff here.</p>

<h3 id="configuring-criteria-and-scoping-the-report">Configuring criteria and scoping the report</h3>

<p>The new criterion-first <code class="language-plaintext highlighter-rouge">coverage</code> method configures each criterion (<code class="language-plaintext highlighter-rouge">:line</code>, <code class="language-plaintext highlighter-rouge">:branch</code>, <code class="language-plaintext highlighter-rouge">:method</code>) in one place, with identical syntax regardless of which one you are configuring:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">coverage</span> <span class="ss">:line</span> <span class="k">do</span>
    <span class="n">minimum</span> <span class="mi">90</span>
    <span class="n">minimum_per_file</span> <span class="mi">80</span>
    <span class="n">maximum_drop</span> <span class="mi">5</span>
  <span class="k">end</span>

  <span class="n">coverage</span> <span class="ss">:branch</span><span class="p">,</span> <span class="ss">minimum: </span><span class="mi">80</span><span class="p">,</span> <span class="ss">primary: </span><span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The options are <code class="language-plaintext highlighter-rouge">minimum</code>, <code class="language-plaintext highlighter-rouge">maximum</code>, <code class="language-plaintext highlighter-rouge">exact</code>, <code class="language-plaintext highlighter-rouge">maximum_drop</code>, <code class="language-plaintext highlighter-rouge">minimum_per_file</code> (with <code class="language-plaintext highlighter-rouge">only:</code> overrides), and <code class="language-plaintext highlighter-rouge">minimum_per_group</code>.</p>

<p>Alongside it, <code class="language-plaintext highlighter-rouge">SimpleCov.cover</code> finally provides an allowlist. Where <code class="language-plaintext highlighter-rouge">add_filter</code> could only ever subtract, <code class="language-plaintext highlighter-rouge">cover</code> is the positive counterpart:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">cover</span> <span class="s2">"app/**/*.rb"</span><span class="p">,</span> <span class="s2">"lib/**/*.rb"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>It accepts string globs, Regexps, blocks, or arrays of those, and multiple calls union together.</p>

<p>Finally, if you want to start from a blank slate, <code class="language-plaintext highlighter-rouge">SimpleCov.no_default_skips</code> opts out of the filters <code class="language-plaintext highlighter-rouge">SimpleCov.start</code> installs.</p>

<h3 id="directive-comments-and-synthetic-branches">Directive comments and synthetic branches</h3>

<p>On the branch side, <code class="language-plaintext highlighter-rouge">SimpleCov.ignore_branches</code> lets you opt out of the synthetic <code class="language-plaintext highlighter-rouge">:else</code> branches that Ruby’s <code class="language-plaintext highlighter-rouge">Coverage</code> library reports for constructs with no literal <code class="language-plaintext highlighter-rouge">else</code> keyword, which includes exhaustive <code class="language-plaintext highlighter-rouge">case/in</code> matches, <code class="language-plaintext highlighter-rouge">case/when</code> without <code class="language-plaintext highlighter-rouge">else</code>, <code class="language-plaintext highlighter-rouge">||=</code>, <code class="language-plaintext highlighter-rouge">&amp;&amp;=</code>, and <code class="language-plaintext highlighter-rouge">if</code> or <code class="language-plaintext highlighter-rouge">unless</code> without <code class="language-plaintext highlighter-rouge">else</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">enable_coverage</span> <span class="ss">:branch</span>
  <span class="n">ignore_branches</span> <span class="ss">:implicit_else</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Rails applications get something else useful here: <code class="language-plaintext highlighter-rouge">ignore_branches :eval_generated</code> and the new <code class="language-plaintext highlighter-rouge">ignore_methods :eval_generated</code> drop the phantom branch and method entries that macros like <code class="language-plaintext highlighter-rouge">delegate</code> inject.</p>

<h3 id="parallel-runners">Parallel runners</h3>

<p>SimpleCov’s coordination with parallel runners now goes through a pluggable <code class="language-plaintext highlighter-rouge">SimpleCov::ParallelAdapters</code> chain instead of hard-coding the <code class="language-plaintext highlighter-rouge">parallel_tests</code> gem’s API. Two adapters ship, one wrapping the <code class="language-plaintext highlighter-rouge">parallel_tests</code> gem and a generic one for any runner following the <code class="language-plaintext highlighter-rouge">TEST_ENV_NUMBER</code> and <code class="language-plaintext highlighter-rouge">PARALLEL_TEST_GROUPS</code> convention. Custom runners can register their own adapter by subclassing <code class="language-plaintext highlighter-rouge">SimpleCov::ParallelAdapters::Base</code>. There is also a new <code class="language-plaintext highlighter-rouge">SimpleCov.parallel_wait_timeout</code> (default 60 seconds) for the case where one worker runs much heavier files and routinely finishes well after the others, so raise it if you want that worker’s coverage in the merge rather than having the threshold checks run against a partial total.</p>

<h2 id="conclusion">Conclusion</h2>

<p>There are other features we decided not to mention for brevity’s sake. They can all be found in the changelog, at any rate. Given what has changed, most migrations to SimpleCov 1.0 should be straightforward and we recommend to go through with it because the new features add many quality of life improvements for gathering good coverage reports especially in CI, which is essential for dealing with tech debt and guaranteeing your app is functioning as expected.</p>

<p>Here at FastRuby.io we use SimpleCov heavily and it’s one of the staple gems we always end up recommending to clients if they aren’t using anything.</p>

<p>If you want to also know what we can do for you and your team to make you move faster and not get dragged down by endless bugs, performance issues and legacy problems no one understands anymore, <a href="https://www.fastruby.io/#contactus">send us a message and let’s talk!</a>.</p>

<h2 id="resources-and-further-reading">Resources and Further Reading</h2>

<ul>
  <li><a href="https://github.com/simplecov-ruby/simplecov/blob/main/CHANGELOG.md#100-2026-07-12">SimpleCov 1.0.0 changelog</a></li>
  <li><a href="https://github.com/simplecov-ruby/simplecov#readme">SimpleCov README</a></li>
  <li><a href="https://docs.ruby-lang.org/en/master/Coverage.html">Ruby’s Coverage library</a></li>
</ul>]]></content><author><name>mateuspereira</name></author><category term="upgrades" /><summary type="html"><![CDATA[SimpleCov released its first stable 1.0 version. Here is a quick guide to the breaking changes and deprecations, how to tell if they affect you, and how to fix each one.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/simplecov-is-now-version-1.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/simplecov-is-now-version-1.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>