<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://hamzagedikkaya.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://hamzagedikkaya.github.io/" rel="alternate" type="text/html" /><updated>2026-05-03T20:10:05+00:00</updated><id>https://hamzagedikkaya.github.io/feed.xml</id><title type="html">Hamza Gedikkaya | Software Developer</title><subtitle>Hamza Gedikkaya - Software Developer. Personal blog and portfolio website featuring technical articles about Ruby on Rails, web development, and software engineering. Project documentations by Hamza Gedikkaya.</subtitle><author><name>Hamza Gedikkaya</name></author><entry><title type="html">EagerEye: Catching the N+1s Bullet Misses — with Static Analysis</title><link href="https://hamzagedikkaya.github.io/rails%20performance/static%20analysis/tools/2026/05/03/eager_eye_static_analysis.html" rel="alternate" type="text/html" title="EagerEye: Catching the N+1s Bullet Misses — with Static Analysis" /><published>2026-05-03T00:00:00+00:00</published><updated>2026-05-03T00:00:00+00:00</updated><id>https://hamzagedikkaya.github.io/rails%20performance/static%20analysis/tools/2026/05/03/eager_eye_static_analysis</id><content type="html" xml:base="https://hamzagedikkaya.github.io/rails%20performance/static%20analysis/tools/2026/05/03/eager_eye_static_analysis.html"><![CDATA[<p>A few months ago I wrote <a href="/rails%20performance/database%20optimization/2025/12/06/beyond_n+1.html">Beyond N+1: Hidden Performance Traps and Fixes</a> about the performance killers Bullet can’t catch — queries inside custom methods, serializer-induced query explosions, callback-driven inserts, and so on. The post ended with a list of techniques and tools to work around Bullet’s blind spots, but it didn’t really answer the obvious follow-up question: <em>can we catch these automatically, in CI, without running a single test?</em></p>

<p>That’s the gap <a href="https://github.com/hamzagedikkaya/eager_eye">EagerEye</a> tries to close. It’s a static analyzer for Rails — no DB, no Rails boot, no runtime hooks. It parses your Ruby files into an AST and looks for patterns that turn into N+1 queries at runtime. In this post I’ll walk through the design decisions behind it, show what it catches that Bullet can’t, share the real-world numbers from running it on production codebases, and explain how to wire it into CI in five lines of YAML.</p>

<hr />

<h2 id="table-of-contents">Table of Contents</h2>

<ol>
  <li><a href="#1-why-static-analysis">Why Static Analysis?</a></li>
  <li><a href="#2-what-eagereye-catches">What EagerEye Catches</a></li>
  <li><a href="#3-design-decisions-why-this-and-not-that">Design Decisions: Why This and Not That</a></li>
  <li><a href="#4-real-world-results-two-production-codebases">Real-World Results: Two Production Codebases</a></li>
  <li><a href="#5-installation-and-first-scan">Installation and First Scan</a></li>
  <li><a href="#6-ci-integration-five-lines-of-yaml">CI Integration: Five Lines of YAML</a></li>
  <li><a href="#7-vs-code-extension-same-engine-in-your-editor">VS Code Extension: Same Engine, in Your Editor</a></li>
  <li><a href="#8-suppressing-false-positives">Suppressing False Positives</a></li>
  <li><a href="#9-known-limitations">Known Limitations</a></li>
  <li><a href="#10-roadmap-and-how-to-contribute">Roadmap and How to Contribute</a></li>
</ol>

<hr />

<h2 id="1-why-static-analysis">1. Why Static Analysis?</h2>

<p>Bullet is a runtime tool. It hooks into ActiveRecord’s association loading mechanism and notices when you call an association that wasn’t preloaded. This is brilliant when it works, but it has a fundamental constraint: <strong>the code path has to actually run</strong>.</p>

<p>Most teams I’ve worked with have decent test coverage for their happy paths but limited coverage for everything else: admin actions, error branches, rarely-hit feature flags, background jobs that only fire on specific events. Bullet sees nothing in any of those code paths. Worse, if the test happens to use fewer records than would trigger an N+1 in production (<code class="language-plaintext highlighter-rouge">create(:user, :with_posts)</code> in a fixture vs. 10,000 users in prod), you can pass tests with hidden N+1s.</p>

<p>Static analysis solves a different problem. It reads every line of code, regardless of whether it ever executes. It finds patterns. It doesn’t need fixtures, doesn’t need a DB, doesn’t need Redis. It runs in milliseconds per file, not minutes.</p>

<p>The trade-off is that it can’t verify anything. A heuristic that matches <code class="language-plaintext highlighter-rouge">posts.each { |p| p.author }</code> might be flagging a real N+1 — or it might be flagging code where <code class="language-plaintext highlighter-rouge">posts</code> was already preloaded somewhere static analysis can’t see. The only honest path forward is to design the tool around minimizing false positives, even if it costs some false negatives.</p>

<p>That principle — <strong>a warning you can trust is more useful than a warning you have to investigate</strong> — is what shaped the entire design.</p>

<hr />

<h2 id="2-what-eagereye-catches">2. What EagerEye Catches</h2>

<p>EagerEye ships 11 detectors. Some overlap with Bullet (the simple loop case), most don’t.</p>

<h3 id="loopassociation--the-obvious-one">LoopAssociation — the obvious one</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">posts</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">post</span><span class="o">|</span>
  <span class="n">post</span><span class="p">.</span><span class="nf">author</span><span class="p">.</span><span class="nf">name</span>      <span class="c1"># query per post</span>
  <span class="n">post</span><span class="p">.</span><span class="nf">comments</span><span class="p">.</span><span class="nf">count</span>   <span class="c1"># another query per post</span>
<span class="k">end</span>
</code></pre></div></div>

<p>EagerEye flags both lines and suggests <code class="language-plaintext highlighter-rouge">.includes(:author, :comments)</code>. So does Bullet, <em>if</em> this code is exercised by a test that loops over enough posts. But if the loop is in an admin export controller that nobody tests, Bullet stays silent. EagerEye doesn’t care — it sees the loop and the association access regardless.</p>

<h3 id="custommethodquery--the-one-bullet-cant-see">CustomMethodQuery — the one Bullet can’t see</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_many</span> <span class="ss">:teams</span>

  <span class="k">def</span> <span class="nf">supports?</span><span class="p">(</span><span class="n">team_name</span><span class="p">)</span>
    <span class="n">teams</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">name: </span><span class="n">team_name</span><span class="p">).</span><span class="nf">exists?</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="vi">@users</span><span class="p">.</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">user</span><span class="o">|</span> <span class="n">user</span><span class="p">.</span><span class="nf">supports?</span><span class="p">(</span><span class="s2">"Lakers"</span><span class="p">)</span> <span class="p">}</span>
</code></pre></div></div>

<p>This is the example I opened the <a href="/rails%20performance/database%20optimization/2025/12/06/beyond_n+1.html#2-hidden-n1s-in-custom-methods">previous post</a> with. Bullet can’t catch it because <code class="language-plaintext highlighter-rouge">teams.where(...)</code> bypasses the association loading hook. EagerEye scans every model file once, builds a map of which methods contain query calls, then flags any iteration that calls one of those methods on the iteration variable.</p>

<h3 id="serializernesting--query-explosions-in-json">SerializerNesting — query explosions in JSON</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">PostSerializer</span> <span class="o">&lt;</span> <span class="no">Blueprinter</span><span class="o">::</span><span class="no">Base</span>
  <span class="n">field</span> <span class="ss">:author_name</span> <span class="p">{</span> <span class="o">|</span><span class="n">post</span><span class="o">|</span> <span class="n">post</span><span class="p">.</span><span class="nf">author</span><span class="p">.</span><span class="nf">name</span> <span class="p">}</span>
<span class="k">end</span>

<span class="c1"># Controller — no preload</span>
<span class="n">render</span> <span class="ss">json: </span><span class="no">PostSerializer</span><span class="p">.</span><span class="nf">render</span><span class="p">(</span><span class="vi">@posts</span><span class="p">)</span>
</code></pre></div></div>

<p>Serializers are an N+1 graveyard. They run once per record, often access nested associations, and the controller doesn’t always know which fields the serializer touches. EagerEye scans Blueprinter, ActiveModel::Serializer, and Alba blocks for nested association access and suggests preloading at the source.</p>

<h3 id="countiniteration--the-count-vs-size-trap">CountInIteration — the <code class="language-plaintext highlighter-rouge">.count</code> vs <code class="language-plaintext highlighter-rouge">.size</code> trap</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="vi">@users</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="ss">:posts</span><span class="p">)</span>
<span class="vi">@users</span><span class="p">.</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">user</span><span class="o">|</span> <span class="n">user</span><span class="p">.</span><span class="nf">posts</span><span class="p">.</span><span class="nf">count</span> <span class="p">}</span>   <span class="c1"># SELECT COUNT(*) per user, even though posts are loaded</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">.count</code> always queries. <code class="language-plaintext highlighter-rouge">.size</code> uses the loaded array if available. Bullet doesn’t catch this because the association <em>is</em> preloaded — you’re just not using the preload. EagerEye flags every <code class="language-plaintext highlighter-rouge">.count</code> on an association inside an iteration block.</p>

<h3 id="callbackquery--the-silent-killer">CallbackQuery — the silent killer</h3>

<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="n">after_create</span> <span class="ss">:notify_subscribers</span>

  <span class="k">def</span> <span class="nf">notify_subscribers</span>
    <span class="n">customer</span><span class="p">.</span><span class="nf">followers</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">follower</span><span class="o">|</span>
      <span class="n">follower</span><span class="p">.</span><span class="nf">notifications</span><span class="p">.</span><span class="nf">create!</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>   <span class="c1"># N inserts + N queries per save</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Order.import(big_array)</code> triggers the callback for every record. Per record, you get an iteration that fires queries. Bullet usually doesn’t run during background jobs and doesn’t track <code class="language-plaintext highlighter-rouge">create!</code> patterns. EagerEye specifically scans <code class="language-plaintext highlighter-rouge">after_*</code> / <code class="language-plaintext highlighter-rouge">before_*</code> / <code class="language-plaintext highlighter-rouge">around_*</code> callback bodies for iteration-driven queries.</p>

<h3 id="and-six-more">And six more</h3>

<p><code class="language-plaintext highlighter-rouge">MissingCounterCache</code>, <code class="language-plaintext highlighter-rouge">PluckToArray</code>, <code class="language-plaintext highlighter-rouge">DelegationNPlusOne</code>, <code class="language-plaintext highlighter-rouge">DecoratorNPlusOne</code>, <code class="language-plaintext highlighter-rouge">ScopeChainNPlusOne</code>, <code class="language-plaintext highlighter-rouge">ValidationNPlusOne</code> — each targets a pattern from the previous post. The full list with code samples lives in the <a href="https://github.com/hamzagedikkaya/eager_eye#what-it-detects">README</a>.</p>

<hr />

<h2 id="3-design-decisions-why-this-and-not-that">3. Design Decisions: Why This and Not That</h2>

<h3 id="why-ast-instead-of-regex">Why AST instead of regex</h3>

<p>The naive approach to “find loops with association calls” is a regex like <code class="language-plaintext highlighter-rouge">/each.*\.(\w+)\.\w+/</code>. This breaks on the first multi-line block, misses Hash literals, and confuses string interpolation with method calls. AST parsing means we work with the actual structure Ruby sees: <code class="language-plaintext highlighter-rouge">:block</code> nodes contain a <code class="language-plaintext highlighter-rouge">:send</code> (the iteration call), an <code class="language-plaintext highlighter-rouge">:args</code> list, and a body of statements. Walking that tree is annoying but reliable.</p>

<p>The implementation uses <a href="https://github.com/whitequark/parser"><code class="language-plaintext highlighter-rouge">whitequark/parser</code></a>, the same parser RuboCop uses. Ruby 3.1+ syntax is supported.</p>

<h3 id="why-per-method-scope-tracking">Why per-method scope tracking</h3>

<p>One of the gnarlier bugs in early versions was this:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">index</span>
  <span class="n">invoices</span> <span class="o">=</span> <span class="no">Invoice</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="ss">:customer</span><span class="p">,</span> <span class="ss">:merchant</span><span class="p">).</span><span class="nf">where</span><span class="p">(</span><span class="ss">active: </span><span class="kp">true</span><span class="p">)</span>
  <span class="vi">@data</span> <span class="o">=</span> <span class="n">invoices</span><span class="p">.</span><span class="nf">map</span> <span class="p">{</span> <span class="o">|</span><span class="n">i</span><span class="o">|</span> <span class="p">[</span><span class="n">i</span><span class="p">.</span><span class="nf">customer</span><span class="p">.</span><span class="nf">name</span><span class="p">,</span> <span class="n">i</span><span class="p">.</span><span class="nf">merchant</span><span class="p">.</span><span class="nf">name</span><span class="p">]</span> <span class="p">}</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">some_other_action</span>
  <span class="n">invoices</span> <span class="o">=</span> <span class="no">Invoice</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">id: </span><span class="n">params</span><span class="p">[</span><span class="ss">:ids</span><span class="p">])</span>  <span class="c1"># no includes here</span>
  <span class="n">invoices</span><span class="p">.</span><span class="nf">update_all</span><span class="p">(</span><span class="ss">status: </span><span class="s1">'archived'</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The first version of EagerEye tracked <code class="language-plaintext highlighter-rouge">invoices</code> globally across the file. So when it processed <code class="language-plaintext highlighter-rouge">def some_other_action</code>, the <code class="language-plaintext highlighter-rouge">invoices</code> variable assignment overwrote the preload information from <code class="language-plaintext highlighter-rouge">def index</code>, and the iteration in <code class="language-plaintext highlighter-rouge">index</code> started getting flagged for N+1 even though <code class="language-plaintext highlighter-rouge">:customer</code> and <code class="language-plaintext highlighter-rouge">:merchant</code> were preloaded.</p>

<p>The fix was treating each <code class="language-plaintext highlighter-rouge">:def</code> body as an independent scope: variables inherit a snapshot from the enclosing scope, but writes inside a method don’t leak out. This is closer to how Ruby actually works and eliminated about 19 false positives on the iwallet codebase alone.</p>

<h3 id="why-caller-method-preload-tracking">Why caller-method preload tracking</h3>

<p>A common Rails pattern is to extract serialization into a helper:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">index</span>
  <span class="vi">@users</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="ss">:profile</span><span class="p">,</span> <span class="ss">:organization</span><span class="p">)</span>
  <span class="vi">@data</span> <span class="o">=</span> <span class="n">prepare_data</span><span class="p">(</span><span class="vi">@users</span><span class="p">)</span>
<span class="k">end</span>

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

<span class="k">def</span> <span class="nf">prepare_data</span><span class="p">(</span><span class="n">users</span><span class="p">)</span>
  <span class="n">users</span><span class="p">.</span><span class="nf">map</span> <span class="p">{</span> <span class="o">|</span><span class="n">u</span><span class="o">|</span> <span class="p">[</span><span class="n">u</span><span class="p">.</span><span class="nf">profile</span><span class="p">.</span><span class="nf">bio</span><span class="p">,</span> <span class="n">u</span><span class="p">.</span><span class="nf">organization</span><span class="p">.</span><span class="nf">name</span><span class="p">]</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The iteration is in <code class="language-plaintext highlighter-rouge">prepare_data</code>. Static analysis can see that <code class="language-plaintext highlighter-rouge">users</code> is a method parameter — but without inter-procedural analysis, it can’t know that the only caller (<code class="language-plaintext highlighter-rouge">index</code>) preloads <code class="language-plaintext highlighter-rouge">:profile</code> and <code class="language-plaintext highlighter-rouge">:organization</code>.</p>

<p>EagerEye does a two-pass analysis per class: pass 1 collects every self-call between sibling methods along with the caller’s variable state at the call site; pass 2 processes each method with its parameters seeded from the merged caller context. If at least one caller preloads <code class="language-plaintext highlighter-rouge">:profile</code>, the parameter inherits that preload. This handles the helper-method pattern that’s everywhere in Rails controllers.</p>

<h3 id="why-prefer-false-negatives-over-false-positives">Why prefer false negatives over false positives</h3>

<p>This is the philosophical decision the entire tool rests on. Every heuristic has knobs. You can lean toward “flag anything that looks suspicious” — high recall, lots of noise, users start ignoring warnings within a week. Or you can lean toward “only flag what we’re confident about” — lower recall, but every warning is actionable.</p>

<p>EagerEye picks the second path. When in doubt, suppress. When a method has multiple callers and only some preload an association, treat it as preloaded (better to miss a real N+1 than to flag a non-issue). When a model isn’t in the parsed set, defer to a small hardcoded list of well-known association names rather than flagging every method call on a loop variable.</p>

<p>The result: in the two production codebases I tested it on, the false positive rate is under 1%. Every flag is worth investigating.</p>

<hr />

<h2 id="4-real-world-results-two-production-codebases">4. Real-World Results: Two Production Codebases</h2>

<p>I ran EagerEye through two production Rails apps I work on. Both are 5+ years old, multi-thousand-file codebases with mature test suites. Bullet runs in their dev environment and catches the N+1s that show up in tests. Here’s what EagerEye found on top of that.</p>

<h3 id="codebase-a-160-files-affected">Codebase A (~160 files affected)</h3>

<table>
  <thead>
    <tr>
      <th>Detector</th>
      <th style="text-align: right">Issues</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>LoopAssociation</td>
      <td style="text-align: right">404</td>
    </tr>
    <tr>
      <td>CustomMethodQuery</td>
      <td style="text-align: right">184</td>
    </tr>
    <tr>
      <td>SerializerNesting</td>
      <td style="text-align: right">150</td>
    </tr>
    <tr>
      <td>CallbackQuery</td>
      <td style="text-align: right">33</td>
    </tr>
    <tr>
      <td>PluckToArray</td>
      <td style="text-align: right">24</td>
    </tr>
    <tr>
      <td>ValidationNPlusOne</td>
      <td style="text-align: right">17</td>
    </tr>
    <tr>
      <td>MissingCounterCache</td>
      <td style="text-align: right">8</td>
    </tr>
    <tr>
      <td>ScopeChainNPlusOne</td>
      <td style="text-align: right">4</td>
    </tr>
    <tr>
      <td>CountInIteration</td>
      <td style="text-align: right">2</td>
    </tr>
    <tr>
      <td>DelegationNPlusOne</td>
      <td style="text-align: right">1</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td style="text-align: right"><strong>827</strong></td>
    </tr>
  </tbody>
</table>

<p>I sampled ~50 issues across detectors and manually verified them against the actual code paths. Real positives: 49. False positives: 1 (and that one was in a code path Bullet also can’t see — it’s a “controller passes preloaded relation to a service object in another file” case).</p>

<h3 id="codebase-b-70-files-affected">Codebase B (~70 files affected)</h3>

<table>
  <thead>
    <tr>
      <th>Detector</th>
      <th style="text-align: right">Issues</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SerializerNesting</td>
      <td style="text-align: right">118</td>
    </tr>
    <tr>
      <td>LoopAssociation</td>
      <td style="text-align: right">75</td>
    </tr>
    <tr>
      <td>CustomMethodQuery</td>
      <td style="text-align: right">17</td>
    </tr>
    <tr>
      <td>PluckToArray</td>
      <td style="text-align: right">5</td>
    </tr>
    <tr>
      <td>CallbackQuery</td>
      <td style="text-align: right">4</td>
    </tr>
    <tr>
      <td>ScopeChainNPlusOne</td>
      <td style="text-align: right">1</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td style="text-align: right"><strong>220</strong></td>
    </tr>
  </tbody>
</table>

<p>Same sampling exercise: 100% real positives in my sample.</p>

<h3 id="what-this-means-in-practice">What this means in practice</h3>

<p>These aren’t all “production-blocking” issues. Some are admin-export controllers that run once a week. Some are background jobs that happen to be fast despite the N+1 because each query is tiny. But every single one is a place where someone made a decision (intentionally or not) to leave a query-per-iteration pattern in the code, and they probably didn’t know.</p>

<p>The really useful warnings are the ones in hot paths. SerializerNesting in <code class="language-plaintext highlighter-rouge">Api::V2::ProductsSerializer</code> rendered millions of times a day is a different problem than a one-off <code class="language-plaintext highlighter-rouge">db:seed</code> script. EagerEye flags both, and you decide which to fix.</p>

<hr />

<h2 id="5-installation-and-first-scan">5. Installation and First Scan</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"eager_eye"</span><span class="p">,</span> <span class="ss">group: :development</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">install</span>
</code></pre></div></div>

<p>That’s it. No Rails initializer, no config file. From your project root:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>eager_eye          <span class="c"># scans app/ by default</span>
eager_eye app/controllers app/serializers   <span class="c"># specific paths</span>
eager_eye <span class="nt">--format</span> json                     <span class="c"># for CI tools to parse</span>
eager_eye <span class="nt">--only</span> loop_association,serializer_nesting   <span class="c"># specific detectors</span>
</code></pre></div></div>

<p>A fresh scan of a typical Rails app finishes in 2-5 seconds.</p>

<p>If you want to suppress detectors or set per-detector severity, generate a config file:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails g eager_eye:install
</code></pre></div></div>

<p>That creates <code class="language-plaintext highlighter-rouge">.eager_eye.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">excluded_paths</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">app/legacy/**</span>
  <span class="pi">-</span> <span class="s">lib/tasks/**</span>

<span class="na">severity_levels</span><span class="pi">:</span>
  <span class="na">loop_association</span><span class="pi">:</span> <span class="s">error</span>
  <span class="na">missing_counter_cache</span><span class="pi">:</span> <span class="s">info</span>

<span class="na">min_severity</span><span class="pi">:</span> <span class="s">warning</span>
<span class="na">fail_on_issues</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">fail_on_issues: true</code> makes the CLI exit with a non-zero status when issues are found — the foundation for CI integration.</p>

<hr />

<h2 id="6-ci-integration-five-lines-of-yaml">6. CI Integration: Five Lines of YAML</h2>

<p>The whole point of static analysis is that it runs without infrastructure. Here’s a complete GitHub Actions workflow:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">EagerEye</span>
<span class="na">on</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">pull_request</span><span class="pi">]</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">analyze</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">ruby/setup-ruby@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">ruby-version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3.3"</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">gem install eager_eye</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">eager_eye app/</span>
</code></pre></div></div>

<p>No DB setup, no <code class="language-plaintext highlighter-rouge">bundle install</code> of your full Gemfile, no fixtures. The whole job runs in under a minute. If new code introduces a flagged pattern, the build fails and the PR is blocked.</p>

<p>For teams that want non-blocking warnings instead, swap the last line for:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">eager_eye app/ --format json &gt; report.json</span>
<span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">issues=$(ruby -rjson -e 'puts JSON.parse(File.read("report.json"))["summary"]["total_issues"]')</span>
    <span class="s">[ "$issues" -gt 0 ] &amp;&amp; echo "::warning::Found $issues potential N+1 issues" || true</span>
</code></pre></div></div>

<p>This uses GitHub Actions’ <code class="language-plaintext highlighter-rouge">::warning::</code> annotation syntax, which surfaces the issue count directly on the PR without failing the build. Useful during a gradual adoption phase where you want visibility but not enforcement.</p>

<hr />

<h2 id="7-vs-code-extension-same-engine-in-your-editor">7. VS Code Extension: Same Engine, in Your Editor</h2>

<p>For the development loop, a CLI run after every change is friction. EagerEye also ships as a <a href="https://marketplace.visualstudio.com/items?itemName=hamzagedikkaya.eager-eye">VS Code extension</a> that runs on save and surfaces issues inline:</p>

<ul>
  <li>Squiggly underline on the offending line</li>
  <li>Hover for the explanation and suggestion</li>
  <li>Quick Fix actions for common patterns (<code class="language-plaintext highlighter-rouge">.pluck(:id)</code> → <code class="language-plaintext highlighter-rouge">.select(:id)</code>, etc.)</li>
  <li>Status bar showing total issue count for the current file</li>
</ul>

<p>The extension is a thin wrapper around the gem — it shells out to the <code class="language-plaintext highlighter-rouge">eager_eye</code> binary on save and parses the JSON output. Same analysis engine, same detection, just a smoother feedback loop.</p>

<p>Recommended workflow: extension during development for fast iteration, CLI in CI to gate PRs.</p>

<hr />

<h2 id="8-suppressing-false-positives">8. Suppressing False Positives</h2>

<p>When EagerEye gets it wrong (rare, but it happens), you suppress like RuboCop:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">user</span><span class="p">.</span><span class="nf">posts</span><span class="p">.</span><span class="nf">count</span>  <span class="c1"># eager_eye:disable CountInIteration</span>

<span class="c1"># eager_eye:disable-next-line LoopAssociation</span>
<span class="vi">@users</span><span class="p">.</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">u</span><span class="o">|</span> <span class="n">u</span><span class="p">.</span><span class="nf">profile</span> <span class="p">}</span>

<span class="c1"># eager_eye:disable LoopAssociation, SerializerNesting</span>
<span class="vi">@users</span><span class="p">.</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">u</span><span class="o">|</span> <span class="n">u</span><span class="p">.</span><span class="nf">posts</span><span class="p">.</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="nb">p</span><span class="o">|</span> <span class="nb">p</span><span class="p">.</span><span class="nf">author</span> <span class="p">}</span> <span class="p">}</span>
<span class="c1"># eager_eye:enable LoopAssociation, SerializerNesting</span>

<span class="c1"># Whole file (must be in first 5 lines)</span>
<span class="c1"># eager_eye:disable-file CustomMethodQuery</span>

<span class="c1"># With explanation</span>
<span class="n">user</span><span class="p">.</span><span class="nf">posts</span><span class="p">.</span><span class="nf">count</span>  <span class="c1"># eager_eye:disable CountInIteration -- using counter_cache</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">-- reason</code> syntax is borrowed from RuboCop and is purely documentation — the linter doesn’t enforce it but reviewers will appreciate it.</p>

<hr />

<h2 id="9-known-limitations">9. Known Limitations</h2>

<p>Static analysis isn’t magic. Three things EagerEye can’t do today:</p>

<p><strong>Cross-file flow tracking.</strong> EagerEye propagates preload context across method calls within the same class. If a controller calls a service object in a different file (<code class="language-plaintext highlighter-rouge">OrderProcessor.new(orders).call</code>), the analyzer can’t see that the orders were preloaded. Same applies to renderable view partials.</p>

<p><strong>Runtime metadata.</strong> EagerEye doesn’t read your DB schema, doesn’t know if a column has an index, doesn’t know how many records actually live in production. A <code class="language-plaintext highlighter-rouge">Post.where(active: true).each</code> looks identical whether <code class="language-plaintext highlighter-rouge">active</code> is on 10 records or 10 million. Bullet plus production monitoring (Skylight, Scout, NewRelic) cover this.</p>

<p><strong>Heuristic association detection.</strong> When a method is called on an iteration variable but the variable’s model class can’t be inferred, EagerEye falls back to a small hardcoded list of common association names (<code class="language-plaintext highlighter-rouge">author</code>, <code class="language-plaintext highlighter-rouge">user</code>, <code class="language-plaintext highlighter-rouge">posts</code>, etc.). This can miss exotic naming, and very rarely it can over-flag (a column happens to share a name with a common association). The hardcoded list errs on the side of suppression.</p>

<p>The honest summary: use EagerEye alongside Bullet, not instead of it. Static catches code paths Bullet can’t reach; runtime catches what static can’t see. They’re complementary.</p>

<hr />

<h2 id="10-roadmap-and-how-to-contribute">10. Roadmap and How to Contribute</h2>

<p>The thing I most want to add next is <strong>inter-file call graph tracking</strong> — propagating preload context not just across same-class methods but across <code class="language-plaintext highlighter-rouge">include</code>d modules and called service objects. The current implementation handles intra-class flow well; cross-file is the main remaining false-positive source.</p>

<p>Beyond that:</p>

<ul>
  <li>A <code class="language-plaintext highlighter-rouge">--baseline</code> mode that snapshots existing issues and only fails CI on <em>new</em> ones (so you can adopt EagerEye on a brownfield project without fixing 800 existing warnings on day one)</li>
  <li>Integration with <a href="https://github.com/troessner/reek">Reek</a> and <a href="https://rubocop.org/">RuboCop</a> for unified linter output</li>
  <li>A web dashboard for tracking issue trends over time</li>
</ul>

<p>If any of these sound useful, the gem is MIT-licensed and open to PRs:</p>

<ul>
  <li>Repo: <a href="https://github.com/hamzagedikkaya/eager_eye">github.com/hamzagedikkaya/eager_eye</a></li>
  <li>VS Code extension: <a href="https://github.com/hamzagedikkaya/eager_eye_vscode">github.com/hamzagedikkaya/eager_eye_vscode</a></li>
  <li>Issues and feature requests welcome.</li>
</ul>

<hr />

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

<p>In the <a href="/rails%20performance/database%20optimization/2025/12/06/beyond_n+1.html">previous post</a> I argued that Bullet only catches the tip of the N+1 iceberg. EagerEye is my attempt at catching most of the rest, automatically, in CI, on every PR — without DB infrastructure, without test fixtures, and without learning a new query DSL.</p>

<p>It won’t catch everything. Static analysis fundamentally can’t. But it shifts the detection point from “after deployment, when production starts paging” to “before merge, when the developer can still fix it cheaply.” That shift is most of what makes a tool useful.</p>

<p>If you try it on a real codebase and find it useful — or find a false positive — open an issue. The whole reason I built this is that the existing tooling left a gap, and the only way to close that gap is to keep iterating on real codebases.</p>

<hr />

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://rubygems.org/gems/eager_eye">EagerEye on RubyGems</a></li>
  <li><a href="https://github.com/hamzagedikkaya/eager_eye">EagerEye on GitHub</a></li>
  <li><a href="https://marketplace.visualstudio.com/items?itemName=hamzagedikkaya.eager-eye">VS Code Extension</a></li>
  <li><a href="/rails%20performance/database%20optimization/2025/12/06/beyond_n+1.html">Beyond N+1: Hidden Performance Traps and Fixes</a> (the post that motivated this tool)</li>
  <li><a href="https://github.com/flyerhzm/bullet">Bullet Gem</a></li>
  <li><a href="https://github.com/charkost/prosopite">Prosopite</a></li>
  <li><a href="https://github.com/whitequark/parser">Parser Gem</a> (the AST library EagerEye is built on)</li>
  <li><a href="https://github.com/rubocop/rubocop">RuboCop</a> (architectural inspiration for the suppression syntax)</li>
</ul>]]></content><author><name>Hamza Gedikkaya</name></author><category term="Rails Performance" /><category term="Static Analysis" /><category term="Tools" /><category term="Ruby on Rails" /><category term="Performance" /><category term="ActiveRecord" /><category term="Static Analysis" /><category term="Open Source" /><summary type="html"><![CDATA[A few months ago I wrote Beyond N+1: Hidden Performance Traps and Fixes about the performance killers Bullet can’t catch — queries inside custom methods, serializer-induced query explosions, callback-driven inserts, and so on. The post ended with a list of techniques and tools to work around Bullet’s blind spots, but it didn’t really answer the obvious follow-up question: can we catch these automatically, in CI, without running a single test?]]></summary></entry><entry><title type="html">Beyond N+1: Hidden Performance Traps and Fixes</title><link href="https://hamzagedikkaya.github.io/rails%20performance/database%20optimization/2025/12/06/beyond_n+1.html" rel="alternate" type="text/html" title="Beyond N+1: Hidden Performance Traps and Fixes" /><published>2025-12-06T00:00:00+00:00</published><updated>2025-12-06T00:00:00+00:00</updated><id>https://hamzagedikkaya.github.io/rails%20performance/database%20optimization/2025/12/06/beyond_n+1</id><content type="html" xml:base="https://hamzagedikkaya.github.io/rails%20performance/database%20optimization/2025/12/06/beyond_n+1.html"><![CDATA[<p>Bullet is an excellent gem for catching N+1 queries. However, a significant portion of the performance issues we encounter in production fly completely under Bullet’s radar. In this post, we’ll explore the performance killers that Bullet can’t detect and how to identify and fix them.</p>

<hr />

<h2 id="table-of-contents">Table of Contents</h2>

<ol>
  <li><a href="#1-bullets-limitations">Bullet’s Limitations</a></li>
  <li><a href="#2-hidden-n1s-in-custom-methods">Hidden N+1s in Custom Methods</a></li>
  <li><a href="#3-serializer-induced-query-explosions">Serializer-Induced Query Explosions</a></li>
  <li><a href="#4-the-count-vs-size-vs-length-trap">The Count vs Size vs Length Trap</a></li>
  <li><a href="#5-silent-killers-in-callbacks">Silent Killers in Callbacks</a></li>
  <li><a href="#6-select-vs-pluck-memory-and-performance-trade-offs">Select vs Pluck: Memory and Performance Trade-offs</a></li>
  <li><a href="#7-finding-real-issues-with-explain-analyze">Finding Real Issues with EXPLAIN ANALYZE</a></li>
  <li><a href="#8-index-strategies">Index Strategies</a></li>
  <li><a href="#9-tools-and-best-practices">Tools and Best Practices</a></li>
</ol>

<hr />

<h2 id="1-bullets-limitations">1. Bullet’s Limitations</h2>

<p>Bullet (currently v8.0.8 as of 2025) detects eager loading issues through ActiveRecord association hooks. However, it remains silent in these scenarios:</p>

<ul>
  <li>Queries inside custom methods (<code class="language-plaintext highlighter-rouge">.where</code>, <code class="language-plaintext highlighter-rouge">.exists?</code>, <code class="language-plaintext highlighter-rouge">.find_by</code>)</li>
  <li>Relationships defined in serializers without AR association hooks</li>
  <li>Conditional queries that don’t go through standard association loading</li>
  <li>Database operations inside callbacks</li>
  <li>View and controller tests (when views aren’t rendered)</li>
  <li>Background jobs (requires special configuration)</li>
</ul>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Bullet WILL NOT CATCH THIS</span>
<span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_many</span> <span class="ss">:teams</span>

  <span class="k">def</span> <span class="nf">supports?</span><span class="p">(</span><span class="n">team_name</span><span class="p">)</span>
    <span class="n">teams</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">name: </span><span class="n">team_name</span><span class="p">).</span><span class="nf">exists?</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># In the view - separate query for each user</span>
<span class="o">&lt;</span><span class="sx">% @users.each </span><span class="k">do</span> <span class="o">|</span><span class="n">user</span><span class="o">|</span> <span class="sx">%&gt;
  &lt;li class="&lt;%= user.supports?("Lakers") ? "purple" : "" %&gt;</span><span class="s2">"&gt;
    &lt;%= user.name %&gt;
  &lt;/li&gt;
&lt;% end %&gt;
</span></code></pre></div></div>

<p>This code generates 101 queries for 100 users, and Bullet gives no warning.</p>

<h3 id="why-this-happens">Why This Happens</h3>

<p>Bullet hooks into ActiveRecord’s association loading mechanism. When you call <code class="language-plaintext highlighter-rouge">user.teams</code>, it tracks whether the association was preloaded. But when you call <code class="language-plaintext highlighter-rouge">teams.where(...)</code>, you’re creating a new query scope that bypasses the association tracking entirely.</p>

<hr />

<h2 id="2-hidden-n1s-in-custom-methods">2. Hidden N+1s in Custom Methods</h2>

<h3 id="the-problem">The Problem</h3>

<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="n">belongs_to</span> <span class="ss">:user</span>
  <span class="n">has_many</span> <span class="ss">:line_items</span>

  <span class="k">def</span> <span class="nf">total_with_discount</span>
    <span class="c1"># Separate query for each order</span>
    <span class="n">user</span><span class="p">.</span><span class="nf">loyalty_tier</span><span class="p">.</span><span class="nf">discount_rate</span> <span class="o">*</span> <span class="n">line_items</span><span class="p">.</span><span class="nf">sum</span><span class="p">(</span><span class="ss">:price</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># Controller</span>
<span class="k">def</span> <span class="nf">index</span>
  <span class="vi">@orders</span> <span class="o">=</span> <span class="no">Order</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="ss">:line_items</span><span class="p">).</span><span class="nf">limit</span><span class="p">(</span><span class="mi">50</span><span class="p">)</span>
<span class="k">end</span>

<span class="c1"># View - Bullet is silent, but user and loyalty_tier cause N+1</span>
<span class="o">&lt;</span><span class="sx">% @orders.each </span><span class="k">do</span> <span class="o">|</span><span class="n">order</span><span class="o">|</span> <span class="sx">%&gt;
  &lt;td&gt;</span><span class="o">&lt;</span><span class="sx">%= order.total_with_discount %&gt;&lt;/td&gt;
&lt;% end %&gt;
</span></code></pre></div></div>

<h3 id="solution-1-expand-eager-loading">Solution 1: Expand Eager Loading</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">index</span>
  <span class="vi">@orders</span> <span class="o">=</span> <span class="no">Order</span>
    <span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="ss">:line_items</span><span class="p">,</span> <span class="ss">user: :loyalty_tier</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">limit</span><span class="p">(</span><span class="mi">50</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="solution-2-preload-and-pass-data">Solution 2: Preload and Pass Data</h3>

<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="k">def</span> <span class="nf">total_with_discount</span><span class="p">(</span><span class="n">discount_rate</span> <span class="o">=</span> <span class="kp">nil</span><span class="p">)</span>
    <span class="n">rate</span> <span class="o">=</span> <span class="n">discount_rate</span> <span class="o">||</span> <span class="n">user</span><span class="p">.</span><span class="nf">loyalty_tier</span><span class="p">.</span><span class="nf">discount_rate</span>
    <span class="n">rate</span> <span class="o">*</span> <span class="n">line_items</span><span class="p">.</span><span class="nf">sum</span><span class="p">(</span><span class="ss">:price</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># Controller - preload separately, pass to view</span>
<span class="k">def</span> <span class="nf">index</span>
  <span class="vi">@orders</span> <span class="o">=</span> <span class="no">Order</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="ss">:line_items</span><span class="p">).</span><span class="nf">limit</span><span class="p">(</span><span class="mi">50</span><span class="p">)</span>
  <span class="n">user_ids</span> <span class="o">=</span> <span class="vi">@orders</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:user_id</span><span class="p">).</span><span class="nf">uniq</span>
  
  <span class="vi">@discount_rates</span> <span class="o">=</span> <span class="no">User</span>
    <span class="p">.</span><span class="nf">joins</span><span class="p">(</span><span class="ss">:loyalty_tier</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">id: </span><span class="n">user_ids</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">pluck</span><span class="p">(</span><span class="ss">:id</span><span class="p">,</span> <span class="s2">"loyalty_tiers.discount_rate"</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">to_h</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="solution-3-use-prosopite-for-detection">Solution 3: Use Prosopite for Detection</h3>

<p>Prosopite is an alternative gem that detects N+1s by counting actual SQL queries rather than tracking associations:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s1">'prosopite'</span>

<span class="c1"># config/environments/development.rb</span>
<span class="n">config</span><span class="p">.</span><span class="nf">after_initialize</span> <span class="k">do</span>
  <span class="no">Prosopite</span><span class="p">.</span><span class="nf">rails_logger</span> <span class="o">=</span> <span class="kp">true</span>
  <span class="no">Prosopite</span><span class="p">.</span><span class="nf">raise</span> <span class="o">=</span> <span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Prosopite will catch the <code class="language-plaintext highlighter-rouge">supports?</code> method N+1 that Bullet misses.</p>

<hr />

<h2 id="3-serializer-induced-query-explosions">3. Serializer-Induced Query Explosions</h2>

<p>ActiveModel::Serializers and similar gems automatically call associations, and these often escape Bullet’s detection.</p>

<h3 id="the-problem-1">The Problem</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">PostSerializer</span> <span class="o">&lt;</span> <span class="no">ActiveModel</span><span class="o">::</span><span class="no">Serializer</span>
  <span class="n">attributes</span> <span class="ss">:id</span><span class="p">,</span> <span class="ss">:title</span><span class="p">,</span> <span class="ss">:author_name</span><span class="p">,</span> <span class="ss">:comments_count</span>

  <span class="k">def</span> <span class="nf">author_name</span>
    <span class="n">object</span><span class="p">.</span><span class="nf">user</span><span class="p">.</span><span class="nf">name</span>  <span class="c1"># N+1</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">comments_count</span>
    <span class="n">object</span><span class="p">.</span><span class="nf">comments</span><span class="p">.</span><span class="nf">count</span>  <span class="c1"># N+1 (should use counter_cache)</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># Controller</span>
<span class="k">def</span> <span class="nf">index</span>
  <span class="n">posts</span> <span class="o">=</span> <span class="no">Post</span><span class="p">.</span><span class="nf">all</span>
  <span class="n">render</span> <span class="ss">json: </span><span class="n">posts</span>  <span class="c1"># 2 extra queries per post</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="solution-1-eager-loading-in-controller">Solution 1: Eager Loading in Controller</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">index</span>
  <span class="n">posts</span> <span class="o">=</span> <span class="no">Post</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="ss">:user</span><span class="p">,</span> <span class="ss">:comments</span><span class="p">)</span>
  <span class="n">render</span> <span class="ss">json: </span><span class="n">posts</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="solution-2-check-if-association-is-loaded">Solution 2: Check if Association is Loaded</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">PostSerializer</span> <span class="o">&lt;</span> <span class="no">ActiveModel</span><span class="o">::</span><span class="no">Serializer</span>
  <span class="n">attributes</span> <span class="ss">:id</span><span class="p">,</span> <span class="ss">:title</span><span class="p">,</span> <span class="ss">:author_name</span>

  <span class="n">belongs_to</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">if: </span><span class="o">-&gt;</span> <span class="p">{</span> <span class="n">object</span><span class="p">.</span><span class="nf">association</span><span class="p">(</span><span class="ss">:user</span><span class="p">).</span><span class="nf">loaded?</span> <span class="p">}</span>

  <span class="k">def</span> <span class="nf">author_name</span>
    <span class="k">return</span> <span class="kp">nil</span> <span class="k">unless</span> <span class="n">object</span><span class="p">.</span><span class="nf">association</span><span class="p">(</span><span class="ss">:user</span><span class="p">).</span><span class="nf">loaded?</span>
    <span class="n">object</span><span class="p">.</span><span class="nf">user</span><span class="p">.</span><span class="nf">name</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="solution-3-batchloader-for-lazy-loading">Solution 3: BatchLoader for Lazy Loading</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s1">'batch-loader'</span>

<span class="k">class</span> <span class="nc">Post</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="k">def</span> <span class="nf">author_lazily</span>
    <span class="no">BatchLoader</span><span class="p">.</span><span class="nf">for</span><span class="p">(</span><span class="n">user_id</span><span class="p">).</span><span class="nf">batch</span> <span class="k">do</span> <span class="o">|</span><span class="n">user_ids</span><span class="p">,</span> <span class="n">loader</span><span class="o">|</span>
      <span class="no">User</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">id: </span><span class="n">user_ids</span><span class="p">).</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">user</span><span class="o">|</span> <span class="n">loader</span><span class="p">.</span><span class="nf">call</span><span class="p">(</span><span class="n">user</span><span class="p">.</span><span class="nf">id</span><span class="p">,</span> <span class="n">user</span><span class="p">)</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>BatchLoader collects all IDs during serialization and executes a single query at the end.</p>

<hr />

<h2 id="4-the-count-vs-size-vs-length-trap">4. The Count vs Size vs Length Trap</h2>

<p>These three methods return the same result but have vastly different performance characteristics.</p>

<h3 id="comparison">Comparison</h3>

<table>
  <thead>
    <tr>
      <th>Method</th>
      <th>Behavior</th>
      <th>Query</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">count</code></td>
      <td>Always executes COUNT query</td>
      <td><code class="language-plaintext highlighter-rouge">SELECT COUNT(*) FROM...</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">length</code></td>
      <td>Loads collection, counts in Ruby</td>
      <td><code class="language-plaintext highlighter-rouge">SELECT * FROM...</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">size</code></td>
      <td>Smart: uses length if loaded, count otherwise</td>
      <td>Depends on context</td>
    </tr>
  </tbody>
</table>

<h3 id="the-problem-2">The Problem</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># We eager loaded with includes</span>
<span class="vi">@users</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="ss">:posts</span><span class="p">).</span><span class="nf">where</span><span class="p">(</span><span class="ss">active: </span><span class="kp">true</span><span class="p">)</span>

<span class="vi">@users</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">user</span><span class="o">|</span>
  <span class="c1"># WRONG: Despite includes, executes COUNT query for each user</span>
  <span class="nb">puts</span> <span class="s2">"</span><span class="si">#{</span><span class="n">user</span><span class="p">.</span><span class="nf">name</span><span class="si">}</span><span class="s2">: </span><span class="si">#{</span><span class="n">user</span><span class="p">.</span><span class="nf">posts</span><span class="p">.</span><span class="nf">count</span><span class="si">}</span><span class="s2"> posts"</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="the-solution">The Solution</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="vi">@users</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">user</span><span class="o">|</span>
  <span class="c1"># CORRECT: Counts the loaded collection in Ruby</span>
  <span class="nb">puts</span> <span class="s2">"</span><span class="si">#{</span><span class="n">user</span><span class="p">.</span><span class="nf">name</span><span class="si">}</span><span class="s2">: </span><span class="si">#{</span><span class="n">user</span><span class="p">.</span><span class="nf">posts</span><span class="p">.</span><span class="nf">size</span><span class="si">}</span><span class="s2"> posts"</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="when-to-use-counter-cache">When to Use Counter Cache</h3>

<p>For frequently accessed counts, counter_cache is the best solution:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Migration</span>
<span class="n">add_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:posts_count</span><span class="p">,</span> <span class="ss">:integer</span><span class="p">,</span> <span class="ss">default: </span><span class="mi">0</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>

<span class="c1"># Model</span>
<span class="k">class</span> <span class="nc">Post</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">counter_cache: </span><span class="kp">true</span>
<span class="k">end</span>

<span class="c1"># Backfill existing data</span>
<span class="no">User</span><span class="p">.</span><span class="nf">find_each</span> <span class="k">do</span> <span class="o">|</span><span class="n">user</span><span class="o">|</span>
  <span class="no">User</span><span class="p">.</span><span class="nf">reset_counters</span><span class="p">(</span><span class="n">user</span><span class="p">.</span><span class="nf">id</span><span class="p">,</span> <span class="ss">:posts</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="conditional-counter-cache-with-counter_culture">Conditional Counter Cache with counter_culture</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s1">'counter_culture'</span>

<span class="k">class</span> <span class="nc">Order</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:customer</span>
  <span class="n">counter_culture</span> <span class="ss">:customer</span>  <span class="c1"># orders_count</span>
  
  <span class="n">counter_culture</span> <span class="ss">:customer</span><span class="p">,</span> 
    <span class="ss">column_name: </span><span class="nb">proc</span> <span class="p">{</span> <span class="o">|</span><span class="n">order</span><span class="o">|</span> <span class="n">order</span><span class="p">.</span><span class="nf">cancelled?</span> <span class="p">?</span> <span class="s1">'cancelled_orders_count'</span> <span class="p">:</span> <span class="kp">nil</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<hr />

<h2 id="5-silent-killers-in-callbacks">5. Silent Killers in Callbacks</h2>

<p>Database operations inside callbacks can become performance killers, especially during bulk operations.</p>

<h3 id="the-problem-3">The Problem</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Article</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">after_save</span> <span class="ss">:update_search_index</span>
  <span class="n">after_save</span> <span class="ss">:notify_subscribers</span>
  <span class="n">after_save</span> <span class="ss">:recalculate_stats</span>

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

  <span class="k">def</span> <span class="nf">update_search_index</span>
    <span class="no">SearchIndex</span><span class="p">.</span><span class="nf">update</span><span class="p">(</span><span class="nb">self</span><span class="p">)</span>  <span class="c1"># External API call</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">notify_subscribers</span>
    <span class="n">subscribers</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>  <span class="c1"># N+1 potential</span>
      <span class="no">NotificationMailer</span><span class="p">.</span><span class="nf">new_article</span><span class="p">(</span><span class="nb">sub</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>

  <span class="k">def</span> <span class="nf">recalculate_stats</span>
    <span class="n">author</span><span class="p">.</span><span class="nf">articles</span><span class="p">.</span><span class="nf">published</span><span class="p">.</span><span class="nf">count</span>  <span class="c1"># Query on every save</span>
    <span class="n">category</span><span class="p">.</span><span class="nf">update_article_count!</span>   <span class="c1"># Update on every save</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># Disaster during bulk import</span>
<span class="no">Article</span><span class="p">.</span><span class="nf">import</span><span class="p">(</span><span class="n">articles_data</span><span class="p">)</span>  <span class="c1"># 1000 articles = 3000+ callbacks</span>
</code></pre></div></div>

<h3 id="solution-1-make-callbacks-conditional">Solution 1: Make Callbacks Conditional</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Article</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="nb">attr_accessor</span> <span class="ss">:skip_callbacks</span>

  <span class="n">after_save</span> <span class="ss">:update_search_index</span><span class="p">,</span> <span class="ss">unless: :skip_callbacks</span>
  <span class="n">after_save</span> <span class="ss">:notify_subscribers</span><span class="p">,</span> <span class="ss">unless: :skip_callbacks</span>
<span class="k">end</span>

<span class="c1"># Bulk import</span>
<span class="no">Article</span><span class="p">.</span><span class="nf">transaction</span> <span class="k">do</span>
  <span class="n">articles_data</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">data</span><span class="o">|</span>
    <span class="no">Article</span><span class="p">.</span><span class="nf">create!</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="nf">merge</span><span class="p">(</span><span class="ss">skip_callbacks: </span><span class="kp">true</span><span class="p">))</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># Run post-import operations in batch</span>
<span class="no">Article</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">id: </span><span class="n">imported_ids</span><span class="p">).</span><span class="nf">find_each</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:update_search_index</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="solution-2-use-after_commit">Solution 2: Use after_commit</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Article</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="c1"># Runs after transaction commits</span>
  <span class="c1"># Safe for Sidekiq jobs</span>
  <span class="n">after_commit</span> <span class="ss">:notify_subscribers_async</span><span class="p">,</span> <span class="ss">on: :create</span>

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

  <span class="k">def</span> <span class="nf">notify_subscribers_async</span>
    <span class="no">NotifySubscribersJob</span><span class="p">.</span><span class="nf">perform_later</span><span class="p">(</span><span class="nb">id</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="solution-3-suppressor-for-temporary-bypass">Solution 3: Suppressor for Temporary Bypass</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Rails 5+</span>
<span class="no">Notification</span><span class="p">.</span><span class="nf">suppress</span> <span class="k">do</span>
  <span class="no">User</span><span class="p">.</span><span class="nf">create!</span><span class="p">(</span><span class="ss">name: </span><span class="s2">"Jane"</span><span class="p">)</span>  <span class="c1"># Notification callback won't run</span>
<span class="k">end</span>
</code></pre></div></div>

<hr />

<h2 id="6-select-vs-pluck-memory-and-performance-trade-offs">6. Select vs Pluck: Memory and Performance Trade-offs</h2>

<p>Choosing the right method is critical when working with large datasets.</p>

<h3 id="benchmark-comparison-10000-records">Benchmark Comparison (10,000 records)</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># SLOWEST: Load full objects, then map</span>
<span class="no">User</span><span class="p">.</span><span class="nf">all</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:id</span><span class="p">)</span>
<span class="c1"># ~270ms, high memory</span>

<span class="c1"># MEDIUM: Select only id, but still creates AR objects</span>
<span class="no">User</span><span class="p">.</span><span class="nf">select</span><span class="p">(</span><span class="ss">:id</span><span class="p">).</span><span class="nf">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:id</span><span class="p">)</span>
<span class="c1"># ~100ms, medium memory</span>

<span class="c1"># FASTEST: Returns array directly, no AR objects</span>
<span class="no">User</span><span class="p">.</span><span class="nf">pluck</span><span class="p">(</span><span class="ss">:id</span><span class="p">)</span>
<span class="c1"># ~15ms, low memory</span>
</code></pre></div></div>

<h3 id="when-to-use-which">When to Use Which?</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># PLUCK: When you only need values</span>
<span class="n">user_ids</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">active</span><span class="p">.</span><span class="nf">pluck</span><span class="p">(</span><span class="ss">:id</span><span class="p">)</span>
<span class="n">emails</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">role: :admin</span><span class="p">).</span><span class="nf">pluck</span><span class="p">(</span><span class="ss">:email</span><span class="p">)</span>

<span class="c1"># SELECT: When you need AR methods</span>
<span class="n">users</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">select</span><span class="p">(</span><span class="ss">:id</span><span class="p">,</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">:email</span><span class="p">)</span>
<span class="n">users</span><span class="p">.</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">u</span><span class="o">|</span> <span class="nb">puts</span> <span class="n">u</span><span class="p">.</span><span class="nf">full_display_name</span> <span class="p">}</span>  <span class="c1"># AR method call</span>

<span class="c1"># PLUCK + SUBQUERY: Performant IN clause</span>
<span class="no">Post</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">user_id: </span><span class="no">User</span><span class="p">.</span><span class="nf">active</span><span class="p">.</span><span class="nf">select</span><span class="p">(</span><span class="ss">:id</span><span class="p">))</span>
<span class="c1"># Single query: SELECT * FROM posts WHERE user_id IN (SELECT id FROM users WHERE...)</span>

<span class="c1"># WRONG: pluck with subquery</span>
<span class="no">Post</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">user_id: </span><span class="no">User</span><span class="p">.</span><span class="nf">active</span><span class="p">.</span><span class="nf">pluck</span><span class="p">(</span><span class="ss">:id</span><span class="p">))</span>
<span class="c1"># Two queries + array held in memory</span>
</code></pre></div></div>

<h3 id="lazy-enumeration-for-large-datasets">Lazy Enumeration for Large Datasets</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Memory-friendly iteration</span>
<span class="no">User</span><span class="p">.</span><span class="nf">select</span><span class="p">(</span><span class="ss">:id</span><span class="p">,</span> <span class="ss">:email</span><span class="p">).</span><span class="nf">find_each</span><span class="p">(</span><span class="ss">batch_size: </span><span class="mi">1000</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">user</span><span class="o">|</span>
  <span class="n">process</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
<span class="k">end</span>

<span class="c1"># Even more efficient with postgresql_cursor gem</span>
<span class="no">User</span><span class="p">.</span><span class="nf">select</span><span class="p">(</span><span class="ss">:id</span><span class="p">,</span> <span class="ss">:email</span><span class="p">).</span><span class="nf">each_row</span> <span class="k">do</span> <span class="o">|</span><span class="n">row</span><span class="o">|</span>
  <span class="n">process</span><span class="p">(</span><span class="n">row</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<hr />

<h2 id="7-finding-real-issues-with-explain-analyze">7. Finding Real Issues with EXPLAIN ANALYZE</h2>

<p>Bullet and similar tools catch N+1s, but the real performance problem is sometimes hidden in a single slow query.</p>

<h3 id="using-explain-in-rails">Using EXPLAIN in Rails</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Basic explain</span>
<span class="no">User</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">email: </span><span class="s2">"test@example.com"</span><span class="p">).</span><span class="nf">explain</span>

<span class="c1"># Detailed analysis (actually executes the query)</span>
<span class="no">User</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">email: </span><span class="s2">"test@example.com"</span><span class="p">).</span><span class="nf">explain</span><span class="p">(</span><span class="ss">:analyze</span><span class="p">)</span>

<span class="c1"># More detailed with activerecord-analyze gem</span>
<span class="no">User</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">email: </span><span class="s2">"test@example.com"</span><span class="p">).</span><span class="nf">analyze</span><span class="p">(</span>
  <span class="ss">format: :json</span><span class="p">,</span>
  <span class="ss">buffers: </span><span class="kp">true</span><span class="p">,</span>
  <span class="ss">timing: </span><span class="kp">true</span>
<span class="p">)</span>
</code></pre></div></div>

<h3 id="reading-explain-output">Reading EXPLAIN Output</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">EXPLAIN</span> <span class="k">ANALYZE</span> <span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">users</span> <span class="k">WHERE</span> <span class="n">email</span> <span class="o">=</span> <span class="s1">'test@example.com'</span><span class="p">;</span>

<span class="c1">-- BAD: Sequential Scan (scans entire table)</span>
<span class="n">Seq</span> <span class="n">Scan</span> <span class="k">on</span> <span class="n">users</span>  <span class="p">(</span><span class="n">cost</span><span class="o">=</span><span class="mi">0</span><span class="p">.</span><span class="mi">00</span><span class="p">..</span><span class="mi">1234</span><span class="p">.</span><span class="mi">00</span> <span class="k">rows</span><span class="o">=</span><span class="mi">1</span> <span class="n">width</span><span class="o">=</span><span class="mi">244</span><span class="p">)</span> 
  <span class="p">(</span><span class="n">actual</span> <span class="nb">time</span><span class="o">=</span><span class="mi">45</span><span class="p">.</span><span class="mi">123</span><span class="p">..</span><span class="mi">89</span><span class="p">.</span><span class="mi">456</span> <span class="k">rows</span><span class="o">=</span><span class="mi">1</span> <span class="n">loops</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
  <span class="n">Filter</span><span class="p">:</span> <span class="p">(</span><span class="n">email</span> <span class="o">=</span> <span class="s1">'test@example.com'</span><span class="p">)</span>
  <span class="k">Rows</span> <span class="n">Removed</span> <span class="k">by</span> <span class="n">Filter</span><span class="p">:</span> <span class="mi">99999</span>

<span class="c1">-- GOOD: Index Scan</span>
<span class="k">Index</span> <span class="n">Scan</span> <span class="k">using</span> <span class="n">index_users_on_email</span> <span class="k">on</span> <span class="n">users</span>  
  <span class="p">(</span><span class="n">cost</span><span class="o">=</span><span class="mi">0</span><span class="p">.</span><span class="mi">42</span><span class="p">..</span><span class="mi">8</span><span class="p">.</span><span class="mi">44</span> <span class="k">rows</span><span class="o">=</span><span class="mi">1</span> <span class="n">width</span><span class="o">=</span><span class="mi">244</span><span class="p">)</span> 
  <span class="p">(</span><span class="n">actual</span> <span class="nb">time</span><span class="o">=</span><span class="mi">0</span><span class="p">.</span><span class="mi">026</span><span class="p">..</span><span class="mi">0</span><span class="p">.</span><span class="mi">027</span> <span class="k">rows</span><span class="o">=</span><span class="mi">1</span> <span class="n">loops</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
  <span class="k">Index</span> <span class="n">Cond</span><span class="p">:</span> <span class="p">(</span><span class="n">email</span> <span class="o">=</span> <span class="s1">'test@example.com'</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="red-flags-to-watch-for">Red Flags to Watch For</h3>

<table>
  <thead>
    <tr>
      <th>Situation</th>
      <th>Meaning</th>
      <th>Solution</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Seq Scan</code> on large table</td>
      <td>Index not being used</td>
      <td>Add index</td>
    </tr>
    <tr>
      <td>High <code class="language-plaintext highlighter-rouge">Rows Removed by Filter</code></td>
      <td>Too much data being filtered</td>
      <td>Optimize WHERE clause</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Sort</code> with high cost</td>
      <td>In-memory sorting</td>
      <td>Add index with order</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Hash Join</code> with high cost</td>
      <td>Join not optimized</td>
      <td>Add index on FK</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Nested Loop</code> with many loops</td>
      <td>N+1-like situation</td>
      <td>Convert to batch query</td>
    </tr>
  </tbody>
</table>

<h3 id="pghero-and-rails-pg-extras">PgHero and rails-pg-extras</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s1">'pghero'</span>
<span class="n">gem</span> <span class="s1">'rails-pg-extras'</span>

<span class="c1"># Find slow queries</span>
<span class="no">PgHero</span><span class="p">.</span><span class="nf">slow_queries</span>

<span class="c1"># Unused indexes</span>
<span class="no">PgExtras</span><span class="p">.</span><span class="nf">unused_indexes</span>

<span class="c1"># Index hit rate (below 95% indicates problems)</span>
<span class="no">PgExtras</span><span class="p">.</span><span class="nf">index_usage</span>
</code></pre></div></div>

<hr />

<h2 id="8-index-strategies">8. Index Strategies</h2>

<h3 id="compound-index-column-order">Compound Index Column Order</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Migration</span>
<span class="n">add_index</span> <span class="ss">:orders</span><span class="p">,</span> <span class="p">[</span><span class="ss">:user_id</span><span class="p">,</span> <span class="ss">:status</span><span class="p">,</span> <span class="ss">:created_at</span><span class="p">]</span>

<span class="c1"># WORKS (left-to-right matching)</span>
<span class="no">Order</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">user_id: </span><span class="mi">1</span><span class="p">)</span>
<span class="no">Order</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">user_id: </span><span class="mi">1</span><span class="p">,</span> <span class="ss">status: </span><span class="s1">'pending'</span><span class="p">)</span>
<span class="no">Order</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">user_id: </span><span class="mi">1</span><span class="p">,</span> <span class="ss">status: </span><span class="s1">'pending'</span><span class="p">).</span><span class="nf">order</span><span class="p">(</span><span class="ss">created_at: :desc</span><span class="p">)</span>

<span class="c1"># DOESN'T WORK (user_id skipped)</span>
<span class="no">Order</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">status: </span><span class="s1">'pending'</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="partial-index">Partial Index</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Index only active records</span>
<span class="n">add_index</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:email</span><span class="p">,</span> 
  <span class="ss">where: </span><span class="s2">"deleted_at IS NULL"</span><span class="p">,</span> 
  <span class="ss">name: </span><span class="s2">"index_active_users_on_email"</span>

<span class="c1"># Index only specific statuses</span>
<span class="n">add_index</span> <span class="ss">:orders</span><span class="p">,</span> <span class="ss">:created_at</span><span class="p">,</span> 
  <span class="ss">where: </span><span class="s2">"status = 'pending'"</span><span class="p">,</span>
  <span class="ss">name: </span><span class="s2">"index_pending_orders_on_created_at"</span>
</code></pre></div></div>

<h3 id="covering-index-index-only-scan">Covering Index (Index-Only Scan)</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># If all columns in SELECT are in the index,</span>
<span class="c1"># PostgreSQL doesn't need to access the table</span>
<span class="n">add_index</span> <span class="ss">:users</span><span class="p">,</span> <span class="p">[</span><span class="ss">:email</span><span class="p">,</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">:role</span><span class="p">]</span>

<span class="c1"># This query is answered entirely from the index</span>
<span class="no">User</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">email: </span><span class="s2">"x@y.com"</span><span class="p">).</span><span class="nf">select</span><span class="p">(</span><span class="ss">:name</span><span class="p">,</span> <span class="ss">:role</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="expression-index">Expression Index</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Index for LOWER()</span>
<span class="n">execute</span> <span class="s2">"CREATE INDEX index_users_on_lower_email ON users (LOWER(email))"</span>

<span class="c1"># Query must use the same expression</span>
<span class="no">User</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="s2">"LOWER(email) = ?"</span><span class="p">,</span> <span class="n">email</span><span class="p">.</span><span class="nf">downcase</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="gin-index-full-text-and-jsonb">GIN Index (Full-text and JSONB)</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># For JSONB columns</span>
<span class="n">add_index</span> <span class="ss">:products</span><span class="p">,</span> <span class="ss">:metadata</span><span class="p">,</span> <span class="ss">using: :gin</span>

<span class="c1"># For array columns</span>
<span class="n">add_index</span> <span class="ss">:articles</span><span class="p">,</span> <span class="ss">:tags</span><span class="p">,</span> <span class="ss">using: :gin</span>

<span class="c1"># For full-text search</span>
<span class="n">execute</span> <span class="o">&lt;&lt;-</span><span class="no">SQL</span><span class="sh">
  CREATE INDEX index_articles_on_search ON articles 
  USING gin(to_tsvector('english', title || ' ' || content))
</span><span class="no">SQL</span>
</code></pre></div></div>

<hr />

<h2 id="9-tools-and-best-practices">9. Tools and Best Practices</h2>

<h3 id="development-tools">Development Tools</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile (development group)</span>
<span class="n">group</span> <span class="ss">:development</span> <span class="k">do</span>
  <span class="n">gem</span> <span class="s1">'bullet'</span>                    <span class="c1"># N+1 detection (associations)</span>
  <span class="n">gem</span> <span class="s1">'prosopite'</span>                 <span class="c1"># N+1 detection (query counting)</span>
  <span class="n">gem</span> <span class="s1">'rack-mini-profiler'</span>        <span class="c1"># Request profiling</span>
  <span class="n">gem</span> <span class="s1">'memory_profiler'</span>           <span class="c1"># Memory allocation</span>
  <span class="n">gem</span> <span class="s1">'activerecord-analyze'</span>      <span class="c1"># EXPLAIN ANALYZE</span>
  <span class="n">gem</span> <span class="s1">'rails-pg-extras'</span>           <span class="c1"># PostgreSQL insights</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="n1-detection-in-tests">N+1 Detection in Tests</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Using n_plus_one_control gem</span>
<span class="c1"># spec/rails_helper.rb</span>
<span class="nb">require</span> <span class="s1">'n_plus_one_control/rspec'</span>

<span class="no">RSpec</span><span class="p">.</span><span class="nf">describe</span> <span class="no">UsersController</span><span class="p">,</span> <span class="ss">type: :request</span> <span class="k">do</span>
  <span class="n">context</span> <span class="s1">'N+1 detection'</span> <span class="k">do</span>
    <span class="n">populate</span> <span class="p">{</span> <span class="o">|</span><span class="n">n</span><span class="o">|</span> <span class="n">create_list</span><span class="p">(</span><span class="ss">:user</span><span class="p">,</span> <span class="n">n</span><span class="p">,</span> <span class="ss">:with_posts</span><span class="p">)</span> <span class="p">}</span>

    <span class="n">it</span> <span class="s1">'does not have N+1 queries'</span> <span class="k">do</span>
      <span class="n">expect</span> <span class="p">{</span> <span class="n">get</span> <span class="s1">'/users'</span> <span class="p">}.</span><span class="nf">to</span> <span class="n">perform_constant_number_of_queries</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="production-monitoring">Production Monitoring</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s1">'skylight'</span>       <span class="c1"># Performance monitoring</span>
<span class="n">gem</span> <span class="s1">'scout_apm'</span>      <span class="c1"># Alternative</span>
<span class="n">gem</span> <span class="s1">'newrelic_rpm'</span>   <span class="c1"># Alternative</span>

<span class="c1"># Custom slow query logging</span>
<span class="c1"># config/initializers/slow_query_logger.rb</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="s1">'sql.active_record'</span><span class="p">)</span> <span class="k">do</span> <span class="o">|*</span><span class="p">,</span> <span class="n">payload</span><span class="o">|</span>
  <span class="n">duration</span> <span class="o">=</span> <span class="n">payload</span><span class="p">[</span><span class="ss">:duration</span><span class="p">]</span>
  <span class="k">if</span> <span class="n">duration</span> <span class="o">&gt;</span> <span class="mi">100</span>  <span class="c1"># Over 100ms</span>
    <span class="no">Rails</span><span class="p">.</span><span class="nf">logger</span><span class="p">.</span><span class="nf">warn</span> <span class="s2">"[SLOW QUERY] </span><span class="si">#{</span><span class="n">duration</span><span class="p">.</span><span class="nf">round</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span><span class="si">}</span><span class="s2">ms: </span><span class="si">#{</span><span class="n">payload</span><span class="p">[</span><span class="ss">:sql</span><span class="p">]</span><span class="si">}</span><span class="s2">"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="ci-pipeline-query-control">CI Pipeline Query Control</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .github/workflows/test.yml</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Run tests with Bullet</span>
  <span class="na">env</span><span class="pi">:</span>
    <span class="na">BULLET_ENABLED</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">run</span><span class="pi">:</span> <span class="s">bundle exec rspec</span>

<span class="c1"># spec/rails_helper.rb</span>
<span class="s">if ENV['BULLET_ENABLED']</span>
  <span class="s">Bullet.enable = </span><span class="no">true</span>
  <span class="s">Bullet.raise = </span><span class="no">true</span>  <span class="c1"># Fail CI on N+1</span>
<span class="s">end</span>
</code></pre></div></div>

<hr />

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

<p>Bullet is a fantastic tool, but it only catches the tip of the iceberg. For real performance optimization:</p>

<ol>
  <li><strong>Watch for queries in custom methods</strong> - Bullet can’t see these</li>
  <li><strong>Audit your serializers</strong> - Every attribute could be a query</li>
  <li><strong>Know the count/size/length difference</strong> - Wrong usage causes N+1</li>
  <li><strong>Minimize callbacks</strong> - They’re disasters in bulk operations</li>
  <li><strong>Use EXPLAIN ANALYZE</strong> - Find the real bottleneck</li>
  <li><strong>Apply proper index strategies</strong> - Partial, compound, covering indexes</li>
  <li><strong>Monitor production</strong> - Skylight, NewRelic, custom logging</li>
  <li><strong>Consider Prosopite</strong> - Catches what Bullet misses</li>
</ol>

<p>The key insight: Bullet tracks <em>association loading</em>, not <em>query execution</em>. Any code path that generates queries without going through standard association loading will be invisible to Bullet.</p>

<hr />

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://evilmartians.com/chronicles/squash-n-plus-one-queries-early-with-n-plus-one-control-test-matchers-for-ruby-and-rails">Evil Martians: N+1 Control</a></li>
  <li><a href="https://github.com/charkost/prosopite">Prosopite Gem</a></li>
  <li><a href="https://pawelurbanek.com/explain-analyze-indexes">PostgreSQL EXPLAIN ANALYZE</a></li>
  <li><a href="https://github.com/pawurb/rails-pg-extras">Rails PG Extras</a></li>
  <li><a href="https://pragprog.com/titles/aapsql/high-performance-postgresql-for-rails/">High Performance PostgreSQL for Rails</a></li>
  <li><a href="https://github.com/flyerhzm/bullet">Bullet Gem</a></li>
  <li><a href="https://labs.factorialhr.com/posts/bullet-or-prosopite-for-nplus1">Factorial: Bullet or Prosopite</a></li>
</ul>]]></content><author><name>Hamza Gedikkaya</name></author><category term="Rails Performance" /><category term="Database Optimization" /><category term="Ruby on Rails" /><category term="PostgreSQL" /><category term="Performance" /><category term="ActiveRecord" /><summary type="html"><![CDATA[Bullet is an excellent gem for catching N+1 queries. However, a significant portion of the performance issues we encounter in production fly completely under Bullet’s radar. In this post, we’ll explore the performance killers that Bullet can’t detect and how to identify and fix them.]]></summary></entry><entry><title type="html">Modern Rails Authentication with Devise &amp;amp; Hotwire</title><link href="https://hamzagedikkaya.github.io/rails%20projects/2025/11/20/devise_hotwired.html" rel="alternate" type="text/html" title="Modern Rails Authentication with Devise &amp;amp; Hotwire" /><published>2025-11-20T00:00:00+00:00</published><updated>2025-11-20T00:00:00+00:00</updated><id>https://hamzagedikkaya.github.io/rails%20projects/2025/11/20/devise_hotwired</id><content type="html" xml:base="https://hamzagedikkaya.github.io/rails%20projects/2025/11/20/devise_hotwired.html"><![CDATA[<p>Building robust user authentication is a fundamental requirement for most web applications. In this guide, we’ll walk through setting up a complete authentication system in Rails using Devise, integrating it seamlessly with Hotwire for a modern SPA-like experience, enhancing our forms with Simple Form, and implementing user profile images with Active Storage.</p>

<p>By the end of this guide, you’ll have a fully functional authentication system that handles user registration, login, profile management, and image uploads—all working smoothly with Turbo.</p>

<hr />

<h2 id="table-of-contents">Table of Contents</h2>

<ol>
  <li><a href="#1-devise-setup">Devise Setup</a></li>
  <li><a href="#2-hotwire-integration">Hotwire Integration</a></li>
  <li><a href="#3-simple-form-configuration">Simple Form Configuration</a></li>
  <li><a href="#4-active-storage--image-processing">Active Storage &amp; Image Processing</a></li>
</ol>

<hr />

<h2 id="1-devise-setup">1. Devise Setup</h2>

<p>Devise is the de facto standard for authentication in Rails applications. It provides a complete MVC solution with modules for password recovery, session management, email confirmation, and more.</p>

<h3 id="installation">Installation</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle add devise
rails generate devise:install
rails generate devise:views
rails generate devise User
</code></pre></div></div>

<p>After running the generator, Devise will create a migration file in <code class="language-plaintext highlighter-rouge">db/migrate/</code>. Before running the migration, let’s add some custom fields to our User model.</p>

<h3 id="adding-custom-fields">Adding Custom Fields</h3>

<p>Open the generated migration file and add the following fields within the <code class="language-plaintext highlighter-rouge">create_table</code> block:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/XXXXXX_devise_create_users.rb</span>

<span class="k">def</span> <span class="nf">change</span>
  <span class="n">create_table</span> <span class="ss">:users</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
    <span class="c1">## Database authenticatable</span>
    <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:email</span><span class="p">,</span>              <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">default: </span><span class="s2">""</span>
    <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:encrypted_password</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">default: </span><span class="s2">""</span>

    <span class="c1"># ... other Devise fields ...</span>

    <span class="c1">## Custom Fields</span>
    <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:name_surname</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">default: </span><span class="s2">""</span>
    <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:gsm</span>
    <span class="n">t</span><span class="p">.</span><span class="nf">date</span>   <span class="ss">:date_of_birth</span>

    <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="k">end</span>

  <span class="n">add_index</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:email</span><span class="p">,</span> <span class="ss">unique: </span><span class="kp">true</span>
  <span class="n">add_index</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:reset_password_token</span><span class="p">,</span> <span class="ss">unique: </span><span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="configuring-strong-parameters">Configuring Strong Parameters</h3>

<p>When adding custom fields, we need to permit them in Devise’s strong parameters. First, update your routes to use a custom registrations controller:</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">devise_for</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">controllers: </span><span class="p">{</span> <span class="ss">registrations: </span><span class="s2">"users/registrations"</span> <span class="p">}</span>
  
  <span class="c1"># ... other routes ...</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Then create the custom controller:</p>

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

<span class="k">class</span> <span class="nc">Users::RegistrationsController</span> <span class="o">&lt;</span> <span class="no">Devise</span><span class="o">::</span><span class="no">RegistrationsController</span>
  <span class="n">before_action</span> <span class="ss">:configure_sign_up_params</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:create</span><span class="p">]</span>
  <span class="n">before_action</span> <span class="ss">:configure_account_update_params</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:update</span><span class="p">]</span>

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

  <span class="k">def</span> <span class="nf">configure_sign_up_params</span>
    <span class="n">devise_parameter_sanitizer</span><span class="p">.</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:sign_up</span><span class="p">,</span> <span class="ss">keys: </span><span class="p">[</span><span class="ss">:name_surname</span><span class="p">,</span> <span class="ss">:gsm</span><span class="p">,</span> <span class="ss">:date_of_birth</span><span class="p">])</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">configure_account_update_params</span>
    <span class="n">devise_parameter_sanitizer</span><span class="p">.</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:account_update</span><span class="p">,</span> <span class="ss">keys: </span><span class="p">[</span><span class="ss">:name_surname</span><span class="p">,</span> <span class="ss">:gsm</span><span class="p">,</span> <span class="ss">:date_of_birth</span><span class="p">])</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Now run the migration:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails db:migrate
</code></pre></div></div>

<hr />

<h2 id="2-hotwire-integration">2. Hotwire Integration</h2>

<p>Hotwire (HTML Over The Wire) is Rails’ answer to building reactive applications without writing custom JavaScript. However, Devise was built before Hotwire existed, so we need to make a few adjustments to ensure they work together smoothly.</p>

<h3 id="the-problem">The Problem</h3>

<p>By default, when Devise encounters an authentication error (invalid credentials, unauthorized access, etc.), it responds with HTTP status codes that Turbo doesn’t handle gracefully. This can result in broken redirects or missing flash messages.</p>

<h3 id="creating-a-custom-failure-app">Creating a Custom Failure App</h3>

<p>To handle authentication failures properly with Turbo, create a custom failure app:</p>

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

<span class="k">class</span> <span class="nc">TurboFailureApp</span> <span class="o">&lt;</span> <span class="no">Devise</span><span class="o">::</span><span class="no">FailureApp</span>
  <span class="k">def</span> <span class="nf">respond</span>
    <span class="k">if</span> <span class="n">request_format</span> <span class="o">==</span> <span class="ss">:turbo_stream</span>
      <span class="n">redirect</span>
    <span class="k">else</span>
      <span class="k">super</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">skip_format?</span>
    <span class="sx">%w[html turbo_stream */*]</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="n">request_format</span><span class="p">.</span><span class="nf">to_s</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="configuring-devise-for-turbo">Configuring Devise for Turbo</h3>

<p>Update your Devise initializer to use the custom failure app:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/devise.rb</span>

<span class="c1"># Ensure the custom failure app is loaded</span>
<span class="nb">require</span> <span class="s2">"turbo_failure_app"</span>

<span class="no">Devise</span><span class="p">.</span><span class="nf">setup</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="c1"># ... other configurations ...</span>

  <span class="c1"># Add turbo_stream to navigational formats</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">navigational_formats</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"*/*"</span><span class="p">,</span> <span class="ss">:html</span><span class="p">,</span> <span class="ss">:turbo_stream</span><span class="p">]</span>

  <span class="c1"># Configure Warden to use our custom failure app</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">warden</span> <span class="k">do</span> <span class="o">|</span><span class="n">manager</span><span class="o">|</span>
    <span class="n">manager</span><span class="p">.</span><span class="nf">failure_app</span> <span class="o">=</span> <span class="no">TurboFailureApp</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="how-it-works">How It Works</h3>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">TurboFailureApp</code></td>
      <td>Intercepts authentication failures and ensures proper redirect behavior for Turbo Stream requests</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">skip_format?</code></td>
      <td>Allows the failure app to handle HTML, Turbo Stream, and wildcard formats</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">navigational_formats</code></td>
      <td>Tells Devise which response formats should trigger redirects instead of 401 responses</td>
    </tr>
  </tbody>
</table>

<p>With this configuration, your Devise authentication will work seamlessly with Turbo Drive and Turbo Frames.</p>

<hr />

<h2 id="3-simple-form-configuration">3. Simple Form Configuration</h2>

<p>Simple Form is a powerful form builder that reduces boilerplate and integrates beautifully with CSS frameworks like Bootstrap and Tailwind.</p>

<h3 id="installation-1">Installation</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle add simple_form
rails generate simple_form:install

<span class="c"># For Bootstrap projects:</span>
rails generate simple_form:install <span class="nt">--bootstrap</span>
</code></pre></div></div>

<h3 id="updating-devise-views">Updating Devise Views</h3>

<p>Let’s refactor the Devise login page to use Simple Form with Tailwind CSS styling:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/devise/sessions/new.html.erb %&gt;</span>

<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"max-w-md mx-auto bg-white shadow-lg rounded-lg p-8 border border-gray-300"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;h2</span> <span class="na">class=</span><span class="s">"text-3xl font-bold text-center mb-8 text-gray-800"</span><span class="nt">&gt;</span>Log in<span class="nt">&lt;/h2&gt;</span>

  <span class="cp">&lt;%=</span> <span class="n">simple_form_for</span><span class="p">(</span><span class="n">resource</span><span class="p">,</span> <span class="ss">as: </span><span class="n">resource_name</span><span class="p">,</span> <span class="ss">url: </span><span class="n">session_path</span><span class="p">(</span><span class="n">resource_name</span><span class="p">))</span> <span class="k">do</span> <span class="o">|</span><span class="n">f</span><span class="o">|</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"space-y-4"</span><span class="nt">&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">input</span> <span class="ss">:email</span><span class="p">,</span>
                  <span class="ss">label: </span><span class="s2">"Email"</span><span class="p">,</span>
                  <span class="ss">required: </span><span class="kp">true</span><span class="p">,</span>
                  <span class="ss">autofocus: </span><span class="kp">true</span><span class="p">,</span>
                  <span class="ss">input_html: </span><span class="p">{</span>
                    <span class="ss">autocomplete: </span><span class="s2">"email"</span><span class="p">,</span>
                    <span class="ss">class: </span><span class="s2">"mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"</span>
                  <span class="p">}</span> <span class="cp">%&gt;</span>

      <span class="cp">&lt;%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">input</span> <span class="ss">:password</span><span class="p">,</span>
                  <span class="ss">label: </span><span class="s2">"Password"</span><span class="p">,</span>
                  <span class="ss">required: </span><span class="kp">true</span><span class="p">,</span>
                  <span class="ss">input_html: </span><span class="p">{</span>
                    <span class="ss">autocomplete: </span><span class="s2">"current-password"</span><span class="p">,</span>
                    <span class="ss">class: </span><span class="s2">"mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"</span>
                  <span class="p">}</span> <span class="cp">%&gt;</span>

      <span class="cp">&lt;%</span> <span class="k">if</span> <span class="n">devise_mapping</span><span class="p">.</span><span class="nf">rememberable?</span> <span class="cp">%&gt;</span>
        <span class="cp">&lt;%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">input</span> <span class="ss">:remember_me</span><span class="p">,</span>
                    <span class="ss">as: :boolean</span><span class="p">,</span>
                    <span class="ss">label: </span><span class="s2">"Remember me"</span><span class="p">,</span>
                    <span class="ss">wrapper_html: </span><span class="p">{</span> <span class="ss">class: </span><span class="s2">"flex items-center"</span> <span class="p">},</span>
                    <span class="ss">input_html: </span><span class="p">{</span> <span class="ss">class: </span><span class="s2">"h-4 w-4 text-blue-600 border-gray-300 rounded"</span> <span class="p">}</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>

      <span class="cp">&lt;%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">button</span> <span class="ss">:submit</span><span class="p">,</span>
                   <span class="s2">"Log in"</span><span class="p">,</span>
                   <span class="ss">class: </span><span class="s2">"w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-150"</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>

  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"mt-6 text-center text-sm text-gray-600"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%=</span> <span class="n">render</span> <span class="s2">"devise/shared/links"</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<hr />

<h2 id="4-active-storage--image-processing">4. Active Storage &amp; Image Processing</h2>

<p>Active Storage provides a simple way to attach files to Active Record models. Combined with the <code class="language-plaintext highlighter-rouge">image_processing</code> gem, we can handle user profile images with validation and transformations.</p>

<h3 id="installation-2">Installation</h3>

<p>First, uncomment the <code class="language-plaintext highlighter-rouge">image_processing</code> gem in your Gemfile:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"image_processing"</span><span class="p">,</span> <span class="s2">"~&gt; 1.2"</span>
</code></pre></div></div>

<p>Then install and set up Active Storage:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">install
</span>rails active_storage:install
rails db:migrate
</code></pre></div></div>

<h3 id="attaching-images-to-users">Attaching Images to Users</h3>

<p>Update the User model to accept profile images:</p>

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

<span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">devise</span> <span class="ss">:database_authenticatable</span><span class="p">,</span> <span class="ss">:registerable</span><span class="p">,</span>
         <span class="ss">:recoverable</span><span class="p">,</span> <span class="ss">:rememberable</span><span class="p">,</span> <span class="ss">:validatable</span>

  <span class="n">has_one_attached</span> <span class="ss">:profile_image</span>

  <span class="n">validate</span> <span class="ss">:acceptable_image</span>

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

  <span class="k">def</span> <span class="nf">acceptable_image</span>
    <span class="k">return</span> <span class="k">unless</span> <span class="n">profile_image</span><span class="p">.</span><span class="nf">attached?</span>

    <span class="c1"># Validate file size (max 10MB)</span>
    <span class="k">if</span> <span class="n">profile_image</span><span class="p">.</span><span class="nf">byte_size</span> <span class="o">&gt;</span> <span class="mi">10</span><span class="p">.</span><span class="nf">megabytes</span>
      <span class="n">errors</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="ss">:profile_image</span><span class="p">,</span> <span class="no">I18n</span><span class="p">.</span><span class="nf">t</span><span class="p">(</span><span class="s2">"errors.messages.profile_image_too_large"</span><span class="p">,</span> <span class="ss">default: </span><span class="s2">"is too large (maximum is 10MB)"</span><span class="p">))</span>
    <span class="k">end</span>

    <span class="c1"># Validate content type</span>
    <span class="n">acceptable_types</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"image/jpeg"</span><span class="p">,</span> <span class="s2">"image/jpg"</span><span class="p">,</span> <span class="s2">"image/png"</span><span class="p">,</span> <span class="s2">"image/webp"</span><span class="p">]</span>
    <span class="k">unless</span> <span class="n">acceptable_types</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="n">profile_image</span><span class="p">.</span><span class="nf">content_type</span><span class="p">)</span>
      <span class="n">errors</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="ss">:profile_image</span><span class="p">,</span> <span class="no">I18n</span><span class="p">.</span><span class="nf">t</span><span class="p">(</span><span class="s2">"errors.messages.profile_image_invalid_format"</span><span class="p">,</span> <span class="ss">default: </span><span class="s2">"must be a JPEG, PNG, or WebP image"</span><span class="p">))</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="updating-the-registration-form">Updating the Registration Form</h3>

<p>Add the file input to your edit registration view:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/devise/registrations/edit.html.erb %&gt;</span>

<span class="cp">&lt;%=</span> <span class="n">simple_form_for</span><span class="p">(</span><span class="n">resource</span><span class="p">,</span> <span class="ss">as: </span><span class="n">resource_name</span><span class="p">,</span> <span class="ss">url: </span><span class="n">registration_path</span><span class="p">(</span><span class="n">resource_name</span><span class="p">),</span> <span class="ss">html: </span><span class="p">{</span> <span class="ss">method: :put</span><span class="p">,</span> <span class="ss">multipart: </span><span class="kp">true</span> <span class="p">})</span> <span class="k">do</span> <span class="o">|</span><span class="n">f</span><span class="o">|</span> <span class="cp">%&gt;</span>
  
  <span class="c">&lt;%# ... other fields ... %&gt;</span>

  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"space-y-2"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">if</span> <span class="n">resource</span><span class="p">.</span><span class="nf">profile_image</span><span class="p">.</span><span class="nf">attached?</span> <span class="cp">%&gt;</span>
      <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"mb-4"</span><span class="nt">&gt;</span>
        <span class="cp">&lt;%=</span> <span class="n">image_tag</span> <span class="n">resource</span><span class="p">.</span><span class="nf">profile_image</span><span class="p">.</span><span class="nf">variant</span><span class="p">(</span><span class="ss">resize_to_limit: </span><span class="p">[</span><span class="mi">150</span><span class="p">,</span> <span class="mi">150</span><span class="p">]),</span>
                      <span class="ss">class: </span><span class="s2">"rounded-full border-2 border-gray-200"</span> <span class="cp">%&gt;</span>
      <span class="nt">&lt;/div&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>

    <span class="cp">&lt;%=</span> <span class="n">f</span><span class="p">.</span><span class="nf">input</span> <span class="ss">:profile_image</span><span class="p">,</span>
                <span class="ss">as: :file</span><span class="p">,</span>
                <span class="ss">label: </span><span class="s2">"Profile Image"</span><span class="p">,</span>
                <span class="ss">hint: </span><span class="s2">"Accepted formats: JPEG, PNG, WebP. Maximum size: 10MB"</span><span class="p">,</span>
                <span class="ss">input_html: </span><span class="p">{</span>
                  <span class="ss">accept: </span><span class="s2">"image/jpeg,image/png,image/jpg,image/webp"</span><span class="p">,</span>
                  <span class="ss">class: </span><span class="s2">"block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"</span>
                <span class="p">}</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>

  <span class="c">&lt;%# ... submit button ... %&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<h3 id="updating-strong-parameters">Updating Strong Parameters</h3>

<p>Don’t forget to permit the <code class="language-plaintext highlighter-rouge">profile_image</code> parameter in your registrations controller:</p>

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

<span class="k">def</span> <span class="nf">configure_account_update_params</span>
  <span class="n">devise_parameter_sanitizer</span><span class="p">.</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:account_update</span><span class="p">,</span> <span class="ss">keys: </span><span class="p">[</span><span class="ss">:name_surname</span><span class="p">,</span> <span class="ss">:gsm</span><span class="p">,</span> <span class="ss">:date_of_birth</span><span class="p">,</span> <span class="ss">:profile_image</span><span class="p">])</span>
<span class="k">end</span>
</code></pre></div></div>

<hr />

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

<p>We’ve built a complete, modern authentication system that combines the reliability of Devise with the reactivity of Hotwire. Here’s what we accomplished:</p>

<ul>
  <li><strong>Devise</strong>: Handles all authentication logic with custom user fields</li>
  <li><strong>Hotwire</strong>: Provides seamless page updates without full reloads</li>
  <li><strong>Simple Form</strong>: Creates clean, maintainable forms with minimal code</li>
  <li><strong>Active Storage</strong>: Manages user profile images with proper validation</li>
</ul>

<p>This setup provides a solid foundation that you can extend with additional features like OAuth providers, two-factor authentication, or email confirmation as your application grows.</p>

<hr />

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/heartcombo/devise">Devise Documentation</a></li>
  <li><a href="https://hotwired.dev/">Hotwire Documentation</a></li>
  <li><a href="https://github.com/heartcombo/simple_form">Simple Form Documentation</a></li>
  <li><a href="https://guides.rubyonrails.org/active_storage_overview.html">Active Storage Guide</a></li>
</ul>]]></content><author><name>Hamza Gedikkaya</name></author><category term="Rails Projects" /><category term="Ruby on Rails" /><category term="Devise" /><category term="Hotwire" /><category term="Authentication" /><summary type="html"><![CDATA[Building robust user authentication is a fundamental requirement for most web applications. In this guide, we’ll walk through setting up a complete authentication system in Rails using Devise, integrating it seamlessly with Hotwire for a modern SPA-like experience, enhancing our forms with Simple Form, and implementing user profile images with Active Storage.]]></summary></entry></feed>