<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Rails Revelry]]></title><description><![CDATA[RailsRevelry explains Rails as an execution system: how requests flow, how framework boundaries work, and why Rails behaves the way it does.]]></description><link>https://railsrevelry.substack.com</link><image><url>https://substackcdn.com/image/fetch/$s_!VQC-!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa9c2d72d-eaf1-46bc-bf53-fe489f91c036_1254x1254.png</url><title>Rails Revelry</title><link>https://railsrevelry.substack.com</link></image><generator>Substack</generator><lastBuildDate>Sat, 08 Aug 2026 06:55:28 GMT</lastBuildDate><atom:link href="https://railsrevelry.substack.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Syed Aslam]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[railsrevelry@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[railsrevelry@substack.com]]></itunes:email><itunes:name><![CDATA[Syed Aslam]]></itunes:name></itunes:owner><itunes:author><![CDATA[Syed Aslam]]></itunes:author><googleplay:owner><![CDATA[railsrevelry@substack.com]]></googleplay:owner><googleplay:email><![CDATA[railsrevelry@substack.com]]></googleplay:email><googleplay:author><![CDATA[Syed Aslam]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[What Happens When You Call save]]></title><description><![CDATA[An account has a pending change, save returns false, errors stays empty, and no UPDATE reaches the database. This article traces the Active Record lifecycle that refused the write, shows how callbacks alter or halt persistence, and explains why a failed inner save can still leave surrounding transaction work committed.]]></description><link>https://railsrevelry.substack.com/p/what-happens-when-you-call-save</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/what-happens-when-you-call-save</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 02 Aug 2026 03:30:26 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!xe_B!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>An account upgrade starts with an ordinary assignment:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account = Account.find(42)
account.plan = "growth"

account.changes_to_save
# =&gt; {
#   "plan" =&gt; ["starter", "growth"]
# }</code></pre></div><p>The object has the right value. Dirty tracking has the right transition, but the save fails:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.save
# =&gt; false

account.errors.full_messages
# =&gt; []</code></pre></div><p>The row remains on the <code>starter</code> plan. The SQL log contains no <code>UPDATE</code>.</p><p>That missing <code>UPDATE</code> is useful evidence: <code>save</code> stopped before the adapter attempted the row write. It does not yet tell us what stopped it.</p><p>The cause lives in a concern included by <code>Account</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">module BillingMigrationGuard
  extend ActiveSupport::Concern

  included do
    before_update :prevent_plan_change_during_migration
  end

  private

  def prevent_plan_change_during_migration
    # An Account column maintained by the background billing migration.
    throw :abort if billing_migration_state == "running"
  end
end</code></pre></div><p>The callback stopped the update after validation but before Active Record reached SQL. It added no error, so the caller received <code>false</code> without an explanation.</p><p>In a mature model, finding that callback may be harder than this example suggests. Start by inspecting the update chain:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Account._update_callbacks.map do |callback|
  [callback.kind, callback.filter]
end</code></pre></div><p>The array may contain named methods, callback objects, and anonymous <code>Proc</code> instances installed by concerns, associations, or gems.</p><p>Callback inspection locates the guard. We still need to explain why <code>save</code> returned <code>false</code>, why <code>errors</code> stayed empty, and what else can commit after the update is refused.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!xe_B!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!xe_B!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png 424w, https://substackcdn.com/image/fetch/$s_!xe_B!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png 848w, https://substackcdn.com/image/fetch/$s_!xe_B!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png 1272w, https://substackcdn.com/image/fetch/$s_!xe_B!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!xe_B!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png" width="1456" height="819" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:819,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:2581314,&quot;alt&quot;:&quot;A ruby-red data capsule is diverted by a checkpoint before reaching a database&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/209282361?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="A ruby-red data capsule is diverted by a checkpoint before reaching a database" title="A ruby-red data capsule is diverted by a checkpoint before reaching a database" srcset="https://substackcdn.com/image/fetch/$s_!xe_B!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png 424w, https://substackcdn.com/image/fetch/$s_!xe_B!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png 848w, https://substackcdn.com/image/fetch/$s_!xe_B!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png 1272w, https://substackcdn.com/image/fetch/$s_!xe_B!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F901615ea-cf9d-4ff4-9a30-04ad4db478bd_1672x941.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2><code>save</code> is assembled across Active Record</h2><p>We already saw how an assignment becomes <a href="https://railsrevelry.substack.com/p/how-rails-knows-what-changed">Active Record dirty-tracking state</a>. Dirty tracking can describe the pending update:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.plan_change_to_be_saved
# =&gt; ["starter", "growth"]</code></pre></div><p>Persistence still has to accept and execute it.</p><p>Rails builds <code>save</code> by layering several modules around the same operation. <code>ActiveRecord::Transactions</code> supplies the outer wrapper:<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-1" href="#footnote-1" target="_self">1</a></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def save(**)
  with_transaction_returning_status { super }
end</code></pre></div><p><code>ActiveRecord::Validations</code> runs validation before delegating further:<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-2" href="#footnote-2" target="_self">2</a></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def save(**options)
  perform_validations(options) ? super : false
end</code></pre></div><p><code>ActiveRecord::Callbacks</code> wraps persistence in the save callbacks:<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-3" href="#footnote-3" target="_self">3</a></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def create_or_update(**)
  _run_save_callbacks { super }
end</code></pre></div><p>Finally, <code>ActiveRecord::Persistence#create_or_update</code> selects <code>_create_record</code> or <code>_update_record</code> from <code>new_record?</code>.<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-4" href="#footnote-4" target="_self">4</a> A new record takes the create callbacks and <code>INSERT</code> branch, with corresponding validation and callback cancellation points.</p><p>The method calls nest in this order because each module delegates inward with <code>super</code>. The transaction wrapper is entered before validation runs, so even a uniqueness validator&#8217;s <code>SELECT</code> runs inside the transaction that may later contain the <code>INSERT</code> or <code>UPDATE</code>. The SQL write appears near the inside of the operation, after validation and the relevant before callbacks have completed.</p><p>For an account update, the callback order around that write is:</p><ol><li><p><code>before_save</code></p></li><li><p><code>before_update</code></p></li><li><p>timestamps handling and <code>_update_record</code></p></li><li><p><code>after_update</code></p></li><li><p><code>after_save</code></p></li></ol><p>Around callbacks wrap their corresponding save or update work.</p><h2>Where <code>save</code> can stop</h2><p>A validation failure and a callback abort both make non-bang <code>save</code> return <code>false</code>, but Rails reached different stages.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.billing_email = nil

account.save
# =&gt; false

account.errors.full_messages
# =&gt; ["Billing email can't be blank"]</code></pre></div><p>Validation populated <code>errors</code> and prevented the callback and persistence path from continuing. <code>save!</code> reports that failure with <code>ActiveRecord::RecordInvalid</code>.</p><p>The billing migration guard runs later. Validation passes, the update callbacks begin, and <code>throw :abort</code> halts the chain. <code>save!</code> reports with <code>ActiveRecord::RecordNotSaved</code>.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!dBb1!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F48249273-e46f-4cc2-8b84-d8223c328adc_1240x668.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!dBb1!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F48249273-e46f-4cc2-8b84-d8223c328adc_1240x668.png 424w, https://substackcdn.com/image/fetch/$s_!dBb1!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F48249273-e46f-4cc2-8b84-d8223c328adc_1240x668.png 848w, https://substackcdn.com/image/fetch/$s_!dBb1!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F48249273-e46f-4cc2-8b84-d8223c328adc_1240x668.png 1272w, https://substackcdn.com/image/fetch/$s_!dBb1!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F48249273-e46f-4cc2-8b84-d8223c328adc_1240x668.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!dBb1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F48249273-e46f-4cc2-8b84-d8223c328adc_1240x668.png" width="1240" height="668" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/48249273-e46f-4cc2-8b84-d8223c328adc_1240x668.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:668,&quot;width&quot;:1240,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:95551,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/209282361?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F48249273-e46f-4cc2-8b84-d8223c328adc_1240x668.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!dBb1!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F48249273-e46f-4cc2-8b84-d8223c328adc_1240x668.png 424w, https://substackcdn.com/image/fetch/$s_!dBb1!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F48249273-e46f-4cc2-8b84-d8223c328adc_1240x668.png 848w, https://substackcdn.com/image/fetch/$s_!dBb1!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F48249273-e46f-4cc2-8b84-d8223c328adc_1240x668.png 1272w, https://substackcdn.com/image/fetch/$s_!dBb1!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F48249273-e46f-4cc2-8b84-d8223c328adc_1240x668.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><code>save</code> returning <code>false</code> tells you that the lifecycle refused the write. It does not tell you which stage refused it.</p><p>The non-bang method is not an exception-free form of persistence. A raised callback exception, <code>ActiveRecord::RecordNotUnique</code>, <code>ActiveRecord::StaleObjectError</code>, a connection failure, or a readonly record still raises from <code>save</code>.</p><p>If callback inspection returns an anonymous block, ask Ruby where it was defined:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">callback.filter.source_location if callback.filter.respond_to?(:source_location)</code></pre></div><p><code>validate: false</code> also has a narrower effect than its name can suggest:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.save(validate: false)</code></pre></div><p>It skips validation. The transaction wrapper, save callbacks, update callbacks, timestamps, and persistence still run. The billing migration guard can still abort this call.</p><p>That makes <code>validate: false</code> a poor substitute for a direct-write API. It bypasses one stage of <code>save</code>, not the model lifecycle.</p><p>One Rails boundary, explained each week</p><p>RailsRevelry follows ordinary Rails APIs into the framework source and back to the production behaviour they create.</p><p>If this helped you explain a save that returned false, subscribe free for next week&#8217;s article on what Active Record transactions actually protect.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://railsrevelry.substack.com/subscribe?"><span>Subscribe now</span></a></p><h2>A callback can change the proposed write</h2><p>Before callbacks can assign new values before Rails builds the statement:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">before_update do 
  self.billing_email = billing_email.strip.downcase
end</code></pre></div><p>Before <code>save</code>, <code>changes_to_save</code> may contain the address with its original whitespace. After this callback assigns the normalized address, dirty tracking and the eventual <code>UPDATE</code> contain the normalized value.</p><p>After a successful write, Rails clears the pending change and records the persisted transition in <code>saved_changes</code>. That history describes what reached the database, including changes made by before callbacks.</p><p>The billing migration guard has a larger effect: it vetoes the entire update.</p><p>Adding an error makes that veto visible:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def prevent_plan_change_during_migration
  return unless billing_migration_state == "running"

  errors.add(:plan, "cannot change during billing migration")
  throw :abort
end</code></pre></div><p>It does not make the design explicit. A user-initiated plan change is a business operation with an expected refusal case. Hiding that decision in <code>before_update</code> means every caller has to discover the callback contract through <code>false</code>, <code>errors</code>, or source inspection.</p><p>An explicit upgrade operation can return a domain result before assigning the plan.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class UpgradeAccountPlan
  Result = Data.define(:status, :reason)

  def self.call(account, to:)
    if account.billing_migration_state == "running"
      return Result.new(
        status: :refused,
        reason: :billing_migration_running
      )
    end

    account.update!(plan: to)
    Result.new(status: :upgraded, reason: nil)
  end
end</code></pre></div><p>Now the expected business refusal has a name:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">result = UpgradeAccountPlan.call(account, to: "growth")

result.status
# =&gt; :refused

result.reason
# =&gt; :billing_migration_running</code></pre></div><p>The callback can remain as protection against another persistence path bypassing the operation. But ordinary callers no longer have to infer a business decision from <code>false</code> and an empty <code>errors</code> collection.</p><p>The migration guard is an expected refusal; other persistence failures still raise from <code>update!</code>.</p><h2><code>true</code> does not require an <code>UPDATE</code></h2><p>An unchanged object can complete the save lifecycle:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account = Account.find(42)

account.has_changes_to_save?
# =&gt; false

account.save
# =&gt; true</code></pre></div><div class="callout-block" data-callout="true"><p><strong>Why is there no </strong><code>UPDATE</code><strong>?</strong></p><p>Rails enables partial updates by default, which means an <code>UPDATE</code> contains only attributes that changed. Here, dirty tracking has no changed attributes, and no callback adds one, so Rails has no columns to write.</p><p><code>Account.partial_update? # =&gt; true</code></p></div><p>The save lifecycle still completes, and its callbacks still run. An <code>after_save</code> callback therefore does not prove that the adapter executed a write. Code that publishes an event from <code>after_save</code> on the assumption that a row changed can publish work for a no-op save. If the event belongs to a plan change, guard it with <code>saved_change_to_plan?</code>; <code>saved_changes?</code> is broader and matches any persisted attribute change.</p><p>Moving external publication to <code>after_commit</code> protects it from a later transaction rollback. It solves a different problem and does not replace the attribute-change guard.</p><h2>A failed <code>save</code> can leave the outer transaction running</h2><p>The callback abort becomes more consequential inside a larger transaction. Consider a service that checks the result and returns it from the transaction block:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">result = Account.transaction do
  AuditEvent.create!(
    account: account,
    event_type: "plan_upgrade_requested"
  )

  account.plan = "growth"
  saved = account.save
  Rails.logger.warn("Plan upgrade refused for Account #{account.id}") unless saved
  saved
end

result
# =&gt; false</code></pre></div><p>The service reports failure, but the audit event commits. Returning <code>false</code> normally from an <code>Account.transaction</code> block does not ask Rails to roll that transaction back.</p><p><code>save</code> always enters Active Record&#8217;s transaction wrapper.</p><ul><li><p>If no transaction is already open, the wrapper starts one for the save. When the save returns <code>false</code>, <code>with_transaction_returning_status</code> raises <code>ActiveRecord::Rollback</code>, and that transaction rolls back. </p></li><li><p>If the caller already opened a transaction, as in the example above, <code>save</code> joins it. Rails does not create a savepoint for this ordinary nested transaction. The inner transaction call catches <code>ActiveRecord::Rollback</code>, returns control to the outer block, and the outer transaction continues.<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-5" href="#footnote-5" target="_self">5</a></p></li></ul><p>After the block:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.reload.plan
# =&gt; "starter"

AuditEvent.exists?(
  account: account,
  event_type: "plan_upgrade_requested"
)
# =&gt; true</code></pre></div><p>If the account update and audit event must succeed or fail together, the caller has to react to the failed save:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Account.transaction do
  AuditEvent.create!(
    account: account,
    event_type: "plan_upgrade_requested"
  )

  account.plan = "growth"
  account.save!
end</code></pre></div><p>The callback abort now raises <code>ActiveRecord::RecordNotSaved</code>. Unless application code rescues it inside the transaction, the exception leaves the block, and Rails rolls back the outer transaction.</p><p>This is where the return value becomes part of the surrounding operation. <code>false</code> describes one refused save. It does not automatically refuse the transaction around it.</p><p>When <code>save</code> does not write:</p><ol><li><p>Capture its return value or exception.</p></li><li><p>Inspect <code>errors</code> before rerunning validation.</p></li><li><p>Check the SQL log for an <code>INSERT</code> or <code>UPDATE</code>.</p></li><li><p>Inspect the save, create, or update callbacks.</p></li><li><p>Decide whether the surrounding transaction can continue.</p></li></ol><p>First ask how far this save got. Then ask whether the larger transaction may still commit.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">The next article follows a larger boundary: what an Active Record transaction protects, what rollback leaves untouched, and when database work becomes durable. Subscribe to get it next week.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-1" href="#footnote-anchor-1" class="footnote-number" contenteditable="false" target="_self">1</a><div class="footnote-content"><p>Rails wraps <code>save</code> and <code>save!</code> with <code>with_transaction_returning_status</code> in <a href="https://github.com/rails/rails/blob/v8.1.3/activerecord/lib/active_record/transactions.rb">ActiveRecord::Transactions</a>.</p></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-2" href="#footnote-anchor-2" class="footnote-number" contenteditable="false" target="_self">2</a><div class="footnote-content"><p>The validation overrides for <code>save</code>, <code>save!</code>, and <code>perform_validations</code> live in <a href="https://github.com/rails/rails/blob/v8.1.3/activerecord/lib/active_record/validations.rb">ActiveRecord::Validations</a>.</p></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-3" href="#footnote-anchor-3" class="footnote-number" contenteditable="false" target="_self">3</a><div class="footnote-content"><p>The save, create, and update callback wrappers live in <a href="https://github.com/rails/rails/blob/v8.1.3/activerecord/lib/active_record/callbacks.rb">ActiveRecord::Callbacks</a>.</p></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-4" href="#footnote-anchor-4" class="footnote-number" contenteditable="false" target="_self">4</a><div class="footnote-content"><p>The create/update branch and adapter write paths live in <a href="https://github.com/rails/rails/blob/v8.1.3/activerecord/lib/active_record/persistence.rb">ActiveRecord::Persistence</a>.</p></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-5" href="#footnote-anchor-5" class="footnote-number" contenteditable="false" target="_self">5</a><div class="footnote-content"><p><a href="https://github.com/rails/rails/blob/v8.1.3/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb">ConnectionAdapters::DatabaseStatement#transaction</a> joins an existing transaction unless <code>requires_new</code> is requested and silently catches <code>ActiveRecord::Rollback</code>.</p></div></div>]]></content:encoded></item><item><title><![CDATA[How Rails knows what changed]]></title><description><![CDATA[Why the same callback can read the right value at the wrong point in an Active Record object's lifecycle.]]></description><link>https://railsrevelry.substack.com/p/how-rails-knows-what-changed</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/how-rails-knows-what-changed</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 26 Jul 2026 03:30:36 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!k_JC!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Changing an Active Record object can feel like one operation: assign a value, call <code>save</code>, and expect the row to change.</p><p>Rails sees two separate moments. Before the save, it needs to know what the next write would change. After the save, callbacks and application code may need to know what that write changed.</p><p>Confusing those questions can produce a bug even when the update itself succeeds.</p><p>A plan-change audit starts recording the wrong transition.</p><p>The account update succeeds, and the callback creates the audit row. Nothing raises, and the result looks close enough to correct:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class Account &lt; ApplicationRecord
  before_update :record_plan_change

  private

  def record_plan_change
    AuditEvent.create!(
      account: self,
      from: plan_before_last_save,
      to: plan
    )
  end
end</code></pre></div><p>The same <code>Account</code> instance has already been saved once, moving from <code>trial</code> to <code>starter</code>. Application code now changes that instance again, from <code>starter</code> to <code>growth</code>.</p><p>The audit should record:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">start -&gt; growth</code></pre></div><p>Instead, it records:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">trial -&gt; growth</code></pre></div><p>The callback asked Rails for a real value. It was the wrong value for that point in the record&#8217;s lifecycle.</p><p><code>plan_before_last_save</code> returns the value before the most recent completed save. The <code>before_update</code> callback needs the change waiting to be written by the current save:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">plan_change_to_be_saved
# =&gt; ["starter", "growth"]</code></pre></div><p>Active Record exposes both answers because dirty tracking maintains two different comparisons. Before persistence, it compares the object&#8217;s database-driven value with its current in-memory value. After persistence, it preserves the transition made by the last save.</p><p>The similar method names are easier to reason about once the snapshots are explicit.</p><h2>Assignment creates a pending change</h2><p>Start with one loaded account:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account = Account.find(42)

account.plan
# =&gt; "starter"</code></pre></div><p>A <a href="https://railsrevelry.substack.com/p/active-record-object-snapshot">loaded Active Record object</a> owns an in-memory snapshot. Reading <code>account.plan</code> reads the object&#8217;s attribute state. It does not query the row again.</p><p>Assignment changes that in-memory state:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.plan = "growth"

account.plan
# =&gt; "growth"</code></pre></div><p>No <code>UPDATE</code> has happened. Rails now has two relevant values for <code>plan</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.plan_in_database
# =&gt; "starter"

account.plan
# =&gt; "growth"</code></pre></div><p>Despite its name, <code>plan_in_database</code> does not issue a fresh <code>SELECT</code>. It returns the database-derived value that this object uses as its baseline.</p><p>The pending-change APIs describe the difference:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.will_save_change_to_plan?
# =&gt; true

account.plan_change_to_be_saved
# =&gt; ["starter", "growth"]

account.changes_to_save
# =&gt; {
#   "plan" =&gt; ["starter", "growth"]
# }</code></pre></div><p>These methods inspect the proposed write, making them useful for validation and before callbacks. Assignment changed the Ruby object; dirty tracking describes what persistence would need to write to make the row match it.</p><h2>Dirty tracking is not assignment history</h2><p>Dirty tracking does not record every setter call.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.plan
# =&gt; "starter"

account.plan = "growth"
account.plan = "starter"

account.will_save_change_to_plan?
# =&gt; false

account.plan_change_to_be_saved
# =&gt; nil</code></pre></div><p>The setter ran twice, but the current value once again matches the database-derived baseline. There is no set attribute change for the next save.</p><p>Type casting is part of the same comparison. Suppose <code>retry_limit</code> is an integer column:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.retry_limit
# =&gt; 5

account.retry_limit = "5"

account.retry_limit
# =&gt; 5

account.will_save_change_to_retry_limit?
# =&gt; false</code></pre></div><p>The assigned input was a string. The value exposed by the model is the integer <code>5</code>.</p><p>Active Record&#8217;s attribute types participate in dirty detection. The type-level <code>changed?</code> comparison receives the old and new values after type casting. For an ordinary value, the default question is equivalent to:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">old_value != new_value</code></pre></div><p>Both sides are <code>5</code>, so Rails has no integer change to save.</p><p>This matters in request code because form parameters arrive as strings. A request that assigns <code>"5"</code> to an integer attribute already holding <code>5</code> has invoked a setter, but it has not proposed a different database value.</p><p>Dirty tracking is only meaningful when you name both sides of the comparison.</p><h2>A save moves the comparison</h2><p>Return to the pending plan change:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.plan = "growth"

account.plan_change_to_be_saved
# =&gt; ["starter", "growth"]</code></pre></div><p>Now save it:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.save!</code></pre></div><p>After the write, the pending-change APIs no longer describe that transition:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.will_save_change_to_plan?
# =&gt; false

account.plan_change_to_be_saved
# =&gt; nil

account.plan_in_database
# =&gt; "growth"</code></pre></div><p>The object&#8217;s current value and its database-derived baseline now agree.</p><p>Rails has not forgotten the transition. It moved it into the last-save comparison:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.saved_change_to_plan?
# =&gt; true

account.saved_change_to_plan
# =&gt; ["starter", "growth"]

account.plan_before_last_save
# =&gt; "starter"

account.saved_changes
# =&gt; {
#   "plan" =&gt; ["starter", "growth"],
#   "updated_at" =&gt; [...]
# }</code></pre></div><p>The lifecycle now looks like this:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!k_JC!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!k_JC!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png 424w, https://substackcdn.com/image/fetch/$s_!k_JC!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png 848w, https://substackcdn.com/image/fetch/$s_!k_JC!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png 1272w, https://substackcdn.com/image/fetch/$s_!k_JC!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!k_JC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png" width="1456" height="911" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:911,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1539499,&quot;alt&quot;:&quot;Dirty tracking before and after save&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/208434946?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Dirty tracking before and after save" title="Dirty tracking before and after save" srcset="https://substackcdn.com/image/fetch/$s_!k_JC!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png 424w, https://substackcdn.com/image/fetch/$s_!k_JC!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png 848w, https://substackcdn.com/image/fetch/$s_!k_JC!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png 1272w, https://substackcdn.com/image/fetch/$s_!k_JC!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84c689a0-3a88-45fa-8683-3dac456c79db_1586x992.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Inside Rails, <code>ActiveRecord::AttributeMethods::Dirty</code> delegates the pending comparison to a mutation tracked called <code>mutations_from_database</code>. After Active Record creates or updates the row, <code>changes_applied</code> moves that tracker into <code>mutations_before_last_save</code>.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">mutations_from_database
        |
        | changes_applied
        v
mutations_before_last_save</code></pre></div><p>That handoff is why the public API has two families. The pending tracker answers what the next save would change. The saved tracker answers what the last save changed.</p><h2>Callbacks ask different temporal questions</h2><p>The original callback mixed those two families.</p><p>Before an update, inspect the change about to be written:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class Account &lt; ApplicationRecord
  before_update :record_plan_change

  private

  def record_plan_change
    return unless will_save_change_to_plan?

    from, to = plan_change_to_be_saved

    AuditEvent.create!(
      account: self,
      from: from,
      to: to
    )
  end
end</code></pre></div><p>After the update, a plan-history record needs the transition made by that save:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class Account &lt; ApplicationRecord
  after_update :append_plan_history

  private

  def append_plan_history
    return unless saved_change_to_plan?

    from, to = saved_change_to_plan

    PlanHistory.create!(
      account: self,
      from: from,
      to: to
    )
  end
end</code></pre></div><p>Rails introduced this split to make callback timing explicit: the <code>will_save_*</code> family describes the pending write, while the saved-change family describes the completed save.</p><p>Older code may express these checks through <code>*_changed?</code>, <code>*_was?</code>, or <code>previous_changes</code>. Do not translate those methods by name alone. First decide whether the callback needs the pending write (<code>will_save_change_to_*</code> and <code>*_change_to_be_saved</code>) or the completed save (<code>saved_change_to_*</code> and <code>saved_changes</code>).</p><p><code>saved_change_to_plan?</code> says that the update completed within the model&#8217;s save lifecycle, not that an enclosing transaction committed. An <code>after_update</code> callback can run before the outer transaction commits or rolls back; work that depends on committed visibility belongs at the commit boundary.</p><p>Validations and before callbacks inspect pending changes. After callbacks inspect the changes made by the save that triggered them.</p><h2>The baseline belongs to this object</h2><p><code>plan_in_database</code> sounds authoritative. Its authority is local to the model instance.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account = Account.find(42)
# account.plan =&gt; "starter"

# Another process updates account 42 to "enterprise".

account.plan_in_database
# =&gt; "starter"</code></pre></div><p>That call does not ask PostgreSQL or MySQL what the row contains now. It asks the object&#8217;s mutation tracker for the database-driven value it has been comparing against.</p><p>The distinction from <a href="https://railsrevelry.substack.com/p/active-record-object-snapshot">An Active Record Object Is a Snapshot, Not the Row</a> still applies:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">database row now:           "enterprise"
this object's baseline:     "starter"
this object's current plan: "starter"</code></pre></div><p>From the object&#8217;s perspective, <code>plan</code> is unchanged:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.will_save_change_to_plan?
# =&gt; false</code></pre></div><p>That answer is internally consistent. It is not a freshness check.</p><p>Now assign another value:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.plan = "growth"

account.plan_change_to_be_saved
# =&gt; ["starter", "growth"]</code></pre></div><p>Rails can accurately describe the change this object proposes relative to its own baseline, even though another process has already changed the row to <code>enterprise</code>.</p><p>If the question is what the database contains now, cross the database boundary again:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.reload</code></pre></div><p><code>reload</code> does more than check the row. It replaces the object&#8217;s attribute state and clears its dirty-tracking comparisons:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.plan
# =&gt; "enterprise"

account.will_save_change_to_plan?
# =&gt; false

account.saved_changes
# =&gt; {}</code></pre></div><p>Any unsaved <code>growth</code> assignment is gone. The last-save history associated with the old object state is gone too.</p><p>Reloading replaces the baseline rather than preserving the old comparison. If application code needs the pending values, it must capture them before reloading or query through a separate object.</p><p>Dirty tracking describes one object&#8217;s relationship to the database-derived state that object knows. It does not continuously synchronize that relationship with the live row.</p><h2>Mutable values need their type</h2><p>Scalar assignment makes the comparison easy to see:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.plan = "growth"</code></pre></div><p>Suppose <code>settings</code> is backed by a JSON column. Its hash can change without calling the attribute setter:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.settings["digest"] = "weekly"</code></pre></div><p>For ordinary database-backed attributes, Active Record can detect many in-place changes through its attribute type:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.will_save_change_to_settings?
# =&gt; true</code></pre></div><p>The type API provides <code>changed_in_place?</code> for values changed without assignment. Rails&#8217; JSON type deserializes the original stored JSON and compares it with the current hash. Its string type compares the original stored string with the current string. A custom type that returns a mutable object must provide an equivalent comparison, or Rails may not know that the attribute needs to be written.</p><p>Plain <code>ActiveModel::Dirty</code> requires manual marking for in-place mutation.</p><p>The debugging question is <em>&#8220;what attribute type owns this value, and can it detect the way the value was changed?&#8221;</em></p><h2>Inspect the right comparison</h2><p>When a callback, audit, or synchronization path reports the wrong change, inspect the record at the point where the code makes its decision. Ask:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Am I before the save or after it?

Am I asking about the next write or the last completed write?

Am I comparing this object's snapshots,
or do I need fresh database state?</code></pre></div><p>Those questions are more useful than starting with <code>changed?</code>, because they identify the temporal comparison the code needs.</p><p>Dirty tracking can describe the proposed write. It cannot tell us whether validations, callbacks, or persistence will allow that write to happen.</p><p>That is the work coordinated by <code>save</code>.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Next in The Persistence Boundary: <strong><a href="https://railsrevelry.substack.com/p/what-happens-when-you-call-save">What Happens When You Call </a></strong><code>save</code>. We&#8217;ll follow the validations, callbacks, timestamps, transaction, and SQL that decide whether a pending change reaches the database. Subscribe to get it when it publishes.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Why N+1 Queries Are a Natural Result of Lazy Loading]]></title><description><![CDATA[Why loading the parents doesn't load their associations &#8212; and why preloading doesn't fix every repeated query.]]></description><link>https://railsrevelry.substack.com/p/n-plus-one-queries-lazy-loading</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/n-plus-one-queries-lazy-loading</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 19 Jul 2026 03:30:19 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/9d46e307-6016-4d14-a8ed-69ea65f93bb0_1491x1055.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>An N+1 often arrives as the kind of change nobody is nervous about.</p><p>A serializer grows one nested field. The review focuses on the JSON shape, not on database access. Staging has five accounts. Every individual query is fast, and the diff ships without drama.</p><p>Then a larger customer requests a full page of fifty accounts, and the same response-building loop turns one request into fifty-one query executions.</p><p>The code did not add a visible query object. It added object navigation inside a loop.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account = Account.active.limit(50)

accounts.map do |account|
  {
    name: account.name,
    invoices: account.invoices.map do |invoice|
      {
        number: invoice.number,
        status: invoice.status
      }
    end
  }
end</code></pre></div><p>Read it quickly, and it looks like formatting: get the active accounts, include a little invoice data for each one, return an array of hashes.</p><p>The SQL log tells a different story. First Rails loads the parents:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT "accounts".*
FROM "accounts"
WHERE "accounts"."active" = TRUE
LIMIT 50</code></pre></div><p>Then the invoice query repeats for one owner at a time:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT "invoices".*
FROM "invoices"
WHERE "invoices"."account_id" = 12

SELECT "invoices".*
FROM "invoices"
WHERE "invoices"."account_id" = 19

SELECT "invoices".*
FROM "invoices"
WHERE "invoices"."account_id" = 27</code></pre></div><p>And so on.</p><p>Nothing in the loop looks like a query builder because the query is hidden behind <code>account.invoices</code>. That call uses the same association reader from <a href="https://railsrevelry.substack.com/p/associations-are-query-interfaces">Associations Are Query Interfaces, Not Object Properties</a>: it feels like a property, but it is a query interface with cache state.</p><p>Lazy loading can feel wonderfully convenient for one object; across a collection, the same convenience becomes a scaling rule.</p><blockquote><p>N+1 is not Rails forgetting to be efficient. It is lazy loading repeated across a collection.</p></blockquote><h2>Loaded for Which Question?</h2><p>The misleading assumption is small: <em>I loaded the accounts, so this loop is now memory-only Ruby work.</em></p><p>But the loaded state belongs to a specific object and a specific question.</p><p>Before execution, <code>accounts</code> is still a relation. Once the loop begins, Rails has to load that outer relation, but the association targets remain separate:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">relation = Account.active.limit(50)

relation.loaded?
# =&gt; false

accounts = relation.to_a

relation.loaded?
# =&gt; true

accounts.first(3).map do |account|
  account.association(:invoices).loaded?
end
# =&gt; [false, false, false]</code></pre></div><p>The parent relation loaded its account records, but it did nothing about the invoice association target on each account.</p><p>Those are separate pieces of state:</p><ul><li><p><code>relation.loaded?</code> describes the parent relation.</p></li><li><p><code>account.association(:invoices).loaded?</code> describes one owner&#8217;s association target.</p></li></ul><p>When the serializer calls <code>account.invoices.map(&amp;:number)</code>, the association proxy has to produce invoice records. For one account, that path is reasonable: <code>account.invoices</code> returns a <code>CollectionProxy</code>, Rails sees that the target is unloaded, builds an owner-scoped relation, runs a query for that account ID, and stores the returned rows on that account object&#8217;s association target.</p><p>Put the same association read inside a loop, and Rails repeats the same lazy-loading path for every owner:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">accounts.each do |account|
  account.invoices.map(&amp;:number)
end</code></pre></div><p>The <code>+1</code> is the parent query. The <code>N</code> is the repeated association query. In this example, one account query is followed by fifty invoice queries.</p><p>Each query is fast. The problem is that the query count scales with the parent collection: 50 accounts mean 50 invoice queries, and the endpoint becomes more expensive with every record the page adds.</p><p>Nothing changed in the body of the loop. Only the size of the parent collection changed.</p><h2>Move the Association Load Outside the Loop</h2><p>If the response needs invoice records for every account, load those records as a set:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">accounts = Account
  .active
  .preload(:invoices)
  .limit(50)

accounts.map do |account|
  {
    name: account.name,
    invoices: account.invoices.map do |invoice|
      {
        number: invoice.number,
        status: invoice.status
      }
    end
  }
end</code></pre></div><p>Now Rails can fetch invoices for the parent set instead of asking each owner separately:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:&quot;643a71df-871c-4dee-b790-6c7eea158f1f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT "invoices".*
FROM "invoices"
WHERE "invoices"."account_id" IN (12, 19, 27, ...)</code></pre></div><p>The association targets still belong to individual account objects. Preloading leaves those targets in place; it changes when they are filled. With lazy loading, each owner fills its target when the loop touches it. With preloading, Rails collects the parent IDs, fetches the associated rows using a set-based query, and attaches the correct invoice records to the correct account targets before the loop asks for them.</p><p>Rails offers three related loading APIs. <code>preload</code> loads each requested association with a separate query. Internally, <code>ActiveRecord::Associations::Preloader</code> groups owners and assigns the fetched child records back to the correct association targets.</p><p><code>eager_load</code> loads parents and associations through a <code>LEFT OUTER JOIN</code>.</p><p><code>includes</code> normally uses separate queries, but conditions or references involving the associated table can make Rails use a <code>LEFT OUTER JOIN</code>.</p><p>The first debugging question is: will this association be loaded once for the parent set, or lazily once per parent object?</p><p>Use that question before stopping at <code>includes</code>.</p><h2>Preloading Has to Match the Question</h2><p>Preloading associated records fixes repeated record loading. Calls that compose a scoped relation or request a calculation can still go back to SQL.</p><p>The first surprise comes from scoped relations. Suppose <code>Invoice</code> defines an <code>unpaid</code> scope:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class Invoice &lt; ApplicationRecord
  scope :unpaid, -&gt; { where(status: "unpaid") }
end</code></pre></div><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">accounts = Account
  .active
  .preload(:invoices)
  .limit(50)

accounts.each do |account|
  account.invoices.unpaid.each do |invoice|
    # ...
  end
end</code></pre></div><p>The base <code>invoices</code> target may be loaded, but <code>account.invoices.unpaid</code> composes a new owner-scoped relation. That relation asks a different database question:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;facdfaa1-8ee4-4e63-b94a-cd44dfec015f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices
# read the loaded base target

account.invoices.unpaid
# build a scoped relation for unpaid invoices owned by this account</code></pre></div><p>If unpaid invoices are the records the response needs, preload that target directly:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class Account &lt; ApplicationRecord
  has_many :invoices
  has_many :unpaid_invoices, -&gt; { unpaid }, class_name: "Invoice"
end

accounts = Account
  .active
  .preload(:unpaid_invoices)
  .limit(50)</code></pre></div><p>Now the loop can read <code>account.unpaid_invoices</code> as the preloaded association target, instead of composing a new scoped relation from <code>account.invoices</code>.</p><p>One caveat: <code>invoices</code> and <code>unpaid_invoices</code> are separate association targets. Preloading one does not populate the other, so preload the association that the loop actually reads.</p><p>Counts create a different version of the same mistake:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">accounts = Account
  .active
  .preload(:invoices)
  .limit(50)

accounts.map do |account|
  {
    name: account.name,
    invoice_count: account.invoices.count
  }
end</code></pre></div><p>The loop can still issue one count query per account. The code asked a different question.</p><p><code>account.invoices.map</code> needs records, so preloading records helps.</p><p><code>account.invoices.count</code> requests a count from the database. On an association collection, <a href="https://api.rubyonrails.org/classes/ActiveRecord/Associations/CollectionProxy.html">count</a> is an SQL calculation. It does not have to instantiate the target first.</p><p>If the target is already loaded and you want the number of loaded records, use the collection-aware method:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.size</code></pre></div><p><code>size</code> can use the loaded target when one is present. It can also use a counter cache or issue a count when the target is not loaded.</p><p>The method names look interchangeable in Ruby. They are not interchangeable at the persistence boundary.</p><p>Separate the question before choosing the fix:</p><ul><li><p>Need the preloaded records? Read the association target.</p></li><li><p>Need filtered subset? Preload a matching named association or answer it set-wise.</p></li><li><p>Need a count? Use a grouped aggregate, a counter cache, or <code>size</code> on an intentionally loaded target.</p></li><li><p>Need a boolean? Prefer a set-based existence query over one <code>exists?</code> call per parent.</p></li></ul><p>For counts, a grouped query may be the right shape:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;2cb266f0-61c6-48bb-bc95-563783d6ea48&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">accounts = Account.active.limit(50).to_a
account_ids = accounts.map(&amp;:id)

counts_by_account_id = Invoice
  .where(account_id: account_ids)
  .group(:account_id)
  .count</code></pre></div><p>generating one grouped query for the whole page:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;sql&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-sql">SELECT COUNT(*) AS "count_all", "invoices"."account_id" AS "invoices_account_id"
FROM "invoices"
WHERE "invoices"."account_id" IN (12, 19, 27, ...)
GROUP BY "invoices"."account_id"</code></pre></div><p>Then the loop can read from a hash instead of asking each association for its own account:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;c69f175b-a16a-4e65-a89b-eae9eb5ec566&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">accounts.map do |account|
  {
    name: account.name,
    invoice_count: counts_by_account_id[account.id] || 0
  }
end</code></pre></div><p>Booleans follow the same pattern. Instead of one <code>exists?</code> query per account, ask once which parent IDs have invoices at all:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;3dbdda78-4e19-47d1-8f2d-0c63128c052d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">ids_with_invoices = Invoice
  .where(account_id: account_ids)
  .distinct
  .pluck(:account_id)
  .to_set

accounts.map do |account|
  {
    name: account.name,
    has_invoices: ids_with_invoices.include?(account.id)
  }
end</code></pre></div><p>One set-based query answers the existence question for the whole page.</p><p>Preload the association when the loop needs records. Choose a different query when the loop needs an answer about those records.</p><h2>Why This Survives Code Review</h2><p>N+1 problems survive code review because each line looks reasonable in isolation.</p><p>The controller owns the parent relation, such as <code>@accounts = Account.active.limit(50)</code>. The serializer owns the association read, such as <code>account.invoices.map</code>.</p><p>Neither file says &#8220;run one query per account.&#8221; The database cost emerges only when those layers execute together.</p><p>The controller chooses the parent relation, the serializer decides which associations are touched, and the database sees the combined runtime behavior. In a mature app, that cost may show up far away from the serializer: higher latency, more checked-out connections, or a database that suddenly looks busy even though no single query looks frightening.</p><p>Small test fixtures hide it too. With three accounts, the endpoint returns the expected JSON. With a full page of fifty accounts, the same code path can execute fifty-one queries before the application has done anything else interesting.</p><h2>Detecting and Enforcing the Boundary</h2><p>The first diagnostic tool is still the SQL log.</p><p>Do not read it only as noise. Look for the same query template repeated with a different owner ID: <code>WHERE "invoices"."account_id" = 12</code>, then <code>19</code>, then <code>27</code>, and so on. The changing ID is the clue that Rails is loading the same association once per parent.</p><p>Then inspect association state for one object:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account = accounts.first

account.association(:invoices).loaded?
# =&gt; false</code></pre></div><p>If the code is meant to read preloaded records, that answer matters.</p><p>After a successful preload, each parent should have that association target ready:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">accounts = Account.active.preload(:invoices).limit(50).to_a

accounts.first.association(:invoices).loaded?
# =&gt; true</code></pre></div><p>Rails also gives you a guardrail: <a href="https://guides.rubyonrails.org/active_record_querying.html#strict-loading">strict_loading</a>.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">accounts = Account
  .strict_loading
  .active
  .limit(50)
  .to_a

accounts.first.invoices.to_a
# raises ActiveRecord::StrictLoadingViolationError</code></pre></div><p>The trigger is retrieving associated records, not merely to touch the reader. Calling <code>accounts.first.invoices</code> returns the association proxy without complaint. Methods such as <code>to_a</code>, <code>each</code>, and <code>map</code> load the collection target; a finder such as <code>first</code> may issue a narrower association query. Either way, Rails has crossed from returning the proxy to lazily retrieving associated records.</p><p>Strict loading is not an explanation of N+1. It is enforcement: it turns unexpected association lazy loading into a visible failure.</p><p>The guardrail does not have to stay local to one relation. Setting <code>config.active_record.strict_loading_by_default = true</code> in development makes every relation strict, so a new lazy load fails on your machine instead of on a customer&#8217;s page. If raising is too aggressive for an existing codebase, <code>config.active_record.action_on_strict_loading_violation = :log</code> reports violations without breaking requests, which turns cleanup into a backlog you can work through instead of an incident.</p><p>Keep the boundary clear. Strict loading governs association lazy loading; it will not explain every repeated query. A loop that calls <code>account.invoices.count</code> is issuing explicit aggregate SQL, so it still needs log reading, query instrumentation, or a different query shape.</p><h2>The Debugging Reflex</h2><p>When you see N+1 behavior, do not treat <code>includes</code> as the diagnosis.</p><p>Ask what the outer query loaded, which association each parent loaded lazily, and whether the loop needs records, a filtered subset, a count, or a boolean. Then decide whether the associated data should be loaded as a set or the question answered with a different query.</p><p>An association reader is not a plain object property. Through the association proxy, the reader can load or read a target, compose a relation, ask SQL for a count, or ask SQL for existence.</p><p>Put an operation that needs those records or asks a database question through the association inside a loop, and the distinction becomes visible in the log.</p><p>Lazy loading is useful because Rails can wait until the code needs associated records. N+1 is the cost of making that decision one owner at a time.</p><p>Loading the right records resolves one boundary. The next confusion begins when <a href="https://railsrevelry.substack.com/p/how-rails-knows-what-changed?r=4jsb">changing a Ruby object</a> feels like changing the database.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">How Rails Knows What Changed. Subscribe to get it when it publishes.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Associations Are Query Interfaces, Not Object Properties]]></title><description><![CDATA[A loaded association target answers one question. It does not make every future association-shaped call memory-only.]]></description><link>https://railsrevelry.substack.com/p/associations-are-query-interfaces</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/associations-are-query-interfaces</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 12 Jul 2026 03:30:30 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/39133b57-7175-4ca6-a9bc-41282952ace6_1731x909.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When the association target has not been loaded, we know this can run SQL:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.to_a</code></pre></div><p>The confusion begins once you assume the association has already loaded.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account = Account.includes(:invoices).find(42)

account.invoices.loaded?
# =&gt; true

account.invoices.map(&amp;:id)
# no SELECT

account.invoices.overdue.any?
# Invoice Exists?
# SELECT 1 AS one
# FROM "invoices"
# WHERE "invoices"."account_id" = 42
#   AND "invoices"."status" = 'overdue'
# LIMIT 1 </code></pre></div><p>The output looks contradictory only if &#8220;the association is loaded&#8221; means too much.</p><p>Rails loaded one target:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">The invoices association for this account</code></pre></div><p>The later line asked a different question:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Does this account have any overdue invoices?</code></pre></div><p>Those are related questions, but they are not the same operation.</p><blockquote><p>A loaded association target does not make every future association-shaped call memory-only.</p></blockquote><p><code>account.invoices </code>feels intuitive because it looks like object navigation, which is the main goal in typical application code. It allows you to navigate from an account to its invoices effortlessly, without concern for foreign keys, relation builders, or object caches.</p><p>But Rails is not storing &#8220;the invoices&#8221; as a plain property on the <code>Account</code> object.</p><p>An association reader is a query interface with cache state.</p><h2>The Association Declaration Is Metadata</h2><p>The association begins in the model class:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class Account &lt; ApplicationRecord
  has_many :invoices
end</code></pre></div><p><code>has_many</code> does not load invoices. It records <a href="https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html">association metadata</a>: how invoices relate to accounts, which Rails can inspect later:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">reflection = Account.reflect_on_association(:invoices)

reflection.macro
# =&gt; :has_many

reflection.klass
# =&gt; Invoice

reflection.foreign_key
# =&gt; "account_id"</code></pre></div><p>The declaration gives class-level information:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Account has many Invoice records
Invoice rows point back through account_id</code></pre></div><p>An individual account supplies instance-level state:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;367b100a-f7eb-4ee9-8818-c62c168cd951&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.id
# =&gt; 42</code></pre></div><p>Together, those pieces let Rails build a query for this owner:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;7b0be993-14ca-4f54-b84f-cbf9a524b7ae&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.scope.to_sql
# =&gt; SELECT "invoices".*
#    FROM "invoices"
#    WHERE "invoices"."account_id" = 42</code></pre></div><p>No invoice object has to exist yet.</p><p>The association reader packages three pieces:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;260ec082-170c-4293-9b01-e81ff1af7843&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">association declaration
owner object state
association scope</code></pre></div><p>For a collection association, Rails exposes those pieces through a <code>CollectionProxy</code>.</p><p>The next operation decides what happens:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">build a narrower relation
load the target
read the loaded target
ask SQL for a count or existence answer
construct a new associated object</code></pre></div><h2>The Reader Returns an Interface</h2><p>For a collection association, <code>account.invoices</code> returns an <a href="https://api.rubyonrails.org/classes/ActiveRecord/Associations/CollectionProxy.html">ActiveRecord::Associations::CollectionProxy</a>.</p><p>That proxy can behave like a relation:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.where(status: "paid")
account.invoices.order(issued_at: :desc)
account.invoices.limit(10)</code></pre></div><p>It can behave like a collection:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;3aa7a2f5-73bb-4a3b-950a-fa57e01edc49&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.each { |invoice| invoice.total_cents }
account.invoices.map(&amp;:id)
account.invoices.length</code></pre></div><p>It can also build new associated objects:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;aba6f765-2fe4-49a8-9504-451f36af2a34&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice = account.invoices.build(total_cents: 5000)

invoice.account_id
# =&gt; 42</code></pre></div><p>Those calls do not take the same path.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.count
# SELECT COUNT(*)
# FROM "invoices"
# WHERE "invoices"."account_id" = 42</code></pre></div><p><code>count</code> asks the database for a number.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;c76248fe-1da7-49dc-86db-187f02331dd5&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.each do |invoice|
  invoice.total_cents
end
# SELECT "invoices".*
# FROM "invoices"
# WHERE "invoices"."account_id" = 42</code></pre></div><p><code>each</code> needs records, so Rails loads the target.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.where(status: "paid")
# no SELECT yet</code></pre></div><p><code>where</code> returns a narrower relation from the association scope.</p><p>The same reader can lead to relation composition, SQL, target loading, target reading, or object construction.</p><p>Reading <code>account.invoices</code> as &#8220;the invoices stored on account&#8221; eventually breaks down.</p><h2>Loading Creates a Target</h2><p>When Rails loads a collection association, it stores the resulting records as the association target for that owner object.</p><p>Before loading:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;fa1d5387-1714-4f61-b0a2-a9817de43039&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoices = account.invoices

invoices.loaded?
# =&gt; false</code></pre></div><p><code>loaded?</code> is only a state check. It does not load the association.</p><p>After loading:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;96f49ca5-c651-40d6-91fc-a88f5989d57e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoices.to_a

invoices.loaded?
# =&gt; true</code></pre></div><p>Now a plain read can use the loaded target:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;6c852b30-1101-448a-9c60-f34c1a120f99&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.loaded?
# =&gt; true

account.invoices.map(&amp;:id)
# no additional SELECT for the base association target</code></pre></div><p>That target belongs to this owner object and this association.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;41f9c42d-bab3-4577-b5e4-89e48fa7dda5&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">first = Account.find(42)
second = Account.find(42)

first.invoices.to_a

first.invoices.loaded?
# =&gt; true

second.invoices.loaded?
# =&gt; false</code></pre></div><p><a href="https://railsrevelry.substack.com/p/an-active-record-object-is-a-snapshot-not-the-row?r=4jsb">Two Ruby objects can represent the same database row yet have different association states</a>. Loading <code>first.invoices</code> leaves <code>second.invoices</code> untouched.</p><p>That association target is also separate from Rails&#8217; SQL query cache.</p><p>The association cache lives on the model instance. It remembers the loaded target for <code>account.invoices</code>.</p><p>The SQL query cache operates at a lower level, storing repeated SQL result sets for a connection within a request or execution context. It may avoid another database round trip for identical SQL, but Rails can still instantiate separate Ruby objects and maintain separate association targets.</p><p>When the target is suspicious, inspect the association object:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;2484b0ed-8086-49cf-aa03-14486c0dbb23&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">association = account.association(:invoices)

association.loaded?
# =&gt; true

association.target
# =&gt; [#&lt;Invoice id: 1, ...&gt;, #&lt;Invoice id: 2, ...&gt;]</code></pre></div><p><code>target</code> is a diagnostic tool. Application code should rarely need it.</p><p>Read it together with <code>loaded?</code> because a target can contain in-memory additions even when the full association has not been loaded.</p><h2>A Loaded Target Is Not Every Query</h2><p>Now return to the surprise:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;fa295a30-124a-4860-8a58-7ec6668671d5&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account = Account.includes(:invoices).find(42)

account.invoices.loaded?
# =&gt; true

account.invoices.overdue.any?
# SELECT 1 AS one ...</code></pre></div><p>The base target is loaded:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">All invoices for the account 42</code></pre></div><p>The scoped association call composes a new query:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">overdue invoices for account 42</code></pre></div><p>Rails does not assume that every future scope should filter the loaded array in Ruby. When you chain <code>where</code>, <code>order</code>, <code>limit</code>, or a named scope onto the association proxy, you are usually building a relation from the association scope.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;356af65f-5a08-4978-9820-8c84e6c380e2&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.where(status: "overdue").to_sql
# =&gt; SELECT "invoices".*
#    FROM "invoices"
#    WHERE "invoices"."account_id" = 42
#      AND "invoices"."status" = 'overdue'</code></pre></div><p>That relation is not the already-loaded target. It is a more specific database question.</p><p>If you want to filter the loaded target in Ruby, say that:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.select { |invoice| invoice.status == "overdue" }</code></pre></div><p>That uses the loaded records and moves the filtering work into Ruby.</p><p>For a small collection already needed by the page, that's fine. For a large collection, the database is usually the better place to filter.</p><p>Do not make that a rule that memory or SQL is always better. Know which one you asked for.</p><p><code>select</code> makes this especially easy to blur:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.select(:id, :status)</code></pre></div><p>That builds an SQL projection.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.select { |invoice| invoice.status == "overdue" }</code></pre></div><p>That loads records and filters them in Ruby.</p><p>With arguments, select behaves like a relation builder. With a block, it behaves like Enumerable and needs records.</p><p>The same method name spans different execution paths depending on how you call it.</p><h2>Loaded for Which Question?</h2><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account = Account.includes(:invoices).find(42)

account.invoices.size
# uses the loaded target

account.invoices.overdue.any?
# SQL</code></pre></div><p>The query itself is not the issue.</p><p>The problem is that the application code treated &#8220;the association is loaded&#8221; as if it answered every association question the code would later ask.</p><p><code>loaded?</code> tells you whether Rails has loaded a target.</p><p>It does not certify future association-shaped calls as memory-only.</p><p>With <code>Account.includes(:invoices)</code>, the code preloads invoices for this account. The next line asks: "<em>Does this account have overdue invoices?"</em></p><p>Rails took a different path because those questions are different.</p><p>This can show up in a view:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;b940d34e-0fb0-4e97-95f4-1a974cf10fc0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">&lt;%= account.invoices.size %&gt;

&lt;% if account.invoices.overdue.any? %&gt;
  Overdue
&lt;% end %&gt;</code></pre></div><p>It can show up in a serializer:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;434e22da-65dc-482a-aa4c-b5323d7208f5&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class AccountSerializer
  def as_json
    {
      invoice_ids: account.invoices.map(&amp;:id),
      has_overdue_invoices: account.invoices.overdue.any?
    }
  end
end</code></pre></div><p>It can show up in a policy or mailer:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;85e7a8fb-90ad-487f-a276-7d4b41ef4c25&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">return unless account.invoices.visible_to(user).any?</code></pre></div><p>In each case, the question is the same:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;c13273d3-0f41-47cb-86a0-379c41dde5b4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Did this code read the loaded target, or did it build another query?</code></pre></div><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">RailsRevelry is a weekly series for Rails developers who want sharper mental models of the framework they already use. Subscribe to get the next article in the persistence boundary series.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><h2>Choosing the Right Shape</h2><p>Once you name the question, the fix becomes a design choice instead of a preload ritual.</p><p>If the page already needs all invoices and the collection is reasonably small, read the target:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.any?(&amp;:overdue?)</code></pre></div><p>If the page needs a scoped collection, model and preload that target:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class Account &lt; ApplicationRecord
  has_many :invoices
  has_many :overdue_invoices,
    -&gt; { overdue },
    class_name: "Invoice"
end</code></pre></div><p>Then, preload the target you plan to read:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account = Account
  .includes(:overdue_invoices)
  .find(42)

account.overdue_invoices.loaded?
# =&gt; true</code></pre></div><p>If the page only needs a boolean, ask for it directly, usually through a scope, query object, or database view. Loading another association to answer "yes" or "no" may be the wrong approach.</p><p>Be careful with this tempting rewrite:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Account
  .includes(:invoices)
  .where(invoices: { status: "overdue" })</code></pre></div><p>Treat that as more than &#8220;the same preload, but filtered.&#8221;</p><p>Conditions in the included table can move Rails from separate preload queries toward a joined eager-loading shape using LEFT OUTER JOIN. That can change which parent rows appear, which child rows are available through the loaded association, and how much duplicate row data Rails has to process.</p><p>It may be the right query, but it is no longer merely a preload decision.</p><h2>Counts, Existence, and Collection State</h2><p>Association methods that look similar in Ruby can ask different persistence questions.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!ydbq!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71ebcaae-9239-4ab6-84ce-d6db3c41d39f_1240x1544.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ydbq!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71ebcaae-9239-4ab6-84ce-d6db3c41d39f_1240x1544.png 424w, https://substackcdn.com/image/fetch/$s_!ydbq!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71ebcaae-9239-4ab6-84ce-d6db3c41d39f_1240x1544.png 848w, https://substackcdn.com/image/fetch/$s_!ydbq!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71ebcaae-9239-4ab6-84ce-d6db3c41d39f_1240x1544.png 1272w, https://substackcdn.com/image/fetch/$s_!ydbq!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71ebcaae-9239-4ab6-84ce-d6db3c41d39f_1240x1544.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!ydbq!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71ebcaae-9239-4ab6-84ce-d6db3c41d39f_1240x1544.png" width="1240" height="1544" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/71ebcaae-9239-4ab6-84ce-d6db3c41d39f_1240x1544.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1544,&quot;width&quot;:1240,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:192267,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/206570065?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71ebcaae-9239-4ab6-84ce-d6db3c41d39f_1240x1544.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ydbq!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71ebcaae-9239-4ab6-84ce-d6db3c41d39f_1240x1544.png 424w, https://substackcdn.com/image/fetch/$s_!ydbq!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71ebcaae-9239-4ab6-84ce-d6db3c41d39f_1240x1544.png 848w, https://substackcdn.com/image/fetch/$s_!ydbq!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71ebcaae-9239-4ab6-84ce-d6db3c41d39f_1240x1544.png 1272w, https://substackcdn.com/image/fetch/$s_!ydbq!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71ebcaae-9239-4ab6-84ce-d6db3c41d39f_1240x1544.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Limits, custom scopes, <code>select</code>, <code>distinct</code>, joins, counter caches, and strict loading can still change details.</p><p>On an unloaded association, without a usable counter cache, <code>any?</code> and <code>empty?</code> ask existence questions. They do not load the target or run a full count. <code>exists?</code> should be treated as an explicit database check, even when the target is loaded.</p><p><code>size</code> is collection-aware. It can use a loaded target, include built-but-unsaved records, or read a counter cache on the parent. <code>count</code> asks the database.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice = account.invoices.build(total_cents: 5000)

account.invoices.size
# includes the built invoice

account.invoices.count
# database count</code></pre></div><p>If <code>Invoice</code> belongs to <code>Account</code> with a counter cache and <code>accounts.invoices_count</code> is present, <code>account.invoices.size</code> can read the count from the already-loaded account instead of issuing <code>SELECT COUNT(*)</code>.</p><p>Useful, but only as accurate as the counter column. On old tables, failed backfills or manual SQL changes can turn a performance feature into a misleading object state.</p><p>When the log feels random, name the operation:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">database count
SQL existence check
counter-cache read
target load
target read
new scoped relation
cache reset</code></pre></div><h2>Singular Associations Have Cache State Too</h2><p>Collections make the proxy visible, but singular associations share the same basic shape.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice = Invoice.find(123)

invoice.association(:account).loaded?
# =&gt; false

invoice.account
# Account Load
# SELECT "accounts".*
# FROM "accounts"
# WHERE "accounts"."id" = 42
# LIMIT 1

invoice.association(:account).loaded?
# =&gt; true</code></pre></div><p>After that, <code>invoice.account</code> can return the cached object.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice.account.anem
# no additional SELECT for the cached account</code></pre></div><p>The foreign key and the associated object are different state:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;f92e7bb4-707a-4e7f-9e72-5f4dc535975a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice.account_id
# =&gt; 42</code></pre></div><p><code>account_id</code> is an attribute on the invoice snapshot. <code>invoice.account</code> is an association read. One is already present on the loaded invoice. The other may need a query and may then be cached.</p><p>Bidirectional associations add one more cache wrinkle. Rails can often connect both sides of an ordinary association in memory, so <code>invoice.account</code> may reuse the already-loaded <code>account</code> object. Custom names, foreign keys, <code>through</code> associations, and scopes can make that inverse relationship ambiguous. <code>inverse_of</code> helps Rails stitch both sides of the object graph together once records are in memory. It is not a preload.</p><h2>Debugging Association State</h2><p>When association behavior is surprising, separate the four questions:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">What relationship did the model declare?
What query would this owner use?
Has this owner loaded this association target?
Is the code reading the target or composing a new query?</code></pre></div><p>For most debugging, the useful handles are small: the association object, the generated scope, the loaded target, and the reset/reload controls.</p><p>Start with the association object:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account = Account.find(42)
association = account.association(:invoices)

association.loaded?
# =&gt; false</code></pre></div><p>Look at the query shape without loading records:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.scope.to_sql</code></pre></div><p>Compare the operations that look similar:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;f90c2b7a-2953-49de-96e9-ad58bbd4e72c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.size
account.invoices.length
account.invoices.any?
account.invoices.empty?
account.invoices.exists?
account.invoices.any?(&amp;:overdue?)
account.invoices.overdue.any?
account.invoices.to_a</code></pre></div><p>When lazy loads are expensive, <code>strict_loading</code> can turn an accidental association read into an explicit failure.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;9d91400a-8137-4ca6-9550-4458ebff7eb3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account = Account.strict_loading.find(42)
account.invoices.to_a
# raises ActiveRecord::StrictLoadingViolationError</code></pre></div><p>Rails also supports narrower strict-loading modes for N+1 detection:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.strict_loading!(mode: :n_plus_one_only)</code></pre></div><p>Use that when you want to catch repeated lazy loading across a collection.</p><p>Do not treat strict loading as an explanation of association semantics. It is a guardrail. It indicates that the code attempted to load an association through a strict record without loading it in advance. You still need to decide whether the right fix is preloading, a scoped association, or an explicit query defined elsewhere.</p><h2>The Debugging Reflex</h2><p>Do not ask only:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Is the association loaded?</code></pre></div><p>Ask:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Loaded for which question?</code></pre></div><p><code>account.invoices</code> can expose a loaded target.</p><p><code>account.invoices.where(&#8230;)</code> can build a new relation.</p><p><code>account.invoices.size</code> can use memory, SQL, or a counter cache depending on state.</p><p><code>account.invoices.reset</code> can discard the target.</p><p><code>invoices.account</code> can be a cached object or a query waiting for the first read.</p><p>Associations are what make Rails models feel connected. The cost is that object navigation and query construction can be hidden behind the same method name.</p><p>When the same mistaken assumption recurs throughout a collection, the problem grows louder.</p><p>N+1 starts there: <a href="https://open.substack.com/pub/railsrevelry/p/n-plus-one-queries-lazy-loading?r=4jsb">association lazy loading repeated once per parent record</a>.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">The next article follows that mistake into N+1 queries. Subscribe if you want the next RailsRevelry piece in your inbox.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[An Active Record Object Is a Snapshot, Not the Row]]></title><description><![CDATA[A loaded model instance represents database-backed state at one moment. It is not synchronized database truth.]]></description><link>https://railsrevelry.substack.com/p/an-active-record-object-is-a-snapshot-not-the-row</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/an-active-record-object-is-a-snapshot-not-the-row</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 05 Jul 2026 07:01:44 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!FNSf!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The second trap in Active Record starts after the query has already run.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice = Invoice.find(42)
same_invoice = Invoice.find(42)

same_invoice.update!(status: "paid")

invoice.status
# =&gt; "overdue"

same_invoice.status
# =&gt; "paid"

invoice == same_invoice
# =&gt; true</code></pre></div><p>That last line is the surprise.</p><p>Both objects represent the same database row. They have the same model class and the same primary key. Active Record can compare them as the same record.</p><p>They are not the same Ruby object, and they do not have to carry the same attribute values.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice.equal?(same_invoice)
# =&gt; false</code></pre></div><p>The first object did not get a message from the database when the second object saved. It stayed exactly what it was: a Ruby representation of database-backed state as Rails saw it when that object was loaded.</p><p>It is not the row.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!FNSf!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!FNSf!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png 424w, https://substackcdn.com/image/fetch/$s_!FNSf!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png 848w, https://substackcdn.com/image/fetch/$s_!FNSf!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png 1272w, https://substackcdn.com/image/fetch/$s_!FNSf!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!FNSf!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png" width="1456" height="1030" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1030,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1299578,&quot;alt&quot;:&quot;Diagram showing one database row, `invoices.id = 42`, represented by two different Ruby objects with different status values and object IDs, emphasizing same Active Record identity but different object snapshots.&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/205127080?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Diagram showing one database row, `invoices.id = 42`, represented by two different Ruby objects with different status values and object IDs, emphasizing same Active Record identity but different object snapshots." title="Diagram showing one database row, `invoices.id = 42`, represented by two different Ruby objects with different status values and object IDs, emphasizing same Active Record identity but different object snapshots." srcset="https://substackcdn.com/image/fetch/$s_!FNSf!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png 424w, https://substackcdn.com/image/fetch/$s_!FNSf!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png 848w, https://substackcdn.com/image/fetch/$s_!FNSf!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png 1272w, https://substackcdn.com/image/fetch/$s_!FNSf!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F87b36d74-4538-4aa0-b58e-c5ff18cab5e8_1491x1055.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><blockquote><p>A loaded Active Record object is not synchronized database truth. It is an in-memory snapshot of the database-backed state at a particular moment.</p></blockquote><p>The last article traced <a href="https://railsrevelry.substack.com/p/when-does-an-active-record-query-actually-run">deferred query intent</a>. An<code> ActiveRecord::Relation</code> is not loaded data until something forces execution.</p><p>Once execution has happened, the confusion moves from query intent to object state.</p><h2>From Row to Object</h2><p>When you call <code>find</code>, Rails does not hand your Ruby process a database row.</p><p>It performs a translation.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">query intent
    -&gt;
SQL execution
    -&gt;
database result row
    -&gt;
attribute state is built
    -&gt;
model object is instantiated
    -&gt;
Ruby code receives an Active Record instance</code></pre></div><p>The object you receive behaves like the application code wants a domain object to behave.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice.id
invoice.status
invoice.due_on
invoice.total_cents
invoice.overdue?</code></pre></div><p>The object came from the database, but after materialization, ordinary attribute reads are object reads.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice.status
# no SELECT</code></pre></div><p>Rails already has attribute state for <code>status</code> inside the model instance, backed by Active Record&#8217;s type system. It does not need to ask the database again each time you call the method.</p><p>Active Record works naturally in controllers, policies, serializers, and views for the same reason stale-object bugs are easy to miss. The syntax does not remind you when the database stopped being involved.</p><h2>The Snapshot Can Drift</h2><p>Any other execution path can change the row after your object was loaded.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice = Invoice.find(42)
# =&gt; #&lt;Invoice id: 42, status: "overdue"&gt;

Invoice.where(id: 42).update_all(status: "paid")

invoice.status
# =&gt; "overdue"</code></pre></div><p>The <code>update_all</code> call changed the database row. It did not mutate the already-loaded <code>invoice</code> object.</p><p><code>update_all</code> issues a direct database update without instantiating model objects or running the normal record lifecycle.</p><p>The same drift can come from another request, a background job, a console session, a webhook handler, a migration script, or a direct SQL statement. Rails does not keep a live subscription between every row and every Ruby object that has ever represented it.</p><p>If you want a newer database state, you have to cross the boundary again.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;243b38db-3f08-459d-8d79-58cd0a5b80b6&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice.reload

invoice.status
# =&gt; "paid"</code></pre></div><p><code>reload</code> is not a cosmetic refresh. It is a database read. It discards the object&#8217;s current attribute snapshot and replaces it with a fresh one from the row, or raises <code>ActiveRecord::RecordNotFound</code> if the row is gone.</p><h2>Active Record Identity Is Not Object Identity</h2><p>Two Rails concepts often get mixed up:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;6e055844-ce02-473a-bb83-fdb4a5ecfb82&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Ruby object identity
  Is this the exact same object allocation?

Active Record identity
  Does this model instance represent the same table row?</code></pre></div><p>They answer different questions.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;f4bcf943-1726-462c-b403-7e1393220e96&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">first_copy = Invoice.find(42)
second_copy = Invoice.find(42)

first_copy.equal?(second_copy)
# =&gt; false

first_copy == second_copy
# =&gt; true</code></pre></div><p><code>equal?</code> is Ruby object identity. These are two different objects.</p><p><code>==</code> is Active Record identity. Once a persisted model has a primary key, Active Record can treat another object of the same model and the same primary key as the same record.</p><p>New records, records loaded without their primary key, and unusual primary-key setups have edge cases, but the ordinary persisted-record case behaves this way.</p><p>The comparison lets sets, arrays, and application code reason about records by database identity rather than by memory address.</p><p>But it is not a freshness check.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">second_copy.update!(status: "paid")

first_copy.status
# =&gt; "overdue"

second_copy.status
# =&gt; "paid"

first_copy == second_copy
# =&gt; true</code></pre></div><p>No contradiction: two Ruby objects can have the same database identity and different attribute snapshots.</p><h2>The Query Cache Is Not an Identity Map</h2><p>Rails has a Query cache, but that does not change the object model.</p><p>Within a request or execution context, Rails may cache SQL result sets for repeated identical reads on a connection. The cache can avoid duplicate trips to the database for the same SQL.</p><p>It does not mean Rails keeps one live Ruby object per database row.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Invoice.find(42).equal?(Invoice.find(42))
# =&gt; false</code></pre></div><p>In ordinary Rails code, separate loads can produce separate objects. Even when a SQL result is cached, Rails can still instantiate model objects from that result. Each instance owns its own attribute state. Updating one instance does not update every other instance with the same primary key.</p><p>A method call on <code>invoice</code> reads the <code>invoice</code>&#8217;s state. That local ownership keeps Active Record pleasant to work with, but it also means correctness that depends on current database state must be explicit.</p><h2>The Production Bug</h2><p>Stale snapshots become production bugs when code makes a later decision from an earlier object.</p><p>Consider an overdue reminder job.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;6b35a18b-d4ac-4bed-9607-eab78fedfcfe&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class SendOverdueReminderJob &lt; ApplicationJob
  def perform(invoice_id)
    invoice = Invoice.find(invoice_id)

    return unless invoice.overdue?

    InvoiceMailer.overdue(invoice).deliver_now
    invoice.update!(last_reminder_sent_at: Time.current)
  end
end</code></pre></div><p>That code is reasonable in isolation. It loads the invoice, checks whether it is overdue, sends the email, and records that the reminder was sent.</p><p>The length of the gap is not the issue. The database read and the Ruby predicate are two separate steps, and another process can commit between them.</p><p>Now add production timing:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;toml&quot;,&quot;nodeId&quot;:&quot;1695f557-6e7d-4477-a064-ee55654abf25&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-toml">10:00:00.000  job loads invoice #42 as overdue
10:00:00.050  customer pays invoice #42 in a web request
10:00:00.090  web request updates invoice #42 to paid
10:00:00.140  job checks its already-loaded invoice object
10:00:00.160  job sends an overdue reminder for a paid invoice</code></pre></div><p>The method reads the loaded object.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;c31abc94-f76f-4b11-9598-9a7eb31070bf&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def overdue?
  status == &#8220;overdue&#8221;
end</code></pre></div><p>That method does not secretly issue a <code>SELECT</code>. It asks the object for its current <code>status</code> value. If the object was loaded before the payment was completed, the answer can be stale.</p><p>Reloading just before the decision can narrow that window:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;84b7b2f0-aeca-4d7f-82b5-08898019de2c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice.reload

return unless invoice.overdue?</code></pre></div><p>But <code>reload</code> is not a concurrency strategy. It narrows one stale-read window; it does not make a read-modify-side-effect sequence automatic. Another process can still change the row after the reload and before the email.</p><p>For decisions that must be true at the database boundary, shape the operation as a database question or a database transition.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;4b3828ec-802d-4d0b-ac56-1eae356ba2d1&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">updated = Invoice
  .where(id: invoice_id, status: "overdue")
  .update_all(
    reminder_state: "queued",
    updated_at: Time.current
  )

return unless updated == 1

invoice = Invoice.find(invoice_id)
InvoiceMailer.overdue(invoice).deliver_now</code></pre></div><p>Here, the database performs the conditional transition rather than using the loaded object&#8217;s old status.</p><p>A real reminder system may still need an outbox, a transaction, <code>after_commit</code>, locking, idempotency keys, or a state machine.</p><p>The job violated one boundary:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;d56cd610-58ff-453c-acdc-6208c7f092e4&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice.overdue?</code></pre></div><p>It is an object-state question.</p><p>Sometimes production code needs a database-state question.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">RailsRevelry is a series of deep dives into how Rails actually behaves in production. Subscribe for free to get the next article in the persistence chapter.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><h2>Lifecycle Flags Belong to the Object</h2><p>Methods like <code>new_record?</code>, <code>persisted?</code>, and <code>destroyed?</code> look like database questions.</p><p>They are object-lifecycle questions.</p><p>Conceptually, the predicates are close to this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def new_record?
  @new_record
end

def destroyed?
  @destroyed
end

def persisted?
  !(@new_record || @destroyed)
end</code></pre></div><p>They tell you what this Ruby object believes about its own Active Record lifecycle. They do not perform a fresh database query.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice = Invoice.find(42)

Invoice.where(id: 42).delete_all

invoice.persisted?
# =&gt; true

invoice.destroyed?
# =&gt; false

Invoice.exists?(invoice.id)
# =&gt; false

invoice.reload
# raises ActiveRecord::RecordNotFound</code></pre></div><p>The object did not go through <code>destroy</code>. It was not told that another SQL statement had deleted its row. Its flags still describe the lifecycle of the object you are holding.</p><p>The same rule applies when the object itself performs the deletion:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;0102341c-7902-46a5-8626-f68aa00f644b&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice = Invoice.find(42)

invoice.destroy!

invoice.destroyed?
# =&gt; true

invoice.id
# =&gt; 42

Invoice.exists?(invoice.id)
# =&gt; false</code></pre></div><p>A destroyed object remains in memory with its ID, which can be useful for logging, auditing, callbacks, and after-commit work.</p><p>Do not ask an object-lifecycle predicate to prove current row existence.</p><h2>Assignment Changes the Object First</h2><p>Assignment is another place where object state and database state part ways.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;e5537abb-c08f-4d83-896d-6ab3de032645&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice = Invoice.find(42)

invoice.status = "paid"

invoice.status
# =&gt; "paid"</code></pre></div><p>At this point, no database write has happened.</p><p><a href="https://railsrevelry.substack.com/p/how-rails-knows-what-changed?r=4jsb">Dirty tracking</a> describes which attribute changes the next save would persist. For now, the boundary is simpler:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">assignment is object mutation
save is persistence orchestration
commit is durable database state</code></pre></div><p>Those steps often appear side by side in Rails code, but they are not the same step. The database only sees writes that actually run, in the order and transaction boundaries it accepts.</p><h2><code>reload</code> Replaces the Snapshot</h2><p><code>reload</code> is the explicit operation for saying:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;5dc72a9c-cfa3-401a-8a76-15f652fab36c&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">discard what this object currently knows
ask the database for this row again
replace this object's attributes with the fresh result</code></pre></div><p>It modifies the receiver in place.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice = Invoice.find(42)
object_id = invoice.object_id

invoice.reload

invoice.object_id == object_id
# =&gt; true</code></pre></div><p>Existing references still point to the same Ruby object, but that object now carries a new attribute snapshot. Rails also clears related caches around the object, including association cache state.</p><p>Repeated reloads often signal that the code is mixing object snapshots and database without naming which one each decision needs.</p><p>Use <code>reload</code> when you mean it:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;4076b1f2-670e-4f4c-8467-24b322b18f70&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice.reload.status</code></pre></div><p>Do not use it as a substitute for deciding where the authority should live.</p><h2>The View Does Not Make Objects Fresher</h2><p>Controller instance variables can make loaded records feel more official than they are.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;eef55601-7321-432e-a32a-f266a597bfda&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class InvoicesController &lt; ApplicationController
  def show
    @invoice = current_account.invoices.find(params[:id])
  end
end</code></pre></div><p>By the time the view renders, <code>@invoice</code> is a model object carrying an attribute snapshot.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;399328a2-033b-4371-bab3-885996e8cd40&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">&lt;%= @invoice.status %&gt;
&lt;%= number_to_currency(@invoice.total_cents / 100.0) %&gt;</code></pre></div><p>Those reads do not ask the database again. They read the object.</p><p>A render should be internally consistent. If helpers, decorators, serializers, or partials casually reload records during rendering, the page can become a mix of values loaded at different times.</p><p>More freshness does not automatically mean more correctness.</p><p>A view should behave like a function of the state the controller chose to render. If the view needs <code>reload</code> to make the page correct, the controller or the surrounding workflow loaded the wrong state for the decision being made.</p><p>The controller should decide which state it needs, deliberately load that state, and render from it. If a particular decision needs the current database truth, make that query explicit at the boundary instead of hiding <code>reload</code> in the presentation code.</p><h2>A Debugging Map</h2><p>When an Active Record object behaves strangely, separate four things:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;ba42caa8-067b-47f5-b0d0-104fa9fb099d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Ruby object identity
  object_id, equal?

Active Record identity
  model class and primary key

object snapshot
  the values currently held by the model instance

database truth
  what a new database query can observe</code></pre></div><p>Then ask the precise question:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!ef-3!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6cab1f34-e0dd-4226-a19c-e42da9e2ead7_1240x714.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ef-3!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6cab1f34-e0dd-4226-a19c-e42da9e2ead7_1240x714.png 424w, https://substackcdn.com/image/fetch/$s_!ef-3!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6cab1f34-e0dd-4226-a19c-e42da9e2ead7_1240x714.png 848w, https://substackcdn.com/image/fetch/$s_!ef-3!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6cab1f34-e0dd-4226-a19c-e42da9e2ead7_1240x714.png 1272w, https://substackcdn.com/image/fetch/$s_!ef-3!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6cab1f34-e0dd-4226-a19c-e42da9e2ead7_1240x714.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!ef-3!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6cab1f34-e0dd-4226-a19c-e42da9e2ead7_1240x714.png" width="1240" height="714" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/6cab1f34-e0dd-4226-a19c-e42da9e2ead7_1240x714.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:714,&quot;width&quot;:1240,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:81654,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/205127080?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6cab1f34-e0dd-4226-a19c-e42da9e2ead7_1240x714.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ef-3!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6cab1f34-e0dd-4226-a19c-e42da9e2ead7_1240x714.png 424w, https://substackcdn.com/image/fetch/$s_!ef-3!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6cab1f34-e0dd-4226-a19c-e42da9e2ead7_1240x714.png 848w, https://substackcdn.com/image/fetch/$s_!ef-3!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6cab1f34-e0dd-4226-a19c-e42da9e2ead7_1240x714.png 1272w, https://substackcdn.com/image/fetch/$s_!ef-3!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F6cab1f34-e0dd-4226-a19c-e42da9e2ead7_1240x714.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The last row is mostly due to dirty tracking, but the object may contain changes that the database has not yet seen.</p><p>Use the answer to choose the next move: inspect the object, reload the object, run a fresh query, or move the decision into a database operation.</p><h2>Where This Lives in Rails</h2><p><a href="https://api.rubyonrails.org/classes/ActiveRecord/Persistence/ClassMethods.html#method-i-instantiate">ActiveRecord::Persistence::ClassMethods#instantiate</a> is part of the path that turns a database result into a model instance. It selects the appropriate class for the record and instantiates it from the returned attributes.</p><p><a href="https://api.rubyonrails.org/classes/ActiveModel/AttributeSet.html">ActiveModel::AttributeSet</a> is part of the typed attribute state beneath ordinary model attribute reads and writes.</p><p><a href="https://api.rubyonrails.org/classes/ActiveRecord/Core.html#method-i-3D-3D">ActiveRecord::Core#==</a> explains why two different Ruby objects with the same model class and primary key can compare as equal.</p><p><a href="https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html">ActiveRecord::Persistence</a> provides lifecycle predicates such as new_record?, persisted?, and destroyed?, as well as <code>reload</code>.</p><p>The mental model maps to the implementation:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;faea5b72-5f04-4105-a046-764164777607&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">row returned by SQL
    -&gt;
attribute state built from database values
    -&gt;
model object allocated
    -&gt;
object lifecycle flags initialized
    -&gt;
Ruby object live independently
    -&gt;
another explicit database operation may refresh or persist it</code></pre></div><p>There is no hidden subscription from the database row back to every Ruby object that has represented it.</p><h2>The Debugging Reflex</h2><p>Do not start with:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Why is the database wrong?</code></pre></div><p>Ask:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Which snapshot am I reading?</code></pre></div><p>Two model instances can represent the same row and disagree.</p><p>Lifecycle predicates can describe the object without proving current row existence.</p><p>An assignment can change the object before any write occurs.</p><p><code>reload</code> can replace the snapshot, but it does not make a multi-step operation concurrency-safe.</p><p>None of those are edge cases in the framework. They are consequences of the abstraction.</p><p>Active Record gives you Ruby objects because Ruby objects are the right interface for application code. The database gives you rows because rows are the durable shared state. Rails translates between them, but it does not collapse them into the same thing.</p><p>That sets up the next abstraction.</p><p>A loaded model object owns an attribute snapshot. But when you call this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoice.line_items</code></pre></div><p>you may not be reading the loaded object state at all.</p><p>You may be touching another query interface hidden behind object navigation.</p><p>The next boundary is <a href="https://railsrevelry.substack.com/p/associations-are-query-interfaces">associations</a>: they are not object properties. They are query interfaces with caches. And a loop that reads one of those interfaces lazily, once per record, is <a href="https://railsrevelry.substack.com/p/n-plus-one-queries-lazy-loading?r=4jsb">how N+1 queries happen</a>.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get the next deep dive on Rails internals, state, and persistence.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[When Does an Active Record Query Actually Run?]]></title><description><![CDATA[Active Record queries often look like loaded records before Rails has touched the database. This article traces how relations accumulate intent, which methods force SQL, and why present? can load too much, and how to debug the moment query intent becomes database work.]]></description><link>https://railsrevelry.substack.com/p/when-does-an-active-record-query-actually-run</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/when-does-an-active-record-query-actually-run</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 28 Jun 2026 03:30:33 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/d5212f9f-6be3-4ba2-846a-442fc8efdd60_1672x941.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The first trap in Active Record is that the code often looks more decisive than it is.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoices = current_account.invoices
  .where(status: "overdue")
  .order(due_at: :asc)</code></pre></div><p>That reads like Rails went to the database and fetched overdue invoices.</p><p>It did not.</p><p>In the ordinary read path, this expression built an <code>ActiveRecord::Relation</code>. It gathered intent: which table, which conditions, which order, which model class should eventually receive the rows. But it did not necessarily send SQL to the database, receive rows, or instantiate <code>Invoice</code> objects.</p><p>That difference is easy to miss because Active Record relations are deliberately comfortable. You can chain them like queries, pass them through service objects, return them from scopes, hand them to views, and eventually treat them like collections.</p><p>That is the abstraction.</p><p>The database work may happen much later than the line where the query was described.</p><p>Consider a controller action that wants to show the first fifty overdue invoices:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class Admin::InvoicesController &lt; ApplicationController
  def index
    invoices = current_account.invoices
      .where(status: "overdue")
      .order(due_at: :asc)

    if invoices.present?
      @invoices = invoices.limit(50)
    else
      flash.now[:notice] = "No overdue invoices."
    end
  end
end</code></pre></div><p>This is ordinary Rails code. Nothing looks reckless. The relation is readable. The <code>limit(50)</code> is right there.</p><p>Then production traffic finds the account with 80,000 overdue invoices.</p><p>The request becomes slow, memory jumps, and the logs show something like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Invoice Load (1842.7ms)
  SELECT "invoices".*
  FROM "invoices"
  WHERE "invoices"."account_id" = 42
    AND "invoices"."status" = 'overdue'
  ORDER BY "invoices"."due_at" ASC

Invoice Load (12.8ms)
  SELECT "invoices".*
  FROM "invoices"
  WHERE "invoices"."account_id" = 42
    AND "invoices"."status" = 'overdue'
  ORDER BY "invoices"."due_at" ASC
  LIMIT 50</code></pre></div><p>The expensive query is the innocent-looking empty check:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoices.present?</code></pre></div><p><code>present?</code> calls <code>blank?</code>, and for an Active Record relation, <code>blank?</code> needs the relation&#8217;s records. That means the relation is loaded. Not counted. Not checked with <code>SELECT 1</code>. Loaded.</p><p>The neighboring methods do not all cross the same line:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoices.blank?   # loads records
invoices.present? # loads records
invoices.empty?   # if loaded, asks an existence question</code></pre></div><p>The problem is not that every collection-looking method is dangerous in the same way. The problem is that each can force the relation to answer a different kind of question.</p><p>The guard that was supposed to ask &#8220;Is there anything here?&#8221; accidentally asked Rails to materialize every matching row before the limited relation was even assigned.</p><p>This is the first persistence-boundary mistake.</p><blockquote><p>The Ruby object you are holding is not database truth. It may only be deferred query intent.</p></blockquote><p>Until something forces execution, the relation is just a plan.</p><h2>A Relation Is Not an Array</h2><p>We can read <code>Invoice.where(status: &#8220;overdue&#8221;)</code> as &#8220;overdue invoices&#8221;, but a more precise reading is &#8220;a relation that can later fetch overdue invoices&#8221;.</p><p>That relation knows enough to become SQL, but it is not yet the result of that SQL.</p><p>You can see the difference in a console if you avoid letting the console inspect the relation for you:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">relation = Invoice.where(status: "overdue").order(:due_at)

relation.class
# =&gt; ActiveRecord::Relation

relation.loaded?
# =&gt; false

relation.to_sql
# =&gt; SELECT "invoices".* FROM "invoices"
#    WHERE "invoices"."status" = 'overdue'
#    ORDER BY "invoices"."due_at" ASC

relation.loaded?
# =&gt; false</code></pre></div><p><code>to_sql</code> is a great tool for inspecting query shape. It shows the statement Rails has built, but does not run it.</p><p>The relation becomes loaded when Rails actually needs records:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">records = relation.to_a

relation.loaded?
# =&gt; true

records.first
# =&gt; #&lt;Invoice id: 1, status: "overdue", ...&gt;</code></pre></div><p>For a record-loading call like <code>to_a</code>, this is the basic lifecycle:</p><ol><li><p>relation construction</p></li><li><p>query clauses accumulate</p></li><li><p>an execution method is called</p></li><li><p>SQL is generated</p></li><li><p>the database adapter executes it</p></li><li><p>rows come back</p></li><li><p>model objects are instantiated</p></li><li><p>the relation is marked loaded</p></li></ol><p>Most confusion comes from mentally skipping the first three steps and imagining the relation already contains rows.</p><h2>The Execution Edges</h2><p>An Active Record relation crosses into database work when you ask a question that requires database information or records.</p><p>The edges are not all the same.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!8PJr!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4645ca55-8b5e-489a-9984-3cc70d977677_1196x1334.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!8PJr!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4645ca55-8b5e-489a-9984-3cc70d977677_1196x1334.png 424w, https://substackcdn.com/image/fetch/$s_!8PJr!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4645ca55-8b5e-489a-9984-3cc70d977677_1196x1334.png 848w, https://substackcdn.com/image/fetch/$s_!8PJr!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4645ca55-8b5e-489a-9984-3cc70d977677_1196x1334.png 1272w, https://substackcdn.com/image/fetch/$s_!8PJr!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4645ca55-8b5e-489a-9984-3cc70d977677_1196x1334.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!8PJr!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4645ca55-8b5e-489a-9984-3cc70d977677_1196x1334.png" width="1196" height="1334" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4645ca55-8b5e-489a-9984-3cc70d977677_1196x1334.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1334,&quot;width&quot;:1196,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:159340,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/203721534?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4645ca55-8b5e-489a-9984-3cc70d977677_1196x1334.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!8PJr!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4645ca55-8b5e-489a-9984-3cc70d977677_1196x1334.png 424w, https://substackcdn.com/image/fetch/$s_!8PJr!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4645ca55-8b5e-489a-9984-3cc70d977677_1196x1334.png 848w, https://substackcdn.com/image/fetch/$s_!8PJr!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4645ca55-8b5e-489a-9984-3cc70d977677_1196x1334.png 1272w, https://substackcdn.com/image/fetch/$s_!8PJr!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4645ca55-8b5e-489a-9984-3cc70d977677_1196x1334.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The table is not a replacement for reading logs because details can vary depending on eager loading, limits, grouping, selected columns, and adapter behavior. But it gives you the debugging reflex: do not ask &#8220;did I write a query?&#8221; Ask &#8220;which method forced this relation to answer?&#8221;</p><p><code>first</code>, <code>take</code> and <code>find_by</code> are worth separating because they look similar in how they fetch one record, but they do not carry the same ordering meaning. <code>first</code> uses an existing order, or falls back to primary-key order if none is defined. <code>take</code> does not imply application-level ordering. <code>find_by</code> adds conditions, then returns one matching row.</p><p><code>count</code>, <code>size,</code> and <code>length</code> show the same problem from the other direction. They look interchangeable in Ruby, but they are not interchangeable in Active Record.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">relation = Invoice.where(status: "overdue")

relation.count
# SELECT COUNT(*) FROM "invoices" WHERE "invoices"."status" = 'overdue'

relation.loaded?
# =&gt; false

relation.size
# SELECT COUNT(*) FROM "invoices" WHERE "invoices"."status" = 'overdue'
# because the relation is still unloaded

relation.length
# SELECT "invoices".* FROM "invoices" WHERE "invoices"."status" = 'overdue'
# then instantiate the records

relation.loaded?
# =&gt; true

relation.size
# now uses the loaded records</code></pre></div><p>The names are ordinary Ruby names, but the behavior is persistence-boundary behavior.</p><p><code>count</code> asks the database for a number. <code>length</code> needs a loaded Ruby collection. <code>size</code> adapts to the relation&#8217;s loaded state.</p><h2>Some Queries Return Answers Without Loading the Relation</h2><p>Not every execution turns the relation into loaded model objects.</p><p>This is important because otherwise the debugging model becomes too blunt. &#8220;Lazy versus loaded&#8221; is not the only difference. Sometimes Rails executes SQL and still does not load the relation&#8217;s record array.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">relation = Invoice.where(status: "overdue")

relation.exists?
# SELECT 1 AS one FROM "invoices"
# WHERE "invoices"."status" = 'overdue'
# LIMIT 1

relation.loaded?
# =&gt; false</code></pre></div><p>The database was queried, but the relation is still not loaded.</p><p>The same idea applies to calculations and value queries:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">relation.count
relation.sum(:total_cents)
relation.pluck(:id)</code></pre></div><p>These execute SQL, but they do not populate the relation&#8217;s loaded records. A later iteration can still issue another query for records.</p><p>This is a common log pattern:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;f53eff1a-eff8-4aa0-9e27-146d4ee86543&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoices = Invoice.where(status: "overdue")

Rails.logger.info("Overdue invoice count: #{invoices.count}")

invoices.each do |invoice|
  InvoiceMailer.reminder(invoice).deliver_later
end</code></pre></div><p>The count did not warm the relation:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Invoice Count
  SELECT COUNT(*) FROM "invoices"
  WHERE "invoices"."status" = 'overdue'

Invoice Load
  SELECT "invoices".*
  FROM "invoices"
  WHERE "invoices"."status" = 'overdue'</code></pre></div><p>That may be perfectly acceptable. Sometimes you need both a count and the records. But if you expected one query, the logs look like Rails is doing mysterious extra work.</p><p>If you look closely, you&#8217;ll notice that you asked two different questions.</p><p>One question was &#8220;how many rows match this relation?&#8221;</p><p>The other was &#8220;give me model objects for these rows.&#8221;</p><h2>Fix the Empty-State Bug</h2><p>Return to the original controller:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoices = current_account.invoices
  .where(status: "overdue")
  .order(due_at: :asc)

if invoices.present?
  @invoices = invoices.limit(50)
else
  flash.now[:notice] = "No overdue invoices."
end</code></pre></div><p>There are a few better versions, depending on what the action really needs.</p><p>If the page is going to render the first fifty records anyway, <a href="https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-load">load</a> the page-shaped relation and ask the loaded page whether it is empty:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">@invoices = current_account.invoices
  .where(status: "overdue")
  .order(due_at: :asc)
  .limit(50)
  .load

if @invoices.empty?
  flash.now[:notice] = "No overdue invoices."
end</code></pre></div><p>If the branch only needs to know whether anything exists, and you are not about to render the records, ask an existence question:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoices = current_account.invoices.where(status: "overdue")

if invoices.exists?
  # show a link, schedule work, or continue
else
  flash.now[:notice] = "No overdue invoices."
end</code></pre></div><p>If you need both existence and records, decide whether two queries are acceptable. Sometimes they are. Sometimes it is cleaner to load the limited records once and branch on the loaded result.</p><p>The important move is not merely deciding &#8220;never use <code>present?</code> on a relation&#8221;, though that is a good instinct in many code paths. The important move is naming the state you are holding:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Is this still a deferred query intent?
Did I ask the database for a scalar answer?
Did I load model objects?
Am I reusing the same loaded relation, or have I created a new one?</code></pre></div><p>Those questions turn Active Record from magic into a trace.</p><p>A shorter companion note isolates this specific trap: <a href="https://syedaslam.com/notes/present-and-the-hidden-query-boundary/">present? and the hidden boundary</a>.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">RailsRevelry is a series of deep dives into how Rails actually behaves in production. Subscribe for free to get the next article in the persistence chapter.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><h2>Why Rails Has Relations at All</h2><p><code>ActiveRecord::Relation</code> is not only a laziness trick.</p><p>It is how Rails lets application code keep describing database work until the final shape of the query is known.</p><p>Real Rails queries are rarely born complete. A controller may begin with the current account&#8217;s records. A policy may narrow them. A search object may add optional filters. A scope may add business vocabulary. Pagination may add a limit and offset. The view may need preloaded associations. Each layer contributes part of the eventual query.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">scope = current_account.invoices
scope = scope.where(status: params[:status]) if params[:status].present?
scope = scope.where("due_at &lt;= ?", params[:due_before]) if params[:due_before].present?
scope = policy_scope(scope)
scope = scope.includes(:customer)
scope = scope.order(due_at: :asc)
scope = scope.limit(50)</code></pre></div><p>If Rails query entry points immediately materialized arrays, every later step would be forced to work with already-loaded Ruby objects. Filtering might happen in memory. Pagination might happen after too many rows had crossed the database boundary. Authorization scopes would have less room to become SQL. Preloading decisions would arrive after the first query had already run.</p><p>The relation is the object that keeps those choices open.</p><p>It lets Rails say:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">not yet
not yet
not yet
now run this final query</code></pre></div><p>That &#8220;not yet&#8221; is what lets scopes compose:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Invoice.overdue.billable.order(:due_at).limit(50)</code></pre></div><p>It is what lets associations behave like query entry points:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices.overdue.limit(50)</code></pre></div><p>And it is what lets the same base intent produce different final queries:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;f866d70b-d128-4632-b380-09db7df24c1b&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">overdue = current_account.invoices.where(status: "overdue")

overdue.count
overdue.limit(50)
overdue.pluck(:id)</code></pre></div><p>Those are not three ways of reading the same loaded array. They are three different questions built from the same deferred intent.</p><p>A relation lets Rails delay execution because the application has not finished describing the work yet.</p><h2>Query Method Accumulate Intent</h2><p>Methods like <code>where</code>, <code>order</code>, <code>limit</code>, <code>select</code>, <code>joins</code>, and named scopes usually keep you in relation-building territory.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">base = Invoice.where(status: "overdue")
page = base.order(:due_at).limit(50)
csv  = base.order(:id).select(:id, :number, :total_cents)</code></pre></div><p>These are three different relation objects. They share parts of the same intent, but each one has its own query shape.</p><p>The important detail is that chaining does not mutate one loaded collection in place. Active Record relations are composable query objects. A later method usually returns another relation with additional or changed query values.</p><p>That is why this code does not load once and then slice in memory:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">invoices = Invoice.where(status: "overdue")
page = invoices.limit(50)</code></pre></div><p>The <code>limit</code> belongs to the query plan for <code>page</code>. It is not applied to a Ruby array unless <code>invoices</code> has already been loaded and you explicitly start working with arrays.</p><h2>Passing a Relation Means Passing Future Work</h2><p>That composability is useful, but it has an edge: a relation can travel through your application before anyone notices it still represents future database work.</p><p>For example, a search object might return a relation instead of records:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class InvoiceSearch
  def initialize(account:, params:)
    @account = account
    @params = params
  end

  def relation
    scope = @account.invoices
    scope = scope.where(status: @params[:status]) if @params[:status].present?
    scope = scope.where("due_at &lt;= ?", @params[:due_before]) if @params[:due_before].present?
    scope.order(due_at: :asc)
  end
end</code></pre></div><p>This object has not hidden database I/O itself. It has hidden query construction, which is a much safer thing to hide.</p><p>That is often a good design. It lets the controller decide the final shape:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;4fb8b0f2-658b-41b8-8e57-1a5cc3c7c5bc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">relation = InvoiceSearch.new(account: current_account, params: params).relation

@invoices = relation.limit(50)</code></pre></div><p>But the risk remains. If a policy, serializer, logging statement, helper, or view calls an execution method before the controller applies pagination or preloading, the query may run with the wrong shape.</p><p>A relation is a transportable promise of future database work. Passing a relation around is not passing data around. It is passing a capability to perform database work later.</p><p>This becomes especially important once associations enter the picture, because associations often make deferred query intent look like ordinary object navigation:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;ddea307a-66e6-4b90-a68f-6e77da5a188d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">account.invoices</code></pre></div><p>That expression feels like &#8220;the account&#8217;s invoices.&#8221; In many cases, it is really &#8220;a query interface scoped to this account&#8217;s invoices.&#8221; Read lazily once per record across a whole collection, that interface is also <a href="https://railsrevelry.substack.com/p/n-plus-one-queries-lazy-loading?r=4jsb">where N+1 queries come from</a>.</p><h2>The Console Can Add Noise</h2><p>The Rails console is helpful, but it can teach this topic badly if you trust what appears after pressing enter.</p><p>When the console prints a relation, it calls inspection methods so you can see something useful. Rails&#8217; relation inspection may execute a limited query for display. That does not mean <code>where</code> itself eagerly fetched records. It means the console asked the relation to show itself.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;cff421c3-dcfc-4917-8b4c-03c2f0945afb&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Invoice.where(status: "overdue")</code></pre></div><p>If the console immediately prints sample invoices, the display step crossed an execution edge.</p><p>When you are checking laziness, assign the relation and ask precise questions:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;0c42a5e5-3130-48d3-9271-e7aa5b11fad1&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">relation = Invoice.where(status: "overdue")
relation.loaded?
relation.to_sql</code></pre></div><p>Then trigger the edge deliberately:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;df93f5cd-28b9-46f5-b711-8813dd92e9ba&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">relation.load
relation.loaded?</code></pre></div><p>That keeps the console from becoming part of the behavior you are trying to understand.</p><h2>Where This Lives in Rails</h2><p>The main object here is <a href="https://api.rubyonrails.org/classes/ActiveRecord/Relation.html">ActiveRecord::Relation</a>.</p><p>A relation carries the model, table, predicate builder, and query values that Rails needs to build SQL. In Rails&#8217; initialization path, a new relation starts out unloaded. The relation can accumulate clauses for a while before records exist.</p><p><a href="https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html">ActiveRecord::QueryMethods</a> provides much of the chainable query API: <code>where</code>, <code>order</code>, <code>limit</code>, <code>select</code>, <code>joins</code>, <code>includes</code>, and the rest of the vocabulary Rails developers use every day. These methods generally return relations, not arrays.</p><p><a href="https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-to_sql">Relation#to_sql</a> compiles the relation into an SQL string. It is a way to inspect the generated statement.</p><p><a href="https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-load">Relation#load</a> is one of the places where the boundary becomes explicit. If the relation is not loaded, Rails executes the query, stores the resulting records on that relation, and returns the relation itself.</p><p>Under that call sits the machinery that turns relation intent into database work: Arel represents the query structure, the adapter compiles and executes SQL appropriate for the database, rows return from the database, and Active Record instantiates model objects from those rows.</p><p>You do not need to think about Arel every time you write a scope. But it is useful to know that <code>where(status: &#8220;overdue&#8221;)</code> is not a string being glued onto a future SQL statement. Rails is building a query representation that the adapter can later compile.</p><p>These three tell you what Rails plans to run, whether this relation has records, and when you intentionally cross the boundary:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">relation.to_sql
relation.loaded?
relation.load</code></pre></div><h2>The Debugging Reflex</h2><p>When a request performs unexpected database work, do not start by asking whether Active Record is slow.</p><p>Start with the execution edge.</p><p>A practical trace looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">relation = current_account.invoices.where(status: "overdue")

Rails.logger.info(relation.to_sql)
Rails.logger.info("loaded before? #{relation.loaded?}")

page = relation.limit(50).load

Rails.logger.info("loaded after? #{page.loaded?}")</code></pre></div><p>Then compare that trace with the SQL logs.</p><p>If a query appears earlier than expected, search for collection-like calls:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;db33d6dd-dd8d-486b-a7dd-3fda1731ed28&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">each
map
to_a
as_json
blank?
present?
empty?
length
first
take
find_by
count
exists?
pluck</code></pre></div><p>Some of those load records. Some execute scalar queries. Some instantiate model objects. Some do not. The point is to stop treating them as equivalent Ruby collection methods once the receiver is an <code>ActiveRecord::Relation</code>.</p><p>The useful debugging question is:</p><blockquote><p>Am I still holding a deferred query intent, or did something already force database work?</p></blockquote><p>Once the query runs, Rails crosses into the next state. It has rows from the database and turns them into model instances. Those objects feel like rows, but they are not rows either. They are Ruby snapshots of database-backed state, loaded at a particular moment, capable of drifting away from the database as soon as time and other writers move on.</p><p>That is the next persistence-boundary mistake: once records are loaded, the confusion moves from query intent to object state.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading RailsRevelry. Subscribe for free to get the next deep dive on Rails internals, state, and persistence.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Start Here: A Map of RailsRevelry]]></title><description><![CDATA[The chapters, reading paths, and Rails boundaries this publication is tracing.]]></description><link>https://railsrevelry.substack.com/p/a-map-of-railsrevelry</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/a-map-of-railsrevelry</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Fri, 26 Jun 2026 13:20:45 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!VQC-!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa9c2d72d-eaf1-46bc-bf53-fe489f91c036_1254x1254.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>RailsRevelry is for Rails developers who can already build applications but want a clearer mental model of what the framework is doing under the hood.</p><p>This page is the map.</p><p>The publication is organized as a sequence of chapters. Each chapter follows one boundary in Rails far enough to explain where its behavior comes from, which objects own it, and what to inspect when the abstraction starts leaking in production.</p><p>You do not need to read every article in order. Each one answers a specific question and stands on its own. But the sequence matters: later chapters build on distinctions established earlier.</p><h2>Chapter 1: The Request Boundary</h2><p>A Rails request does not jump from a URL into a controller action.</p><p>It passes through Rack, middleware, routing, controller dispatch, parameter assembly, callbacks, response construction, and view lookup. Chapter 1 follows that path from the outside in.</p><p>Its debugging reflex is:</p><blockquote><p>Which request layer owns this behavior?</p></blockquote><ol><li><p><a href="https://railsrevelry.substack.com/p/from-rack-to-controller-understanding">From Rack to Controller: Understanding the Rails Request Lifecycle</a></p></li><li><p><a href="https://railsrevelry.substack.com/p/inside-the-rails-middleware-stack">Inside the Rails Middleware Stack</a></p></li><li><p><a href="https://railsrevelry.substack.com/p/how-rails-routing-works-turning-urls">How Rails Routing Works: Turning URLs into Controller Actions</a></p></li><li><p><a href="https://railsrevelry.substack.com/p/how-rails-dispatches-a-request-to">How Rails Dispatches a Request to a Controller</a></p></li><li><p><a href="https://railsrevelry.substack.com/p/how-rails-builds-params">How Rails Builds </a><code>params</code><a href="https://railsrevelry.substack.com/p/how-rails-builds-params"> Before Your Action Runs</a></p></li><li><p><a href="https://railsrevelry.substack.com/p/why-rails-runs-code-before-your-controller">Why Rails Runs Code Before Your Controller Action</a></p></li><li><p><a href="https://railsrevelry.substack.com/p/how-rails-turns-a-controller-action-into-response">How Rails Turns a Controller Action Into a Response</a></p></li><li><p><a href="https://railsrevelry.substack.com/p/how-rails-finds-the-view-to-render">How Rails Finds the View to Render</a></p></li></ol><h2>Chapter 2: State, Identity, and the Shape Between Requests</h2><p>HTTP requests are independent, but Rails applications need login state, redirect messages, and authenticated identity to feel continuous.</p><p>Chapter 2 examines what actually crosses the request boundary, what Rails reconstructs for each request, and what must be cleared before an execution context is reused.</p><p>Its debugging reflex is:</p><blockquote><p>What survived the request boundary, and where was identity reconstructed?</p></blockquote><ol start="9"><li><p><a href="https://railsrevelry.substack.com/p/sessions-are-not-server-memory">Sessions Are Not Server Memory</a></p></li><li><p><a href="https://railsrevelry.substack.com/p/how-rails-flash-survives-one-redirect">How Rails Flash Survives One Redirect</a></p></li><li><p><a href="https://railsrevelry.substack.com/p/where-does-current-user-actually-live">Where Does </a><code>current_user</code><a href="https://railsrevelry.substack.com/p/where-does-current-user-actually-live"> Actually Live?</a></p></li></ol><h2>Chapter 3: The Persistence Boundary</h2><p>Chapter 3 moves to the boundary between Ruby intent and database truth.</p><p>Active Record makes query construction, object navigation, in-memory changes, and persistent writes feel like one continuous object-oriented interface. They are not a single state, and they do not offer the same guarantees.</p><p>In chapter 3, we trace deferred queries, model snapshots, associations, N+1 queries, dirty tracking, saves, transactions, commit callbacks, direct persistence APIs, and database constraints.</p><p>Its debugging reflex is:</p><blockquote><p>Am I examining a deferred query, a Ruby object, an uncommitted transaction, or a durable database state?</p></blockquote><ol start="12"><li><p><a href="https://railsrevelry.substack.com/p/when-does-an-active-record-query-actually-run?r=4jsb">When Does an Active Record Query Actually Run?</a></p></li></ol><ol start="13"><li><p><a href="https://railsrevelry.substack.com/p/an-active-record-object-is-a-snapshot-not-the-row?r=4jsb">An Active Record Object Is a Snapshot, Not the Row</a></p></li><li><p><a href="https://railsrevelry.substack.com/p/associations-are-query-interfaces">Associations Are Query Interfaces, Not Object Properties</a></p></li><li><p><a href="https://open.substack.com/pub/railsrevelry/p/n-plus-one-queries-lazy-loading?r=4jsb">Why N+1 Queries Are a Natural Result of Lazy Loading</a></p></li><li><p><a href="https://railsrevelry.substack.com/p/how-rails-knows-what-changed?r=4jsb">How Rails Knows What Changed</a></p></li><li><p><a href="https://railsrevelry.substack.com/p/what-happens-when-you-call-save">What Happens When You Call save</a></p></li></ol><h2>Where Should You Begin?</h2><p>If you want the full path, begin with the <a href="https://railsrevelry.substack.com/p/from-rack-to-controller-understanding">Rails request lifecycle</a> and read forward.</p><p>If you want one representative article first, read <a href="https://railsrevelry.substack.com/p/how-rails-routing-works-turning-urls">How Rails Routing Works</a>. Routing is where an incoming HTTP request first becomes recognizable as Rails application behavior, and the article captures the publication&#8217;s mix of execution flow, implementation detail, and practical debugging.</p><p>If your current problem is request-to-request state or authentication, begin with <a href="https://railsrevelry.substack.com/p/sessions-are-not-server-memory">Sessions Are Not Server Memory</a> or <a href="https://railsrevelry.substack.com/p/where-does-current-user-actually-live">Where Does </a><code>current_user</code><a href="https://railsrevelry.substack.com/p/where-does-current-user-actually-live"> Actually Live?</a>.</p><p>RailsRevelry is not about memorizing framework source code.</p><p>It is about learning to locate behavior: which layer owns it, which state is real, and which boundary your debugging has crossed.</p>]]></content:encoded></item><item><title><![CDATA[Where Does current_user Actually Live?]]></title><description><![CDATA[The request-local life of identity across Warden, Devise, CurrentAttributes, and the Rails Executor.]]></description><link>https://railsrevelry.substack.com/p/where-does-current-user-actually-live</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/where-does-current-user-actually-live</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 21 Jun 2026 03:30:30 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/fc33414e-46e7-4a33-91fe-b8a8888addaa_1600x900.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most Rails applications eventually place a surprising amount of trust in a single method call.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">current_user</code></pre></div><p>Controllers branch on it, views render navigation from it, authorization policies depend on it, audit trails often assume it, and service objects sometimes reach for <code>Current.user</code> as if authenticated identity were ambient process state.</p><p>The method reads like a global lookup, but a threaded Rails server cannot afford global identity. Puma may run many requests in the same process at the same time. A thread that handled one user&#8217;s request a moment ago may handle someone else&#8217;s request next. If <code>current_user</code> were merely &#8220;the user stored somewhere nearby&#8221;, Rails applications would leak identity across requests under ordinary production traffic.</p><p>The useful question is not only where the helper is defined, but also which object owns the authenticated identity at each layer of execution, what survives between requests, what is reconstructed for each request, and what gets cleared before the execution context is reused.</p><p>In a Devise-backed application, the chain looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">between requests
  the session carries a serialized authentication key

inside Rack
  env["warden"] points to a request-local Warden::Proxy

inside Warden
  the proxy deserializes and memoizes users by scope

inside Action Controller
  Devise exposes current_user and memoizes it on the controller

inside application code
  Current may mirror selected request-wide attributes

after execution
  executor completion callbacks clear CurrentAttributes</code></pre></div><h2>The Common Misread</h2><p>The comfortable mental model is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">current_user
# =&gt; the logged-in user</code></pre></div><p>That is useful shorthand for day-to-day controller code, but it hides the parts of the system that matter for concurrency. A more precise model should be:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;ad086206-d5df-467b-82a7-6cfa60cf8948&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">current_user
# =&gt; the authenticated user for this request and scope,
#    reconstructed from request/session state,
#    cached along the request path,
#    and unavailable once the request boundary is gone</code></pre></div><p>The word &#8220;current&#8221; does not mean &#8220;current&#8221; in the Ruby process. It means current to an execution context, usually the HTTP request currently being handled by one controller instance, and the boundary becomes visible as soon as code leaves the request path:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ExportsController &lt; ApplicationController
  def create
    ExportReportJob.perform_later(params[:report_id])

    redirect_to exports_path, notice: "Export started"
  end
end</code></pre></div><p>If the job reaches back into request-local identity, the code has smuggled a controller assumption into a process that has no browser request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ExportReportJob &lt; ApplicationJob
  def perform(report_id)
    report = Current.user.reports.find(report_id)

    report.generate!
  end
end</code></pre></div><p>Inline job execution in tests can mask this bug because the job may run before the request context has been torn down. A real queue worker has no controller instance, no request env, no browser cookie, and no authentication callback that just executed. The durable value crossing that boundary should be a small identifier, and the worker should resolve authorization again where the work runs.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ExportsController &lt; ApplicationController
  def create
    ExportReportJob.perform_later(params[:report_id], current_user.id)

    redirect_to exports_path, notice: "Export started"
  end
end

class ExportReportJob &lt; ApplicationJob
  def perform(report_id, user_id)
    user = User.find(user_id)
    report = user.reports.find(report_id)

    ExportReport.call(report:, actor: user)
  end
end</code></pre></div><p>The job starts a new execution with explicit inputs rather than continuing the request, using the same boundary pattern Rails uses internally: carry a compact identity pointer across the boundary, then resolve the live record in the context where the work happens.</p><h2>The Ownership Chain</h2><p>Let&#8217;s start with a request path before looking at any helper method:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Browser
  |
  |  Cookie: _app_session=...
  v

Rack env
  |
  |  HTTP headers, path, method, body, cookies
  v

ActionDispatch session middleware
  |
  |  loads session data into env["rack.session"]
  v

Warden::Manager middleware
  |
  |  env["warden"] = Warden::Proxy.new(env, manager)
  |  no user object is loaded just because the proxy exists
  v

Rails router and controller dispatch
  |
  |  before_action :authenticate_user!
  |  current_user -&gt; warden.authenticate(scope: :user)
  v

Warden::Proxy
  |
  |  checks its per-request @users cache
  |  fetches "warden.user.user.key" from the Rack session when needed
  |  deserializes the key into a User record
  v

ActionController instance
  |
  |  Devise helper memoizes @current_user for this controller instance
  |  helper_method exposes the same controller method to views
  v

Current, if the app uses it
  |
  |  Current.user = current_user
  |  Rails isolated execution state stores the request context
  v

Rails Executor completion
  |
  |  executor completion callbacks clear CurrentAttributes
  |  Rails execution context is unwound
  v

The thread returns to the pool without carrying this request's Current state.</code></pre></div><p>Some requests will not traverse this entire path. In many production deployments, a CDN, reverse proxy, web server, or Rack server can serve static files before Rails is involved. In development, test, or application-served file paths, more requests may pass through the Rails stack. The architecture to keep in mind is not &#8220;every asset request loads a user&#8221;; it is &#8220;Warden installs a request-local proxy lazily, and user materialization only happens when application code asks for identity&#8221;, which is why the <code>env[&#8220;warden&#8221;]</code> proxy can exist on a request without making a database query.</p><h2>The Session Carries a Pointer</h2><p>A basic custom authentication flow shows the same lifecycle without involving Devise:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class SessionsController &lt; ApplicationController
  def create
    if user = User.authenticate_by(params.permit(:email_address, :password))
      reset_session
      session[:user_id] = user.id

      redirect_to dashboard_path
    else
      redirect_to new_session_path, alert: "Try another email or password."
    end
  end
end</code></pre></div><p>The session stores this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">session[:user_id] = user.id</code></pre></div><p>It does not store this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">session[:user] = user</code></pre></div><p><a href="https://railsrevelry.substack.com/p/sessions-are-not-server-memory">We traced the default CookieStore pipeline</a> in detail earlier; here, the important part is the object boundary: the browser does not bring a Ruby object back to Rails. It brings headers and cookies. Rails loads session data from those cookies or from whatever session store the application uses. The application then decides whether the identity pointer still resolves to a valid server-side user.</p><p>A small hand-rolled helper makes that lifecycle visible:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ApplicationController &lt; ActionController::Base
  helper_method :current_user

  private

    def current_user
      return @current_user if defined?(@current_user)

      @current_user = User.active.find_by(id: session[:user_id])
    end
end</code></pre></div><p>This method performs three separate jobs:</p><ol><li><p>read the identity pointer from the session</p></li><li><p>query the current server-side record</p></li><li><p>memoize the result on this controller instance</p></li></ol><p>The <code>defined?(@current_user)</code> guard is deliberate. It caches <code>nil</code> as well as a user record, so an authenticated request does not repeat the same lookup every time a layout, partial, or policy asks for the user.</p><p>Every request repeats the reconstruction, and that repetition is not a wasteful ceremony. It is where the browser&#8217;s claim meets the database&#8217;s current truth: a user may have been deleted, locked, disabled, removed from an account, or forced through a password reset since the cookie was issued. The request boundary is where stale identity is either accepted, rejected, or downgraded.</p><p>The durable value is <code>session[:user_id]</code>, while the request-local value is <code>@current_user</code>, and the authentication code becomes hard to reason about when those two lifetimes are treated as the same kind of state.</p><h2>What Devise Adds</h2><p>Devise keeps the same lifecycle, but delegates most of the Rack-level work to Warden.</p><p>Warden is Rack middleware whose manager receives the Rack env, creates a proxy for the request, and stores it where downstream code can find it.<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-1" href="#footnote-1" target="_self">1</a></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby"># Conceptual shape of Warden::Manager
def call(env)
  env["warden"] = Warden::Proxy.new(env, self)

  @app.call(env)
end</code></pre></div><p>That proxy is stateful, but only for this request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby"># Conceptual shape of Warden::Proxy initialization
def initialize(env, manager)
  @env = env
  @manager = manager
  @users = {}
end</code></pre></div><p>The proxy has access to the Rack session through <code>env[&#8220;rack.session&#8221;]</code>. Warden&#8217;s session serializer stores authentication data under a scope-specific key.<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-2" href="#footnote-2" target="_self">2</a></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def key_for(scope)
  "warden.user.#{scope}.key"
end</code></pre></div><p>For the default Devise user scope, the session key is: </p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;8c173964-1b33-47c0-b98e-9433b913374e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">warden.user.user.key</code></pre></div><p>The value stored there is a compact serialized authentication key, not the Active Record object. Devise&#8217;s serializer can later turn that key back into a model record, and the exact serialized shape depends on its mapping and serializer configuration.</p><p>When the application code asks Warden for a user, Warden first checks the proxy&#8217;s per-request cache. If nothing has been loaded for that scope, it fetches from the session serializer and memoizes the deserialized user on the proxy.<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-3" href="#footnote-3" target="_self">3</a></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby"># Simplified from Warden::Proxy#user
def user(scope)
  return @users[scope] if @users.key?(scope)

  if user = session_serializer.fetch(scope)
    @users[scope] = set_user(user, scope: scope, event: :fetch)
  end
end</code></pre></div><p>Authentication methods such as <code>authenticate</code> and <code>authenticate!</code> can go further: if a session-backed user is not already available, they may run configured strategies and trigger failure behavior. In middleware and telemetry code, the difference between <code>user</code> as an identity fetch and <code>authenticate!</code> as an authentication operation is not cosmetic.</p><p>Devise exposes this through controller helpers. The generated helper roughly looks like this. <a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-4" href="#footnote-4" target="_self">4</a></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def warden
  request.env["warden"] || raise Devise::MissingWarden
end

def current_user
  @current_user ||= warden.authenticate(scope: :user)
end</code></pre></div><p>There are two caches in play after the first successful lookup:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Warden::Proxy @users[:user]
  per-request Rack authentication cache

ApplicationController @current_user
  per-controller-instance helper cache</code></pre></div><p>Neither cache is the login or survives the request; both are short-lived Ruby objects built from request-backed state.</p><h2>The View Gets a Bridge</h2><p><code>current_user</code> feels more ambient than it is because views can call it.</p><p>In a custom implementation, the bridge is usually explicit:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">helper_method :current_user</code></pre></div><p>Rails&#8217; <a href="https://api.rubyonrails.org/classes/AbstractController/Helpers/ClassMethods.html#method-i-helper_method">helper_method</a> exposes selected controller methods to the view context. Devise registers its mapping helpers in the same spirit, so this layout code:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">&lt;% if current_user %&gt;
  &lt;%= link_to current_user.email_address, account_path %&gt;
&lt;% end %&gt;</code></pre></div><p>is still calling a controller helper for the current request. The view has gained a method bridge back into the controller/request context, not access to global authentication state.</p><p>That is why the same method is not available in jobs, mailers invoked outside a request, raw model code, concise sessions, and arbitrary threads. Those execution contexts do not have the controller helper bridge unless the application explicitly builds one.</p><h2><code>Current</code> Is a Mirror, Not the Source</h2><p>At some point, a mature Rails codebase usually wants identity deeper than controllers: audit logs need an actor, multi-tenant models need an account, service objects need request metadata, and passing five arguments through every call can become noisy. Rails provides <a href="https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html">ActiveSupport::CurrentAttributes</a> for that narrow category of request-wide state.</p><p>A robust <code>Current</code> class should be small and boring:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby"># app/models/current.rb
class Current &lt; ActiveSupport::CurrentAttributes
  attribute :user, :account
  attribute :request_id, :ip_address, :user_agent

  resets { Time.zone = nil }

  def user=(user)
    super
    Time.zone = user&amp;.time_zone
  end
end</code></pre></div><p>Then a controller explicitly bridges authenticated identity into request-wide application context:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ApplicationController &lt; ActionController::Base
  before_action :authenticate_user!
  before_action :set_current_context

  private

    def set_current_context
      Current.user = current_user
      Current.request_id = request.uuid
      Current.ip_address = request.ip
      Current.user_agent = request.user_agent
    end
end</code></pre></div><p><code>Current.user = current_user</code> doesn&#8217;t explain where <code>current_user</code> came from. It copies the already-authenticated request user into Rails&#8217; current execution context. If the authentication layer does not run, or the controller does not perform the bridge, <code>Current.user</code> is not populated by virtue of the class existing.</p><p>The Rails API documentation for <code>CurrentAttributes</code> is intentionally cautious: <code>Current</code> should hold only a few top-level globals, such as account, user, and request details, that are used across most actions. Controller-specific state does not belong there because it turns ordinary method calls into hidden dependencies on request context.</p><p>In practice, <code>Current</code> is good for values like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;7c4b25a9-f176-4abf-b8bf-5575e1f52ede&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Current.user
Current.account
Current.request_id
Current.ip_address
Current.user_agent</code></pre></div><p>It is a poor home for values like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;43db22bd-098e-4e0a-965d-a0fa4e4e7641&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Current.invoice
Current.search_query
Current.checkout_step
Current.feature_flag_override_for_this_one_action</code></pre></div><p>Those may be legitimate values, but they are not part of the application-wide request context. A useful test: if the value only makes sense for one controller action, it probably doesn&#8217;t belong in <code>Current</code>. It belongs in arguments, query objects, form objects, policy context, or explicit service input.</p><h2>Isolation and Teardown</h2><p>The old version of this pattern was usually something like <code>Thread.current[:user] = current_user</code>. It works until it doesn&#8217;t, and the failure mode is ugly because web server threads are pooled. If a thread finishes one request and the application forgets to clear the thread local, the next request handled by that thread can observe stale identity.</p><p><code>CurrentAttributes</code> gives a safer primitive, but the important property is lifecycle ownership, not the class name.</p><p>Rails describes <code>CurrentAttributes</code> as a thread-isolated attributes singleton that resets automatically before and after each request. In current Rails, the implementation goes through <code>ActiveSupport::IsolatedExecutionState</code>, which supports both <code>:thread</code> and <code>:fiber</code> isolation levels and defaults to <code>:thread</code> unless the application configures a different isolation level.<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-5" href="#footnote-5" target="_self">5</a></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">config.active_support.isolation_level = :fiber</code></pre></div><p>The precise idea is not &#8220;Current is Fiber-local&#8221; for every modern Rails application, though. The safer idea is that <code>Current</code> stores attributes in Rails&#8217; isolated execution state, whose locality is configurable, and Rails clears that state through executor lifecycle hooks at the application boundary.</p><p>The Rails Executor is the wrapper around application code. The <a href="https://guides.rubyonrails.org/threading_and_code_execution.html">Rails Threading and Code Execution guide</a> describes it as the boundary between framework code and your own code, with <code>to_run</code> callbacks before application execution and <code>to_complete</code> callbacks afterward. In current Rails, Active Support&#8217;s railtie wires execution context and current attributes into that lifecycle.<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-6" href="#footnote-6" target="_self">6</a></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;b7066f37-5455-42f3-8ae2-32eda47c17b0&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby"># Conceptual shape from ActiveSupport::Railtie
app.executor.to_run do
  ActiveSupport::ExecutionContext.push
end

app.executor.to_complete do
  ActiveSupport::CurrentAttributes.clear_all
  ActiveSupport::ExecutionContext.pop
end</code></pre></div><p>Rails wraps ordinary web requests for you, and framework-managed job execution is also run through Rails execution wrappers. The place that hurts is custom concurrency:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Thread.new do
  do_work_that_touches_models_and_current
end</code></pre></div><p>The Rails guide is explicit about manual threads and Concurrent Ruby thread pools: wrap application code with the executor as soon as the thread begins running application work.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;496e5fb6-5246-414a-aa43-2c4b7fa92c32&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Thread.new do
  Rails.application.executor.wrap do
    do_work_that_touches_models_and_current
  end
end</code></pre></div><p>That wrapper is not only about <code>Current.user</code>. It also protects query cache lifetime, connection pool handling, autoload/reload safety, execution context, and other framework-owned state that should not bleed across application executions.</p><h2>Failure Mode: <code>Current.user</code> as Invisible Job Input</h2><p>This is the most common production bug because the code can look clean:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;1626a2f1-c06b-4a4b-8d8e-3ed5d5cc7623&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ExportReport
  def self.call(report_id)
    report = Current.user.reports.find(report_id)
    report.generate!
  end
end</code></pre></div><p>The controller path works:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;88cd3038-46a6-4ddb-a099-a46591e07c80&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">ExportReport.call(params[:report_id])</code></pre></div><p>Then a background job reuses the same service:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;dc3a5353-d657-4c9c-8d75-bba3f135f41e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ExportReportJob &lt; ApplicationJob
  def perform(report_id)
    ExportReport.call(report_id)
  end
end</code></pre></div><p>The service has hidden its real dependency. It needs an actor, but the method signature says it only needs a report ID. Inline execution, request specs, and happy-path manual tests may all pass because <code>Current.user</code> happens to be set by the surrounding request. A real worker exposes the truth: the service depends on the request-local state that the job doesn&#8217;t have.</p><p>The stronger interface makes the actor explicit:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;0e6ff4f7-d52a-42fc-9b38-7ff065613b19&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ExportReport
  def self.call(report_id, actor:)
    report = actor.reports.find(report_id)
    report.generate!
  end
end

class ExportReportJob &lt; ApplicationJob
  def perform(report_id, user_id)
    actor = User.find(user_id)

    ExportReport.call(report_id, actor:)
  end
end</code></pre></div><p>The lookup is also the authorization boundary: the job finds the report through the actor, not through a global report ID.</p><p>If a legacy subsystem genuinely expects <code>Current</code>, set it in the narrowest possible block rather than letting it become invisible job state:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;566296fe-6452-43da-91e1-62c956f0234a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ExportReportJob &lt; ApplicationJob
  def perform(report_id, user_id)
    actor = User.find(user_id)

    Current.set(user: actor) do
      ExportReport.call(report_id)
    end
  end
end</code></pre></div><p>The block form shows the boundary and also gives Rails scope for restoring previous Current values.</p><h2>Failure Mode: Tenant Context Derived From the Wrong Layer</h2><p>Rails&#8217; own <code>CurrentAttributes</code> example shows a <code>user=</code> setter that assigns <code>account</code> from <code>user.account</code>. That is reasonable for applications where every user has exactly one account and identity implies tenancy.</p><p>It becomes wrong in applications with account switching, delegated access, organization membership, impersonation, or admin consoles.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;e4f75c74-c661-46ef-8e33-8e899f7646e3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class Current &lt; ActiveSupport::CurrentAttributes
  attribute :user, :account

  def user=(user)
    super
    self.account = user.account
  end
end</code></pre></div><p>The bug is not that <code>Current.account</code> exists; the bug is encoding a product assumption into the identity setter. In a multi-account system, the authenticated actor and the selected tenant are related but not identical. One answers &#8220;who is acting?&#8221; while the other answers &#8220;within which account boundary is this action authorized?&#8221;</p><p>Keep those assignments separate:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;1757aea4-1c5b-4761-a007-834431e75d2a&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ApplicationController &lt; ActionController::Base
  before_action :authenticate_user!
  before_action :set_current_context

  private

    def set_current_context
      account = current_user.accounts.find_by!(slug: params[:account_slug])

      authorize_account_access!(current_user, account)

      Current.user = current_user
      Current.account = account
    end
end</code></pre></div><p>Tenant selection should come from the request, route, subdomain, account switcher, or an explicit policy, and then be authorized against the actor. Deriving it blindly from <code>Current.user</code> is how a convenient default turns into a cross-tenant data bug.</p><h2>Failure Mode: Middleware Forces Identity Too Early</h2><p>Middleware is the wrong place for casual identity access because middleware operates before the controller's intent is known.</p><p>This looks harmless in telemetry, rate limiting, or request logging code:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;41f8c1dd-967d-40b8-a87e-7d74a1d38988&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class RequestTelemetry
  def initialize(app)
    @app = app
  end

  def call(env)
    user = env["warden"]&amp;.authenticate(scope: :user)

    tag_request(user_id: user&amp;.id)

    @app.call(env)
  end
end</code></pre></div><p>The problem is not only performance. <code>authenticate</code> is allowed to run strategies and invoke authentication failure behavior, so telemetry code can accidentally become authentication code. Even <code>env[&#8220;warden&#8221;].user(:user)</code> can materialize a database-backed user from the session. If this middleware runs on every request that reaches the Rails stack, the application has expanded &#8220;load the user when a controller needs it&#8221; into &#8220;load the user before routing for broad classes of requests.&#8221;</p><p>On some deployments, static files and health checks may never reach this middleware. On others, development asset requests, app-served files, engine routes, internal probes, or unauthenticated endpoints may pass through it. The exact blast radius depends on the stack. Still, the architectural smell is stable: a low-level Rack component is forcing high-level identity resolution without knowing whether the endpoint needs a user.</p><p>Prefer one of these:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;3ba1030e-4561-4de6-9435-3184f49b6f3b&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby"># Use request-level identifiers that do not materialize a User record.
request = ActionDispatch::Request.new(env)
tag_request(
  request_id: request.request_id,
  ip: request.ip
)</code></pre></div><p>or, when authenticated identity is a deliberate requirement, constrain both placement and scope:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;e8f1550b-c7bd-4b91-80b7-57c997ea5085&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def call(env)
  request = ActionDispatch::Request.new(env)

  return @app.call(env) unless request.path.start_with?("/app/")

  if warden = env["warden"]
    user = warden.user(:user) # still a materialization point
    tag_request(user_id: user&amp;.id)
  end

  @app.call(env)
end</code></pre></div><p>That code is not &#8220;free&#8221; because <code>warden.user(:user)</code> may deserialize from the session and hit the database. The improvement is that the middleware no longer invokes the full authentication flow for every request, and the path restriction makes the cost and behavior intentional.</p><h2>A Debugging Checklist</h2><p>When <code>current_user</code> behaves strangely, locate the failing code in the lifecycle before changing authentication logic.</p><ul><li><p><strong>Execution context:</strong> Is this code running inside a controller/view request, middleware, job, console, callback, or manual thread?</p></li><li><p><strong>Session layer:</strong> Did the request pass through session middleware, and is <code>env[&#8220;rack.session&#8221;]</code> available?</p></li><li><p><strong>Fetch path:</strong> Is the user coming from Warden&#8217;s per-request <code>@users</code> cache, from session deserialization, or from a strategy?</p></li><li><p><strong>Current bridge:</strong> Is <code>Current</code> being read inside an executor-wrapped application execution?</p></li><li><p><strong>Hidden dependency:</strong> Is a service object hiding actor or tenant dependencies behind <code>Current</code>?</p></li><li><p><strong>Actor vs. tenant:</strong> Are actor identity and tenant/account selection being resolved as separate concepts?</p></li></ul><p>Most confusing authentication bugs become smaller once you name the layer that owns the value and the boundary that should have cleared, copied, or reconstructed it.</p><h2>Where It Actually Lives</h2><p>Between requests, the durable authentication reference lives in the session, usually as a compact serialized key carried by a cookie-backed or server-backed session store.</p><p>During Rack execution, Devise-backed applications find the authentication interface at <code>env[&#8220;warden&#8221;]</code>, a request-local <code>Warden::Proxy</code> created by Warden middleware.</p><p>During authentication, Warden deserializes the scoped session key and memoizes the resulting user object in the proxy&#8217;s <code>@users</code> hash.</p><p>Inside the controller, Devise exposes <code>current_user</code> as a helper and memoizes it on the controller instance, commonly as <code>@current_user</code>.</p><p>Inside deeper application code, <code>Current.user</code> may hold a copy of that identity, but only if the controller or framework code explicitly placed it there for the current execution context.</p><p>After execution completes, Rails clears <code>CurrentAttributes</code> and unwinds the execution context through the Executor, so the thread- or fiber-local execution state does not become the next request&#8217;s identity.</p><p>The method feels like a single answer because Rails and Devise make the handoffs smooth. In production, it is a series of scoped owners with different lifetimes.</p><p>The debugging reflex is to treat strange <code>current_user</code> behavior as a lifecycle question, not just a helper return value question. Ask which layer reconstructed the identity, which scope cached it, which execution context copied it, and whether the code has crossed the boundary where &#8220;current&#8221; stopped meaning anything.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">RailsRevelry is a technical series on how Rails behaves in real applications. Subscribe to get notified about the next article.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-1" href="#footnote-anchor-1" class="footnote-number" contenteditable="false" target="_self">1</a><div class="footnote-content"><p>Warden&#8217;s manager middleware installs <code>env[&#8220;warden&#8221;] = Proxy.new(env, self)</code>: <a href="https://github.com/wardencommunity/warden/blob/master/lib/warden/manager.rb">Warden::Manager</a>.</p></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-2" href="#footnote-anchor-2" class="footnote-number" contenteditable="false" target="_self">2</a><div class="footnote-content"><p>Warden&#8217;s session serializer reads and writes scoped session keys such as "<code>warden.user.#{scope}.key&#8221;</code> through<code> env[&#8220;rack.session&#8221;]</code>: <a href="https://github.com/wardencommunity/warden/blob/master/lib/warden/session_serializer.rb">Warden::SessionSerializer</a>.</p></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-3" href="#footnote-anchor-3" class="footnote-number" contenteditable="false" target="_self">3</a><div class="footnote-content"><p>Warden&#8217;s proxy keeps a per-request <code>@users</code> hash, fetches session-backed users through the session serializer, and records fetched users with <code>set_user(..., event: :fetch)</code>: <a href="https://github.com/wardencommunity/warden/blob/master/lib/warden/proxy.rb">Warden::Proxy</a>.</p></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-4" href="#footnote-anchor-4" class="footnote-number" contenteditable="false" target="_self">4</a><div class="footnote-content"><p>Devise&#8217;s generated mapping helpers define <code>current_#{mapping}</code> in terms of <code>warden.authenticate(scope: ...)</code>, memoized in an instance variable: <a href="https://github.com/heartcombo/devise/blob/main/lib/devise/controllers/helpers.rb">Devise::Controllers::Helpers</a>.</p></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-5" href="#footnote-anchor-5" class="footnote-number" contenteditable="false" target="_self">5</a><div class="footnote-content"><p>Rails&#8217; isolated execution state supports thread and fiber isolation levels, with thread isolation as the default in the linked version: <a href="https://github.com/rails/rails/blob/v8.1.3/activesupport/lib/active_support/isolated_execution_state.rb">ActiveSupport::IsolatedExecutionState</a>.</p></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-6" href="#footnote-anchor-6" class="footnote-number" contenteditable="false" target="_self">6</a><div class="footnote-content"><p>Active Support&#8217;s railtie wires execution context and <code>CurrentAttributes.clear_all</code> into executor callbacks: <a href="https://github.com/rails/rails/blob/v8.1.3/activesupport/lib/active_support/railtie.rb">ActiveSupport::Railtie</a>.</p></div></div>]]></content:encoded></item><item><title><![CDATA[How Rails Flash Survives One Redirect]]></title><description><![CDATA[Rails flash messages feel like view-level notices, but they are request-to-request state. This article traces how flash, flash.now, flash.keep, and flash.discard work through the session, and why messages leak, disappear, or overflow cookies in real applications.]]></description><link>https://railsrevelry.substack.com/p/how-rails-flash-survives-one-redirect</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/how-rails-flash-survives-one-redirect</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 14 Jun 2026 03:31:05 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/46da8a2e-fc9f-4140-b623-ddbbbfa24e11_2096x1408.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The bug usually shows up as a message that refuses to die.</p><p>A form submission fails validation, so the controller renders the form again:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class BillingProfilesController &lt; ApplicationController
  def create
    @billing_profile = current_account.billing_profiles.new(billing_profile_params)

    if @billing_profile.save
      redirect_to billing_profile_path(@billing_profile), notice: "Billing profile saved"
    else
      flash[:alert] = @billing_profile.errors.full_messages.to_sentence
      render :new, status: :unprocessable_entity
    end
  end
end</code></pre></div><p>The alert appears on the rendered form, and that part looks fine.</p><p>Then the user fixes the form, submits again, and lands on the success page. The old validation message appears there too.</p><p>Nothing about the view is obviously wrong. The layout is just rendering <code>flash[:alert]</code> if one exists. The controller assigned the alert only on the failure path. The successful request did not set it.</p><p>The bug originates from an underlying assumption that <strong>flash is not a view-level message store.</strong></p><p>Flash is a request-to-request state. In a default browser-oriented Rails app, it rides through the session. It is built for the response after a redirect, not for every place you want to show a message.</p><p>That is why this line is wrong with <code>render</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">flash[:alert] = @billing_profile.errors.full_messages.to_sentence</code></pre></div><p>and this line is usually right:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">flash.now[:alert] = @billing_profile.errors.full_messages.to_sentence</code></pre></div><p>The difference is not cosmetic. One message is being prepared for a future request. The other belongs only to the current response.</p><h2>Redirects Need a Handoff</h2><p>Start with a successful branch:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">redirect_to billing_profile_path(@billing_profile), notice: "Billing profile saved."</code></pre></div><p>That code is to do two things at once:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">send the browser somewhere else
show a message after it gets there</code></pre></div><p>A redirect does not render the target page. It sends a response with a redirect status and a <code>Location</code> header. The browser then makes a second HTTP request to the new URL.</p><p>That means the success message cannot live in the controller instance. The controller instance is gone when the next request starts. It cannot live in an instance variable either:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">@notice = "Billing profile saved."
redirect_to billing_profile_path(@billing_profile)</code></pre></div><p>The next request will be handled by a new controller object, which will not know about <code>@notice</code>.</p><p>The flash exists to bridge exactly that gap.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">POST /billing_profiles
  -&gt;
set flash notice
  -&gt;
redirect response
  -&gt;
GET /billing_profiles/42
  -&gt;
Read flash notice
  -&gt;
discard it</code></pre></div><p>It is a one-request handoff.</p><h2>Flash Is Session-Backed State</h2><p>Rails exposes flash through the controller, but the flash lifecycle runs below the controller.</p><p>In <code>ActionController::Flash</code>, Rails delegates <code>flash</code> to the request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">delegate :flash, to: request</code></pre></div><p>When Rails needs the flash for a request, the flash methods mixed into the request by <a href="https://api.rubyonrails.org/classes/ActionDispatch/Flash.html">ActionDispatch::Flash</a> build a <code>FlashHash</code> from <code>session[&#8220;flash&#8221;]</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def flash
  flash = flash_hash
  return flash if flash
  self.flash = Flash::FlashHash.from_session_value(session["flash"])
end</code></pre></div><p><a href="https://railsrevelry.substack.com/p/sessions-are-not-server-memory">The previous article</a> tracked where session data goes. Flash uses that same path under a specific key:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">session["flash"]</code></pre></div><p>Flash is not a separate server memory. It is a temporary state stored through the same session machinery.</p><p>During request cleanup, Rails commits the flash back into the session.</p><p>Rails writes a session-friendly flash value into the <code>session[&#8220;flash&#8221;]</code>, and an empty flash becomes <code>nil</code>, which lets Rails delete the session key.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def commit_flash
  return unless session.enabled?

  if flash_hash &amp;&amp; (flash_hash.present? || session.key?("flash"))
    session["flash"] = flash_hash.to_session_value
    self.flash = flash_hash.dup
  end

  if session.loaded? &amp;&amp; session.key?("flash") &amp;&amp; session["flash"].nil?
    session.delete("flash")
  end
end </code></pre></div><p>That is the mechanism behind the everyday API:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">flash[:notice] = "Saved"
redirect_to account_path</code></pre></div><p>The controller writes to <code>flash</code>. Rails writes a session-friendly flash value into the <code>session[&#8220;flash&#8221;]</code> when the request finishes. The next request rebuilds a <code>FlashHash</code> from that session entry.</p><p>If the session store is CookieStore, the flash is part of the encrypted cookie payload. This is why a large flash message can trigger the same <code>CookieOverflow</code> class of failures as any other oversized session value.</p><h2>Why Flash Disappears</h2><p>The strange part of Flash is not that it survives a redirect.</p><p>The more interesting part is that it disappears afterward.</p><p>If Rails only stored the message hash:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">session["flash"] = { "notice" =&gt; "Saved" }</code></pre></div><p>Then the notice would appear forever until something removed it.</p><p>Rails needs one more bit of information: which entries are being carried into the next request, and which entries have already had their turn. </p><p>When you set a flash and redirect, Rails can serialize it like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">session["flash"] = {
  "discard" = [],
  "flashes" =&gt; { "notice" =&gt; "Saved" }
}</code></pre></div><p>The empty <code>discard</code> list tells Rails to keep the message for the next request.</p><p>On that next request, Rails rebuilds the <code>FlashHash</code> from the session:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">new(flashes, flahes.keys)</code></pre></div><p>That second argument becomes the discard set.</p><p>So the message is visible, but it is already marked for drop when the request finishes.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def create
  redirect_to dashboard_path, notice: "Welcome back"
end</code></pre></div><p>The next request can read:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">&lt;% if flash[:notice] %&gt;
  &lt;p&gt;&lt;%= flash[:notice] %&gt;&lt;/p&gt;
&lt;% end %&gt;</code></pre></div><p>When the request finishes, Rails asks the flash what should survive:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">flashes_to_keep = @flashes.except(*@discard)
return nil if flashes_to_keep.empty?</code></pre></div><p>For a normal one-redirect flash, nothing is left to keep. <code>to_session_value</code> returns <code>nil</code>, and <code>commit_flash</code> removes <code>session[&#8220;flash&#8221;]</code>.</p><p>The flash is not time-based. It does not expire after some seconds. It survives one request transition because Rails records it in the session, then declines to keep it on subsequent requests.</p><h2>Render Stays Inside the Same Request</h2><p>Now return to the original bug:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">if @billing_profile.save
  redirect_to billing_profile_path(@billing_profile), notice: "Billing profile saved"
else
  flash[:alert] = @billing_profile.errors.full_messages.to_sentence
  render :new, status: :unprocessable_entity
end</code></pre></div><p>The failure branch does not redirect.</p><p>It renders a response inside the same request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">POST /billing_profiles
  -&gt;
validation fails
  -&gt;
render :new
  -&gt;
response returns</code></pre></div><p>There is no second request that needs a handoff.</p><p>When you use <code>flash[:alert]</code>, Rails assumes you want the message available to the next request. The current response can still render it because the flash is available now, but Rails also prepares it for later.</p><p>That is why it can leak into the success page.</p><p>For a render, use <code>flash.now</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">if @billing_profile.save
  redirect_to billing_profile_path(@billing_profile), notice: "Billing profile saved"
else
  flash.now[:alert] = @billing_profile.errors.full_messages.to_sentence
  render :new, status: :unprocessable_entity
end</code></pre></div><p><code>flash.now</code> writes the message into the current flash and immediately marks it for discard:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def []=(k, v)
  k = k.to_s
  @flash[k] = v
  @flash.discard(k)
end</code></pre></div><p>The view can render the message in this response. Rails will not carry it forward.</p><p>That is the rule:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">redirect -&gt; flash
render   -&gt; flash.now</code></pre></div><p>Not because one is more correct in the abstract, but because they have different request lifetimes.</p><p>The same rule applies when the response is not a full-page render.</p><p>In a Rails 7 app, a failed form submission may return a Turbo Stream or Turbo Frame response instead of rendering the whole layout:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def create
  if @billing_profile.save
    redirect_to billing_profile_path(@billing_profile), notice: "Billing profile saved"
  else
    flash.now[:alert] = "Validation failed"
    render turbo_stream: turbo_stream.replace("billing_form", partial: "form")
  end
end</code></pre></div><p>Turbo changes how the response is delivered. It does not create a follow-up request for the flash to survive. If no redirect is involved, <code>flash.now</code> is usually the safer default.</p><h2>Keep and Discard</h2><p>Most application code does not need to call <code>flash.keep</code> or <code>flash.discard</code> directly.</p><p>But they explain the lifecycle.</p><p><code>discard</code> marks entries to be removed when the request finishes:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">flash.discard(:notice)</code></pre></div><p><code>keep</code> does the opposite. It tells Rails to carry an entry forward one more time:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">flash.keep(:notice)</code></pre></div><p>That can be useful when a request receives a flash and redirects again before the user sees the final page:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">POST /signup
  -&gt;
flash[:notice] = "Finish billing setup"
  -&gt; 
redirect_to dashboard_path

GET /dashboard
  -&gt;
notice is available here
  -&gt;
dashboard redirects again
  -&gt;
flash.keep(:notice)

GET /billing/settings
  -&gt;
The notice is still available.</code></pre></div><p>The middle action has to preserve the message deliberately:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def dashboard
  unless current_account.billing_enabled?
    flash.keep(:notice)
    redirect_to billing_settings_path
  end
end</code></pre></div><p>Without <code>keep</code>, the notice is available to <code>/dashboard</code> and then dropped. The second redirect would land on billing settings with no message.</p><p>That is the same lifecycle rule, just extended:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">loaded flash entries are on their last request
keep extends them
discard shortens them
flash.now never crosses into the next request</code></pre></div><h2>Concurrent Requests Can Consume Flash</h2><p>The clean sequence is easy to picture:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">POST
  -&gt;
redirect
  -&gt;
GET target page
  -&gt;
read flash</code></pre></div><p>Production browsers are not always that quiet.</p><p>Suppose the app has a background request polling for unread notifications. If that request hits the server after the <code>POST</code> commits the flash but before the browser finishes the redirect, it can load the same session.</p><p>If that background request loads the flash and completes its own request lifecycle first, Rails can treat the flash as having been exposed there. The target page may then arrive with no message left to show.</p><p>This is not a reason to avoid flash. It is a reason to keep API and background endpoints away from flash unless they intentionally participate in the page flow. In larger apps, that often means isolating JSON endpoints under controllers that do not use the browser session stack, or at least making sure they do not render shared layout code that reads flash.</p><p>If a request receives a flash and redirects again, use <code>flash.keep</code> deliberately.</p><h2>The Production Version</h2><p>Flash bugs often look like view bugs.</p><p>An alert appears on the wrong page. A notice disappears after an extra redirect. A huge error object overflows the session cookie.</p><p>The fix usually starts by asking a request-level question:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Is this message for the current response,
or for the response after the next request?</code></pre></div><p>If it is for the current response, use <code>flash.now</code>.</p><p>If it is for the request after a redirect, use <code>flash</code>.</p><p>If it needs to survive multiple redirects, use <code>flash.keep</code> deliberately.</p><p>In a CookieStore app, flash bugs can also show up as response-size bugs. A controller that puts a long validation dump, serialized object, or exception message into <code>flash[:alert]</code> is not just creating a noisy UI message. It is increasing the session cookie payload that must be sent back to the browser. If a response suddenly starts failing after a large form error or imported-record validation report, inspect what was written to flash before treating it as a view problem.</p><p>Server-side session stores change the failure mode, not the rule. Redis or database-backed sessions can avoid cookie overflow, but they do not make object payloads safe. You still need to consider serialization, deployment compatibility, state snapshots, and cleanup.</p><p>Do not put records, exception objects, or validation objects in the flash. Store a short string, a small array of strings, or a compact code that the view can translate.</p><p>Flash makes one redirect feel continuous. It does not create a durable server state.</p><p>That distinction raises the next question.</p><p>Some state crosses one redirect and disappears. Some state, like a user ID, can persist across requests until logout. Rails can carry the identifier, but it still has to become a <code>User</code> object somewhere.</p><p>So, where does <code>current_user</code> actually come from?</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">RailsRevelry is a technical series on how Rails behaves in real applications. Subscribe to get the next piece: Where does current_user actually come from?</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Sessions Are Not Server Memory]]></title><description><![CDATA[In a default Rails app, the session is not a server-side hash. It is serialized, encrypted, and carried by the browser as a cookie.]]></description><link>https://railsrevelry.substack.com/p/sessions-are-not-server-memory</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/sessions-are-not-server-memory</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 07 Jun 2026 03:30:33 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!VQC-!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa9c2d72d-eaf1-46bc-bf53-fe489f91c036_1254x1254.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>It usually starts with a good instinct: saving a database query.</p><p>You have a heavy controller action or a complex multi-step wizard. You need the <code>Account</code> object across multiple requests. Re-querying it on every request feels wasteful, so someone reaches for the closest thing that looks like request-to-request storage:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class AccountsController &lt; ApplicationController
  def switch
    @account = current_user.accounts.find(params[:id])

    # Memoize this so we do not have to query it on the next request, right?
    session[:current_account] = @account

    redirect_to dashboard_path
  end
end</code></pre></div><p>At the line where it is written, this looks harmless. <code>session</code> behaves like a hash. The assignment succeeds. The action redirects. Nothing about the controller tells you that you just asked Rails to serialize an Active Record object into an HTTP cookie.</p><p>In development, if the <code>@account</code> record is simple, it might even appear to work.</p><p>In production, as the application matures and that record accumulates loaded associations, it eventually blows up. The confusing part is that it doesn&#8217;t fail in your controller. It fails deep in the framework, long after your action has finished executing:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;bash&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-bash">F, [2026-06-05T14:18:42.221934 #18412] FATAL -- :
[7f4f7d18-7c91-47c9-9cb9-01ec8d2b77c2]
ActionDispatch::Cookies::CookieOverflow (_billing_session cookie overflowed with size 6128 bytes):

[7f4f7d18-7c91-47c9-9cb9-01ec8d2b77c2]
actionpack (8.1.1) lib/action_dispatch/middleware/cookies.rb:616:in `check_for_overflow!'
actionpack (8.1.1) lib/action_dispatch/middleware/cookies.rb:698:in `commit'
actionpack (8.1.1) lib/action_dispatch/middleware/session/cookie_store.rb:117:in `set_cookie'
actionpack (8.1.1) lib/action_dispatch/middleware/session/abstract_store.rb:72:in `commit_session'</code></pre></div><p>The stack trace points at <code>commit_session</code>, not <code>AccountsController#switch</code>.</p><p>That is the first important clue. The controller action has already finished. Rails is back in the middleware stack, trying to turn the response into bytes the browser can understand. Only then does the oversized session become a concrete failure.</p><p>This exposes a common broken assumption about state in Rails: <strong>the session is not a Ruby hash sitting in server memory</strong>. In a default Rails application, the <code>session</code> is backed by <code>ActionDispatch::Session::CookieStore</code>. When you write to it, you are not putting data into a process-local cache. You are asking Rails to serialize that data, encrypt it, authenticate it, and send it back to the browser as a cookie.</p><p>You did not save the object on the server. You packed it into a cookie and asked the browser to bring it back on the next request.</p><h2>Where the Session Goes</h2><p>By the time your controller action runs, Rails has already made the <code>session</code> available through the request context. That part feels ordinary:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">session[:user_id] = user.id</code></pre></div><p>After the action returns, the response travels back up the Rack middleware stack. <code>ActionDispatch::Session::CookieStore</code> gets a chance to persist the session.</p><p>CookieStore does not save the session to a database table or to Redis. It writes the session data into the cookie jar, using the configured session cookie key:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Rails.application.config.session_store :cookie_store, key: "_billing_session"</code></pre></div><p>From there, the encrypted cookie jar takes over. For a modern Rails app using encrypted cookies, the flow looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">session hash
  -&gt;
serialized payload
  -&gt;
encrypted and authenticated message
  -&gt;
cookie value
  -&gt;
Set-Cookie response header</code></pre></div><p>Rails checks the final cookie name plus the encrypted value. If the result exceeds the cookie size limit, the response cannot be committed.</p><p>The controller line looked like a hash assignment. The system behavior was an HTTP write.</p><h2>The Serialization Pipeline</h2><p>Let&#8217;s trace how a Ruby hash becomes an HTTP header.</p><p>The CookieStore handoff is small, but important:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby"># action_dispatch/middleware/session/cookie_store.rb
def set_cookie(request, session_id, cookie)
  cookie_jar(request)[@key] = cookie
end

def cookie_jar(request)
  request.cookie_jar.signed_or_encrypted
end</code></pre></div><p>That method writes the session value into the signed or encrypted cookie jar.</p><p>From there, the encrypted cookie jar does three things:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;dc1e49af-d2ea-419e-a6c2-9f4aabc9266e&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">serialize the session
encrypt and authenticate the payload
check whether the final cookie is too large</code></pre></div><p>The Rails source compresses that final step into a few lines:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby"># action_dispatch/middleware/cookies.rb
def commit(name, options)
  super
  options[:value] = @encryptor.encrypt_and_sign(
    options[:value],
    **cookie_metadata(name, options)
  )
  check_for_overflow!(name, options)
end</code></pre></div><p>The important detail is not the exact encryption setup. It is the direction of travel:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">session hash
  -&gt;
serialized payload
  -&gt;
encrypted cookie value
  -&gt;
Set-Cookie header</code></pre></div><p>New Rails applications use JSON serialization for cookies by default, though upgraded applications may still carry older serializer settings such as Marshall or hybrid modes. After serialization, Rails uses <code>ActiveSupport::MessageEncryptor</code> with keys derived from <code>secret_key_base</code> to seal the payload.</p><p>In a modern AES-GCM encrypted-cookie setup, the browser stores a value with this general shape:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">sJqiplFlaBri/tctIKNc...--+TZCVdk0e/RzsuUf--W3J114mcEMz00DoeuAUNSw==</code></pre></div><p>The pieces are the encrypted payload, the initialization vector, and the authentication tag. </p><p>If any part of that message is changed, Rails cannot decrypt and verify it. The session data is not accepted.</p><p>The exact cookie format can vary across Rails versions, cipher settings, metadata settings, and rotations. But the architecture is stable: CookieStore writes through the cookie jar, <code>MessageEncryptor</code> seals the payload, and the browser carries the sealed result back.</p><p>This pipeline also explains why the <code>CookieOverflow</code> appears after the controller action has finished. The oversized value is discovered when Rails tries to commit the encrypted cookie.</p><p>The session is encrypted and tamper-resistant, but the physical limits of HTTP headers still bind it.</p><h2>The Four Kilobyte Ceiling</h2><p>CookieStore is fast because it avoids server-side lookup of the session blob. It is also brutally small.</p><p>Browsers generally enforce a limit of around 4 KB (4096 bytes) per cookie. Rails checks the size of the cookie name plus the encrypted value before writing it. Encryption and metadata add overhead, so the useful payload is smaller than the raw browser limit.</p><p>That is why this mistake is so easy to misread:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">session[:current_user] = @account</code></pre></div><p>The same mistake often arrives through the flash:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">redirect_to root_path, flash: { error: @account }</code></pre></div><p>That looks like a temporary redirect message, but the flash is carried through the session. If the session is CookieStore, a large flash payload is still a large cookie payload.</p><p>You are not only storing the account&#8217;s ID. Depending on the serializer and object shape, you may be asking Rails to encode attributes, timestamps, loaded association data, dirty tracking state, or some other object representation that was never meant to cross the request boundary.</p><p>The failure mode is not always a clean overflow.</p><p>In a JSON-serialized app, a complex object often comes back as plain serialized data, not a live model:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">session[:current_account] = @account

# Next request
session[:current_account].class
# =&gt; Hash</code></pre></div><p>Now the bug depends on how the rest of the application uses that value. Code that only reads <code>[&#8220;id&#8221;]</code> might limp along. Code that expects an <code>Account</code> instance fails. Code that reads stale attributes may quietly make decisions from yesterday&#8217;s state.</p><p>In older Marshall or hybrid-serializer applications, the failure can be worse in another direction: the object-shaped value may round-trip more successfully, making it easier to believe that the session is storing application state safely. But that object is detached from the database. Its associations do not become fresh when a new request starts. Authorization, billing state, feature flags, account memberships, and plan limits can all change while the cookie still carries an outdated snapshot.</p><p>That is the trap.</p><p>The session can carry identity across requests. It should not carry the state that identity points to.</p><h2>The Distributed Reality</h2><p>Once you see the 4 KB limit, CookieStore can look oddly constrained. Why would Rails make this the default?</p><p>The answer lies in how a mature Rails application actually runs in production.</p><p>When you boot a Rails app locally with <code>bin/rails server</code>, you are often looking at a single process on a single machine. In that isolated environment, a global in-memory hash can appear to work. Production environments do not remain in that state for long.</p><p>A standard production <code>config/puma.rb</code> looks something like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby"># config/puma.rb
workers ENV.fetch("WEB_CONCURRENCY") { 4 }

threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count

preload_app!</code></pre></div><p>When this boots, Puma forks into four distinct worker processes. These are isolated operating system processes. Each process has its own Ruby heap. Threads inside a worker share memory, but workers do not.</p><p>Imagine you decided to build your own session store using a global Ruby hash. A user logs in, the load balancer routes their request to Worker 1, and you save <code>GLOBAL_SESSIONS[session_id] = { user_id: user.id }</code>.</p><p>When the next request lands, the load balancer might route it to Worker 3. That worker looks at its own isolated <code>GLOBAL_SESSIONS</code> hash, finds nothing, and boots the user back to the login screen.</p><p>From the user&#8217;s perspective, the application randomly forgot who they were.</p><p>You can try to patch this with sticky sessions at the load balancer, forcing a user to keep hitting the same backend. That only hides the coupling. It still gets awkward across deploys, worker restarts, autoscaling events, and multi-host routing.</p><p>Teams that need server-side revocation, larger session payloads, or compliance controls sometimes move session storage into Redis or a database-backed store. That changes the trade-offs: network calls, eviction behavior, cleanup, persistence, and capacity planning all become part of the session story. But it does not change the core lesson. Session should carry identity, not application state.</p><p>CookieStore chooses a different trade-off.</p><p>The browser carries the sealed session payload. Every Rails process that shares the same cookie configuration and <code>secret_key_base</code> can read it. Worker 1 does not need to remember what Worker 3 wrote.</p><p>The framework trades payload size for stateless web workers.</p><h2>The Invalidation Problem</h2><p>Once you accept that the session lives on the client, you run into the hardest problem of stateless architecture: <strong>invalidation</strong>.</p><p>Suppose a user&#8217;s account is compromised, or an administrator removes their access. You need to log them out immediately. Because the state is stored in a cookie, you cannot reach into every browser and delete it.</p><p>The naive approach is to add a database flag and check it on every request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby"># The naive approach: Check this on every single request
class ApplicationController &lt; ActionController::Base
  before_action :check_revoked_status

  def check_revoked_status
    if current_user.access_revoked?
      reset_session
      redirect_to login_path
    end
  end
end</code></pre></div><p>This can be the right product behavior. It blocks the user as soon as the application sees the revoked state.</p><p>But it does not invalidate the cookie. The encrypted session still decrypts, and the browser still sends it. If someone copied that cookie earlier, they can replay the same encrypted string until the server rejects the state inside it. That is the replay attack shape this gate is defending against. The application had added a server-side check in front of the session, but it did not remotely destroy every copy of the cookie.</p><p>For global invalidation, the hard level is the secret material.</p><p>Encrypted session cookies are protected by keys derived from <code>session_key_base</code>. If you change that secret and do not accept the old one through cookie rotations, existing session cookies become unreadable across the fleet.  That is effective during a serious security event, but it logs out everyone.</p><p>Cookie ratation needs precise language. Rails rotations are usually for migration: accept messages written with old keys while writing new ones with the new configuration. If the old key remains accepted, old sessions are not invalidated.</p><p>For a targeted invalidation, you need some server-side fact that can change.</p><p>The simple version is a user-level session token. Instead of treating <code>user_id</code> alone as enough, you store a random token in the session and compare it against the current token on the user:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class User &lt; ApplicationRecord
  # Assume a `session_token` string column exists
end

# 1. During sign-in:
session[:user_token] = user.session_token

# 2. In ApplicationController:
def current_user
  @current_user ||= User.find_by(session_token: session[:user_token])
end</code></pre></div><p>Now, when you need to log a user out of all devices forcibly, you rotate the token in the database:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def force_logout!(user)
  user.update!(session_token: SecureRandom.urlsafe_base64)
end</code></pre></div><p>The next time that user makes a request, their browser sends the old token. <code>User.find_by</code> returns <code>nil</code>. They are logged out without changing anyone else&#8217;s session.</p><p>That model invalidates all devices for one user. If you need to revoke one device at a time, use a dedicated <code>UserSession</code> record and store a per-session token in the cookie. That rule is the same either way: CookieStore can carry the token, but the server-side record decides whether the token still means anything.</p><h2>Store Identity, Not State</h2><p>Rails goes out of its way to make the web feel stateful. <code>session[:key] = value</code> is one of the most elegant APIs in the framework, but its simplicity hides a distributed systems reality.</p><p>When you are deciding what to put in the session, use this rule of thumb: <strong>the session is a specialized transport mechanism, not a storage engine.</strong> Store identity, not state. Store IDs, not objects. The moment you try to use the client&#8217;s browser as a caching layer for your database, you are fighting the architecture of the framework itself.</p><p>Once the session contains only a user identifier, another question appears: where does <code>current_user</code> come from on the next request?</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for Reading! RailsRevelry is a technical library that explains how Rails behaves in mature systems. Subscribe to receive future articles in the series.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[How Rails Finds the View to Render]]></title><description><![CDATA[Rendering is not "show the file." Rails builds a lookup query from the controller, action, format, variant, and view paths, then resolves the best matching template.]]></description><link>https://railsrevelry.substack.com/p/how-rails-finds-the-view-to-render</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/how-rails-finds-the-view-to-render</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 31 May 2026 04:30:24 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!zzHi!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the last article, we looked at how a controller action becomes an HTTP response.</p><p>The action might call <code>render</code>.</p><p>It might call <code>redirect_to</code>.</p><p>It might call <code>head</code>.</p><p>Or it might do none of those things and still return a page.</p><p>The last case is classic Rails:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Class UsersController &lt; ApplicationController
  def show
    @user = User.find(params[:id])
  end
end</code></pre></div><p>There is no explicit render call here.</p><p>But if the request reaches <code>UsersController#show</code>, Rails can still render:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/users/show.html.erb</code></pre></div><p>People usually describe this as a convention. That is true, but it hides the lookup process underneath.</p><p>Rails is not looking at the URL and wandering through your <code>app/views</code> directory until something feels right.</p><div class="callout-block" data-callout="true"><p>Earlier pieces covered how <a href="https://railsrevelry.substack.com/p/how-rails-routing-works-turning-urls">routing</a> and <a href="https://railsrevelry.substack.com/p/how-rails-dispatches-a-request-to">controller dispatch</a> identify the controller/action, and how Rails knows whether <a href="https://railsrevelry.substack.com/p/how-rails-turns-a-controller-action-into-response">a response has already been performed</a>.</p></div><p>By this point, Rails has already learned several facts from the request and the controller:</p><ul><li><p>which controller handled the request</p></li><li><p>which action ran</p></li><li><p>whether a response was already performed</p></li><li><p>what format the request wants</p></li><li><p>what variant is active, if any</p></li><li><p>which template handlers are available</p></li><li><p>which view paths the controller should search</p></li></ul><p>The question for this article:</p><blockquote><p>How does Rails decide which view template to render?</p></blockquote><h2>The Problem Rails Is Solving</h2><p>Imagine if every controller action had to name its matching template:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class UsersController &lt; ApplicationController
  def show
    @user = User.find(params[:id])

    render "users/show"
  end
end</code></pre></div><p>Explicit rendering is sometimes exactly what you want. But most HTML controller actions follow a boring pattern:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">run the action
prepare a few instance variables
render the matching template</code></pre></div><p>If every ordinary action had to repeat that last line, Rails would make you spell out what the controller name and action name already imply.</p><p>The framework has enough information to treat this as the default:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">UsersController#show
  -&gt;
users/show</code></pre></div><p>Because Rails supplies that default, this action can stay focused on application state:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;0c458186-2272-43f2-ba25-69d91eb79f37&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  @user = User.find(params[:id])
end</code></pre></div><p>The action loads the data the view needs. Rails handles the conventional rendering step after the action finishes.</p><p>Rails does not implicitly render because the action was empty. It implicitly renders because the action finished without performing a response.</p><h2>Implicit Rendering Is a Fallback</h2><p>Rails keeps asking one request-level question: <em>Has this request already chosen a response?</em></p><p>If the action calls <code>redirect_to</code>, the answer is yes.</p><p>If the action calls <code>head :no_content</code>, the answer is yes.</p><p>If the action calls <code>render :profile</code>, the answer is yes.</p><p>If the action finishes with none of those, the answer may be no. Now it's time for implicit rendering.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;6a54d4db-1bf9-48f2-bc0a-264c3cf3f120&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  @user = User.find(params[:id])
end</code></pre></div><p>After this method returns, Rails checks whether the response has already been performed. If not, <a href="https://api.rubyonrails.org/classes/ActionController/ImplicitRender.html">ActionController::ImplicitRender</a> tries to find a template for the current action.</p><p>For a full-stack Rails controller, the simplified decision looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">did the action already render, redirect, or head?
  -&gt;
yes: keep that response
no: try the default template for this action</code></pre></div><p>Implicit rendering is not the same as rendering from inside the action.</p><p>This action renders explicitly:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  @user = User.find(params[:id])

  render "profiles/show"
end</code></pre></div><p>This action relies on the fallback:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  @user = User.find(params[:id])
end</code></pre></div><p>Both can produce HTML. But only the second one asks Rails to derive the template from the controller and action.</p><h2>The Lookup Starts With a Name and a Prefix</h2><p>For an ordinary controller action, the first two lookup facts are simple:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">action name: show
controller path: users</code></pre></div><p>Rails exposes that controller path directly:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">UsersController.controller_path
# =&gt; "users"</code></pre></div><p>Together, those point toward:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">users/show</code></pre></div><p>In filesystem terms, the usual template is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">app/views/users/show.html.erb</code></pre></div><p>But internally, Rails does not only work with a complete filename. It works with a lookup request.</p><p>For a template render, Rails needs:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">name
prefixes
partial or full template
details
view paths</code></pre></div><p>In the simple <code>UsersController#show</code> case:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">name: show
prefixes: users
partial: false
details: format, locale, variant, handler
view paths: app/views, plus any configured additions</code></pre></div><p>Rails is not asking only whether <code>app/views/users/show.html.erb</code> exists.</p><p>It is asking closer to:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">In the available view paths,
is there a full template named show,
under the users prefix,
matching the current request details?</code></pre></div><p>Rails can then build a set of candidate templates instead of betting on one hard-coded filename.</p><p>A candidate template is a possible match for the logical template Rails wants to render. It is shaped by:</p><ul><li><p>the template name</p></li><li><p>the prefixes</p></li><li><p>the requested format</p></li><li><p>the active variant</p></li><li><p>the available handlers</p></li><li><p>the view paths</p></li></ul><p>Action View takes those facts and resolves the best-matching template.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!zzHi!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!zzHi!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png 424w, https://substackcdn.com/image/fetch/$s_!zzHi!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png 848w, https://substackcdn.com/image/fetch/$s_!zzHi!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png 1272w, https://substackcdn.com/image/fetch/$s_!zzHi!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!zzHi!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png" width="1456" height="728" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:728,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:891128,&quot;alt&quot;:&quot;Diagram showing a Rails controller action becoming a template lookup query, producing candidate templates, selecting the best match, and rendering a response.&quot;,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/199860245?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="Diagram showing a Rails controller action becoming a template lookup query, producing candidate templates, selecting the best match, and rendering a response." title="Diagram showing a Rails controller action becoming a template lookup query, producing candidate templates, selecting the best match, and rendering a response." srcset="https://substackcdn.com/image/fetch/$s_!zzHi!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png 424w, https://substackcdn.com/image/fetch/$s_!zzHi!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png 848w, https://substackcdn.com/image/fetch/$s_!zzHi!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png 1272w, https://substackcdn.com/image/fetch/$s_!zzHi!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8fe313e5-07a8-48fc-a939-2434c8f604b0_1774x887.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading RailsRevelry. Subscribe for free to get the next Rails systems essay in your inbox.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><h2>The Filename Carries Request Details</h2><p>A Rails view filename is compact metadata:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/users/show.html.erb</code></pre></div><p>It breaks down like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">users       -&gt; prefix
show        -&gt; template name
html        -&gt; format
erb         -&gt; handler</code></pre></div><p>The handler tells Action View how to process the template. In most Rails applications, that handler is ERB. Embedded Ruby inside an HTML template, evaluated into the response body.</p><p>Other handlers exist. Jbuilder, Builder, or another registered handler might back a template. But <code>.html.erb</code> is the default combination most Rails developers meet first:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">html -&gt; produce an HTML representation
erb  -&gt; process the template with ERB</code></pre></div><p>Notice that <code>erb</code> is not part of the template name. Rails is not specifically looking for a file called <code>show.html.erb</code> as one indivisible thing.</p><p>It is looking for a template named <code>show</code> under the correct prefix whose lookup details match the request.</p><p>Those details can include locale and variant, too:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/users/show.en.html.erb
app/views/users/show.html+mobile.erb</code></pre></div><p>The rough pattern is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">prefix/name.locale.format+variant.handler</code></pre></div><p>Not every part has to be present. But each present part gives Action View more information to narrow the candidate list.</p><p>These files are separate candidates for the same logical template:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;bb97840e-653f-4839-8781-e749f5ee5928&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/users/show.html.erb
app/views/users/show.json.jbuilder
app/views/users/show.html+mobile.erb</code></pre></div><p>The filename tells Action View why each candidate is different.</p><h2>Format Changes Which Template Rails Wants</h2><p>The most common detail is format.</p><p>For a normal browser request, Rails is usually looking for HTML:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">GET /users/1</code></pre></div><p>The usual match is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/users/show.html.erb</code></pre></div><p>But a request can ask for a different representation:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">GET /users/1.json</code></pre></div><p>Now Rails should not silently use the HTML template and pretend it produced JSON.</p><p>It should look for something that matches the requested format:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/users/show.json.jbuilder</code></pre></div><p>or another template capable of producing the requested response.</p><p>Missing-template errors often mention formats for this reason.</p><p>The problem is not always that there are no <code>users/show</code> templates.</p><p>Sometimes the problem is that a <code>users/show</code> template exists, but it&#8217;s not in the format this request asks for.</p><p>That matters most in controllers that serve more than one client:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  @user = User.find(params[:id])

  respond_to do |format|
    format.html
    format.json
  end
end</code></pre></div><p>The action name did not change.</p><p>The controller did not change.</p><p>The requested representation changed, so the template lookup changed.</p><h2>Variants Narrow the Choice Further</h2><p>Formats answer a broad question: &#8220;<em>What kind of representation is this?&#8221;</em></p><p>Variants answer a narrower question: &#8220;<em>What version of that representation should this request use?&#8221;</em></p><p>For example, a controller might set a mobile variant:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;fb3d5779-2371-4a8f-a9af-033a14e12881&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ApplicationController &lt; ActionController::Base
  before_action :set_variant

  private

  def set_variant
    request.variant = :mobile if mobile_browser?
  end
end</code></pre></div><p>Now the same action can use a more specific template:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/users/show.html+mobile.erb</code></pre></div><p>If Rails is rendering <code>users/show</code> for an HTML request with the <code>:mobile</code> variant, it tries the mobile version before falling back to the generic HTML template.</p><p>The action is still:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  @user = User.find(params[:id])
end</code></pre></div><p>The lookup details are different. A request can change template lookup without changing the action body. Something earlier in the request may have changed the format or variant.</p><h2>View Paths Decide Where Rails Searches</h2><p>So far, we have talked about the logical template:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">users/show</code></pre></div><p>and the details:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">html
mobile
erb</code></pre></div><p>Rails also needs to know where to search.</p><p>Rails calls that list the controller&#8217;s view paths.</p><p>In a standard Rails app, the important path is <code>app/views</code>.</p><p>So the lookup <code>users/show + html + erb</code> can resolve to <code>app/views/users/show.html.erb</code>.</p><p>But view paths are not limited to one directory forever.</p><p>Engines, gems, controller-level overrides, and calls like <code>prepend_view_path</code> or <code>append_view_path</code> can change the places Rails searches.</p><p>A changed search path can explain why a template exists, but Rails does not use the one you expected.</p><p>The issue might not be the template name. It might be the search path.</p><p>Internally, <a href="https://api.rubyonrails.org/classes/ActionView/ViewPaths.html">ActionView::ViewPaths#lookup_context</a> builds an ActionView#LookupContext from three ingredients:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">view paths
details for lookup
prefixes</code></pre></div><p>The lookup context gathers the facts Rails needs to find templates.</p><h2>Template Inheritance Adds Fallback Prefixes</h2><p>One Rails convention is easy to miss.</p><p>Rails can use controller inheritance when looking for templates and partials.</p><p>The Rails Guides give this example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;edad61f5-56de-4e95-98e6-608d3d6ffaae&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class AdminController &lt; ApplicationController
end

class Admin::ProductsController &lt; AdminController
  def index
  end
end</code></pre></div><p>For <code>Admin::ProductsController#index</code>, Rails can look in this order:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/admin/products/
app/views/admin/
app/views/application/</code></pre></div><p>This makes <code>app/views/application</code> a useful place for shared partials.</p><p>The controller path gives Rails the most specific prefix first.</p><p>The inheritance chain can provide broader fallback prefixes after that.</p><p>Used deliberately, fallback prefixes give shared templates and partials a natural home. They also explain a strange class of bugs: &#8220;Why is Rails rendering a shared partial rather than failing?&#8221;</p><p>Sometimes it did not find the most specific template, but it did find a fallback template higher in the controller hierarchy.</p><p>Intentional fallback feels elegant. Accidental fallback sends you looking in the wrong file.</p><h2>Layout Selection Is Related, But Separate</h2><p>Finding the action template is not the whole story of rendering.</p><p>After Rails renders the template body, it often wraps that body in a layout:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/layouts/application.html.erb</code></pre></div><p>or a controller-specific or declared layout:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/layouts/users.html.erb</code></pre></div><p>Layout selection is a second decision layered on top of the template decision. The action template answers the question: &#8220;What content should this action produce?&#8221;</p><p>The layout answers the question: &#8220;What outer page frame should wrap that content?&#8221;</p><p>So this action:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;aad20275-27ba-4062-b10d-09707c02510d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  @user = User.find(params[:id])
end</code></pre></div><p>may involve both:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;66b19930-4933-45d6-bc98-f9da161585f1&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/users/show.html.erb
app/views/layouts/application.html.erb</code></pre></div><p>A rendering bug can live in either place.</p><p>Wrong page-specific content points to the action template or partials. Wrong navigation, scripts, title, or frame points to the layout.</p><h2>What Happens When Rails Cannot Find the Template</h2><p>Missing-template behavior is not only &#8220;file not found.&#8221;</p><p>When a normal controller action finishes without performing a response, <a href="https://api.rubyonrails.org/classes/ActionController/ImplicitRender.html">ActionController::ImplicitRender</a> tries the default template.</p><p>In Rails 8.1, the fallback sequence is roughly:</p><ol><li><p>If the exact template exists, render it.</p></li><li><p>If the templates exist for other formats, variants, or handlers, raise UnknownFormat.</p></li><li><p>If this looks like an ordinary browser page load, raise MissingExactTemplate.</p></li><li><p>Otherwise, respond with 204 No Content.</p></li></ol><p>A request that doesn&#8217;t render anything may not raise a missing-template error; Rails can decide that <code>204 No Content</code> is the appropriate implicit response.</p><p>For API controllers, the implicit response is even simpler:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">204 No Content</code></pre></div><p>The same missing-template situation can therefore feel different depending on the request.</p><p>An HTML page load and a non-browser request do not carry the same expectation.</p><p>Rails is separating two cases: </p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">It looks like you intended to render a page, but it's missing.</code></pre></div><p>from: </p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">This request may not need a body.</code></pre></div><p>The debugging question should include the request format and request type, not only the controller action.</p><h2>A Better Debugging Reflex</h2><p>When Rails renders the wrong view, or fails to render the view you expected, do not start by starting only at the action body.</p><p>Ask what lookup Rails was trying to perform.</p><p>The practical sequence is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Has this action already performed a response?
  render / redirect_to / head / before_action

If not, what action name is Rails using?
  show, index, new, edit, create, update, destroy

What prefix is Rails using?
  users, admin/users, or a fallback from controller inheritance

What format did the request negotiate?
  htmo, json, or something else

Is a variant active?
  mobile, tablet, or another custom variant

Which view paths are being searched?
  app/views, engine paths, prepended paths, configured additions

Is the issue in the action template or the layout?
  page body versus surrounding frame</code></pre></div><p>The sequence turns &#8220;Rails cannot find my view&#8221; into a set of inspectable facts.</p><p>You can check the route.</p><p>You can check the controller path.</p><p>You can check the <code>request.format</code>.</p><p>You can check the <code>request.variant</code>.</p><p>You can check which view paths the controller is using.</p><p>View lookup has a concrete form: a <strong>query</strong>.</p><p>Rails takes the controller, action, request details, and view paths, then asks Action View for the best-matching template.</p><p>The familiar path <code>app/views/users/show.html.erb</code> is the result of that query in the simplest case.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading! RailsRevelry explains how Rails works in real production systems. Subscribe to get the next article.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[How Rails Turns a Controller Action Into Response]]></title><description><![CDATA[Rails actions do not return HTTP responses. They perform response state that Rails turns into status, headers, and body.]]></description><link>https://railsrevelry.substack.com/p/how-rails-turns-a-controller-action-into-response</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/how-rails-turns-a-controller-action-into-response</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 24 May 2026 04:30:56 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!7oVs!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the last article, we looked at the <a href="https://railsrevelry.substack.com/p/why-rails-runs-code-before-your-controller">code Rails can run before your controller action</a>.</p><p>Now the action finally gets its turn.</p><p>It can load records, check state, create something, enqueue work, assign instance variables, choose a format, or decide the user should be somewhere else entirely.</p><p>But after all that Ruby code runs, Rails still owes the outside world the same primitive output: <em>an HTTP response.</em></p><p>HTTP doesn&#8217;t know about controller actions. It does not know about <code>@user</code>, <code>current_user</code>, service objects, partials, or Active Record.</p><p>At the boundary of the web server, the response has to become:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">status
headers
body</code></pre></div><p>Rack makes that contract explicit:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!7oVs!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!7oVs!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png 424w, https://substackcdn.com/image/fetch/$s_!7oVs!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png 848w, https://substackcdn.com/image/fetch/$s_!7oVs!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png 1272w, https://substackcdn.com/image/fetch/$s_!7oVs!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!7oVs!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png" width="1456" height="524" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:524,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1357049,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/198711433?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!7oVs!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png 424w, https://substackcdn.com/image/fetch/$s_!7oVs!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png 848w, https://substackcdn.com/image/fetch/$s_!7oVs!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png 1272w, https://substackcdn.com/image/fetch/$s_!7oVs!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0aaa672a-3149-4cf5-ad7b-92f30ee08a3c_2532x912.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>A request comes in. A response goes out. Rails exists partly to keep you from hand-building that tuple in every controller action.</p><p>The question for this article:</p><blockquote><p>How does Rails turn controller execution into an HTTP response?</p></blockquote><h2>The Problem Rails Is Solving</h2><p>Imagine if every Rails action had to return a Rack response directly:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class UsersController &lt; ApplicationController
  def show
    user = User.find(params[:id])

    [
      200,
      { "Content-Type" =&gt; "text/html",
      ["&lt;h1&gt;#{user.name}&lt;/h2&gt;"]
    ]
  end
end</code></pre></div><p>That would be honest, but miserable. Every action would have to care about status codes, headers, content types, body construction, and response format. That is fine for a tiny Rack app, but not enough for a Rails controller.</p><p>Rails actions often need to do application work first:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def create
  @post = current_user.posts.new(post_params)

  if @post.save
    redirect_to @post
  else
    render :new, status: :unprocessable_entity
  end
end</code></pre></div><p>This code is making a request-level decision:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">If the post is saved, send the client somewhere else.
If it did not, show the form again with errors.</code></pre></div><p><code>render</code>, <code>redirect_to</code>, and <code>head</code> let the action talk in those terms instead of manually assembling <code>[status, headers, body]</code>.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading RailsRevelry. Subscribe for free to get the next Rails systems essay in your inbox.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><h2>Why the Action&#8217;s Return Value Is Not Enough</h2><p>A normal Ruby method is often understood by its return value:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def greeting
  "hello"
end</code></pre></div><p>The method returns <code>&#8221;hello&#8221;</code>. Controller actions do not work that way.</p><p>This action doesn&#8217;t send <code>&#8221;hello&#8221;</code> to the browser:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class PagesController &lt; ApplicationController
  def home
    "hello"
  end
end</code></pre></div><p>Rails is not treating the final Ruby expression as the response body.</p><p>That feels odd until you consider the constraints under which Rails operates.</p><p>An action might render before the last line:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def preview
  render plain: "draft"

  Rails.logger.info("preview rendered")
end</code></pre></div><p>An action might redirect from inside a branch:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  unless current_user
    redirect_to login_path
    return
  end

  @profile = current_user.profile
end</code></pre></div><p>An action might not call <code>render</code> at all:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  @user = User.find(params[:id])
end</code></pre></div><p>That last one is ordinary Rails. The action prepares data. Rails renders the default template afterward.</p><p>Rails does not ask: <em>&#8220;What did the action return?&#8221;</em></p><p>It asks: <em>&#8220;Has this controller performed a response yet?&#8221;</em></p><p>The difference is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Ruby method return value
  -&gt;
not the HTTP response

controller response state
  -&gt;
render / redirect_to / head / implicit render
  -&gt;
ActionDispatch::Response
  -&gt;
[status, headers, body]</code></pre></div><h2>The Response Object Rails Is Building</h2><p>By the time your controller action runs, Rails already has a response object for the request. Inside a controller, you can reach it with:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">response</code></pre></div><p>That object is an instance of <code>ActionDispatch::Response</code>.</p><p>It is where Rails stores the response state that will eventually become the HTTP response.</p><p>At the controller level, you usually do not manipulate that state directly:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext"> self.response_body = "&lt;h1&gt;Hello&lt;/h1&gt;"</code></pre></div><p>You usually write:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">render :show

or

redirect_to users_path

or 

head :no_content</code></pre></div><p>Inside the framework, <a href="https://api.rubyonrails.org/classes/ActionDispatch/Response.html">ActionDispatch::Response</a> is the actual object exposed through <code>ActionController::Metal#response</code>.</p><div class="callout-block" data-callout="true"><p>At the controller level, rendering means deciding what body the response should carry.</p></div><h2>What <code>render</code> Changes</h2><p>Start with explicit rendering:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Class UsersController &lt; ApplicationController
  def show
    @user = User.find(params[:id])

    render :show
  end
end</code></pre></div><p>At the level you write application code, this means: <em>&#8220;render the show template.&#8221;</em></p><p>Rails then has to do several things:</p><ul><li><p>interpret the render arguments</p></li><li><p>find the body content</p></li><li><p>set response details such as content type and status</p></li><li><p>assign the rendered body to the response</p></li></ul><p>The controller-facing implementation anchor is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">ActionController::Rendering</code></pre></div><p>Its <a href="https://api.rubyonrails.org/classes/ActionController/Rendering.html">render</a> method checks whether a response body already exists and raises <code>AbstractController::DoubleRenderError</code> if you try to render again.</p><p>At the source level, the flow is: normalize the render options, call <code>render_to_body</code>, set content type information, and assign:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">self.response_body = rendered_body</code></pre></div><p>That assignment is why Rails considers the response to have been performed.</p><p><code>render</code> doesn&#8217;t mean &#8220;send bytes to the browser right now.&#8221; It means: <em>&#8220;put this rendered body into the response Rails is building.&#8221;</em></p><p>Once that body exists, Rails can say the response has been performed.</p><h2>Rendering Is Not Only Templates</h2><p>Template rendering is the common path:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">render :show</code></pre></div><p>which usually means Rails will look for something like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/users/show.html.erb</code></pre></div><p>But <code>render</code> is broader than HTML templates.</p><p>You can render plain text:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">render plain: "ok"</code></pre></div><p>You can render JSON:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">render json: { id: @user.id, name: @user.name }</code></pre></div><p>You can render with a non-200 status:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">render json: { error: "Not found" }, status: :not_found</code></pre></div><p>Different renderer, same job: <em>&#8220;choose the response body and response metadata.&#8221;</em></p><p>Template rendering eventually flows into Action View: template lookup, layouts, formats, variants, and rendering handlers.</p><p>That subsystem deserves its own deep dive. For now, the important point is narrower: <code>render</code> is one way a controller action performs the response.</p><h2>When Rails Renders For You</h2><p>Now take the explicit <code>render</code> away:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class UsersController &lt; ApplicationController
  def show
    @user = User.find(params[:id])
  end
end</code></pre></div><p>That is not an empty response. In a regular HTML controller, Rails can use the action name and controller name to look for a default template:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">app/views/users/show.html.erb</code></pre></div><p>The action prepares the state:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">@user = User.find(params[:id])</code></pre></div><p>Action View can then render the template that corresponds to the action.</p><p>Rails actions can look smaller than the work they trigger. The last visible line in the action is not the last thing Rails does for the request.</p><p><a href="https://api.rubyonrails.org/classes/ActionController/ImplicitRender.html">ActionController::ImplicitRender</a> handles actions that finish without explicitly calling <code>render</code>, <code>respond_to</code>, <code>redirect_to</code>, or <code>head</code>.</p><p>In a regular controller, Rails may render the matching template. In an <code>ActionController::API</code> controller, templates are not part of the default stack; Rails expects you to call <code>render</code> or <code>redirect_to</code> for real content, and otherwise returns <code>204 No Content</code>.</p><p>If your action did not explicitly perform a response, Rails may still perform one after the action finishes.</p><h2>Redirects Are Responses Too</h2><p>A redirect is not a render.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def create
  @user = User.create!(user_params)
  
  redirect_to @user
end</code></pre></div><p>This does not render the user&#8217;s show page inside the same request.</p><p>It sends the client a response that says: <em>&#8220;Go to this other URL next.&#8221;</em></p><p>At the HTTP level, that response is roughly:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">status: 302
Location: /users/42
body: ""</code></pre></div><p>Then the browser makes a second request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">GET /users/42</code></pre></div><p>That second request may render the show page.</p><p>The source-level path of <code>redirect_to</code> reveals the same response-state idea:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">self.location      = ...
self.response_body = ""
self.status        =</code></pre></div><p>So <code>redirect_to</code> performs a response by setting a <code>Location</code> header, an empty body, and a redirect status.</p><p>It tells the client where to go.</p><h2><code>redirect_to</code> Does Not Stop Ruby</h2><p><code>redirect_to</code> performs a response, but it does not stop the current method.</p><p>This action is buggy:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def create
  unless current_user
    redirect_to login_path
  end

  @post = current_user.posts.create!(post_params)

  redirect_to @post
end</code></pre></div><p>When <code>current_user</code> is <code>nil</code>, it raises the very ordinary Rails pain:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">NoMethodError: undefined method `posts` nil</code></pre></div><p>Or, if later code runs, the method may attempt a second response.</p><p>Use Ruby control flow when you want the method to stop:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def create
  unless current_user
    redirect_to login_path
    return
  end

  @post = current_user.posts.create!(post_params)

  redirect_to @post
end</code></pre></div><p>The rule:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">redirect_to changes the response
return changes the Ruby execution</code></pre></div><p>Rails tracks the response. Ruby still runs your method.</p><h2><code>head</code> Builds the Minimal Response</h2><p>Sometimes the response does not need a body.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def destroy
  @post.destroy!

  head :no_content
end</code></pre></div><p>That sends a <code>204 No Content</code> response.</p><p>No template. No JSON. No HTML.</p><p>Just the response status and headers.</p><p><code>head</code> is useful when the response itself is the signal:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">head :forbidden
head :no_content</code></pre></div><p><a href="https://api.rubyonrails.org/classes/ActionController/Head.html">ActionController::Head</a> sets the status, applies headers such as <code>Location</code> when provided, and assigns an empty response body.</p><p>So the controller response options line up like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">render       -&gt; response with a body
redirect_to  -&gt; redirect response with a Location
head         -&gt; response with status and headers</code></pre></div><p>Different helpers, same goal: build the HTTP response Rails will return.</p><h2>Why Rails Raises Double Render Errors</h2><p>Once Rails has a response body, it has to protect that decision.</p><p>This action tries to perform two responses:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  @user = User.find(params[:id])

  render :show
  render plain: "done"
end</code></pre></div><p>The first <code>render</code> assigns a response body.</p><p>The second <code>render</code> attempts to assign a different body to the same request.</p><p>Rails raises: <code>AbstractController::DoubleRenderError</code></p><p>The same mistake can happen with redirects:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def create
  @post = Post.create!(post_params)

  redirect_to @post
  render :new
end</code></pre></div><p>Both lines are trying to perform the response.</p><p>Rails exposes this state through</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">performed?</code></pre></div><p>In <code>ActionController::Metal</code>, the check is compact:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">response_body || response.committed?</code></pre></div><p>Most controller actions hit the first side of that check: <code>render</code>, <code>redirect_to</code>, or <code>head</code>, which assign a response body. <code>response.committed?</code> covers lower-level cases where the response has already been committed, such as streaming, and Rails should no longer treat the action as free to choose another response.</p><p>That check is why branch structure matters:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def create
  @post = Post.new(post_params)

  if @post.save
    redirect_to @post
  else
    render :new, status: :unprocessable_entity
  end
end</code></pre></div><p>Only one branch performs the response. No accidental fall through. No second render hiding below the first.</p><h2>The Response Still Leaves Through Rack</h2><p>After the controller performs a response, Rails still has to return to the Rack contract:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">[status, headers, body]</code></pre></div><p><a href="https://api.rubyonrails.org/classes/ActionDispatch/Response.html#method-i-to_a">ActionDispatch::Response#to_a</a> prepares the response and returns the Rack-compatible status, headers, and body.</p><p>Then the response travels back outward through the middleware.</p><p>That means your controller may not be the last code to touch it.</p><p>Middleware can still:</p><ul><li><p>write cookies</p></li><li><p>add security headers</p></li><li><p>adjust cache headers</p></li><li><p>handle <code>HEAD</code> requests</p></li><li><p>log response details</p></li><li><p>turn exceptions into error responses</p></li></ul><p>The controller performs the application response. Rack and middleware carry it back to the server, and the server sends it to the client.</p><h2>A Better Debugging Question</h2><p>When response behavior looks wrong, do not start with:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">What did this action return?</code></pre></div><p>Start with:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Where was the response performed?</code></pre></div><p>That question leads you through the right path:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Did a before_action render, redirect, or call head?
  -&gt;
Did the action explicitly perform a response?
  -&gt;
Did execution continue after redirect_to?
  -&gt;
Did Rails fall through to implicit rendering?
  -&gt;
Did two branches try to perform responses?
  -&gt;
Did middleware change the response afterward?</code></pre></div><p>If Rails is looking for a template you did not expect, check implicit rendering.</p><p>If a redirect occurred but the later code still ran, add Ruby control flow.</p><p>If you see <code>DoubleRenderError</code>, find the two response-performing paths.</p><p>If headers appear from nowhere, keep walking outward into middleware.</p><p>The action matters, but it is one step in Rails&#8217; response-building path, not a little function whose return value becomes HTTP.</p><h2>The Core Principle</h2><p>Rails lets controller actions stay focused on request decisions:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">render :show
redirect_to @post
head :no_content</code></pre></div><p>Under those helpers, Rails is still doing the old web job: <em>&#8220;turn this request into status, headers, and body&#8221;.</em></p><p>The full path is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">route matched
  -&gt;
params assembled
  -&gt;
callbacks run
  -&gt;
action executes
  -&gt;
render / redirect_to / head / implicit render
  -&gt;
ActionDispatch::Response
  -&gt;
Rack response
  -&gt;
middleware
  -&gt;
Client</code></pre></div><blockquote><p>Rails actions do not return HTTP responses. They perform a response state that Rails turns into status, headers, and body.</p></blockquote><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading RailsRevelry. Subscribe for free to follow the next chapter in how Rails works in production.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Why Rails Runs Code Before Your Controller Action]]></title><description><![CDATA[Your Rails action may look small because the real request boundary lives above it. `before_action` is where Rails lets a controller declare what must happen before action-specific code gets a turn.]]></description><link>https://railsrevelry.substack.com/p/why-rails-runs-code-before-your-controller</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/why-rails-runs-code-before-your-controller</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 17 May 2026 09:31:29 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!VQC-!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa9c2d72d-eaf1-46bc-bf53-fe489f91c036_1254x1254.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Previously, we looked at how <a href="https://railsrevelry.substack.com/p/how-rails-builds-params">Rails builds params before the controller action runs</a>.</p><p>Now the action has the request data.</p><p>But data is not the only thing an action needs.</p><p>Before a controller action can safely do its work, a Rails app often has to answer a few other questions:</p><ul><li><p>Who is making this request?</p></li><li><p>Which account, tenant, project, or record does this request belong to?</p></li><li><p>Is this user allowed to continue?</p></li><li><p>Should this request be redirected before the action runs?</p></li><li><p>Is there a shared request context that the action should be able to assume?</p></li></ul><p>Those questions rarely belong to a single action. They sit at the edge of many actions.</p><p>For example, a reporting action may look like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ReportsController &lt; ApplicationController
  def index
    @reports = current_account.reports.visible_to(current_user)
  end
end</code></pre></div><p>That action reads cleanly, but only because it assumes some things are already true:</p><ul><li><p>there is a signed-in user</p></li><li><p>there is a current account</p></li><li><p>the current user is allowed to see reports for that account</p></li></ul><p>If those things are not true, the action should probably not run at all.</p><p><code>before_action</code> exists because controller actions usually need a place for request prerequisites.</p><p>The question for this article:</p><blockquote><p>Why does Rails have a mechanism for running code before the controller action?</p></blockquote><h2>Every Action Has a Hidden Prologue</h2><p>Imagine writing every controller action with all its prerequisites inline:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ReportsController &lt; ApplicationController
  def index
    redirect_to login_path and return unless current_user

    @account = current_user.accounts.find(params[:account_id])

    unless current_user.can_view_reports?(@account)
      head :forbidden and return
    end

    @reports = @account.reports.visible_to(current_user)
  end
end</code></pre></div><p>As a single method, this is readable. You can go top to bottom and see the whole path.</p><p>The trouble starts when the same checks show up in <code>show</code>, <code>new</code>, <code>create</code>, <code>update</code>, <code>destroy</code>, and every custom action that belongs to the same part of the app.</p><p>So the controller starts to develop a repeated pattern:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">make sure there is a user
find the current account
check access
load a record
then do the action-specific work</code></pre></div><p>Repetition is only part of the problem. The action-specific work is also mixed in with request setup.</p><p>The action is supposed to answer a narrow question: &#8220;<em>What should happen for this request?&#8221;</em></p><p>Before it can answer that, the application must establish the conditions under which the action may proceed.</p><h2>Preconditions Before Action-Specific Work</h2><p><code>before_action</code><strong> is Rails&#8217; way of expressing controller preconditions.</strong></p><p>It gives the controller a place to say: <em>"Before this action runs, these conditions must be met or already prepared&#8221;.</em></p><p>So instead of repeating the same setup inside every action, you can write:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ReportsController &lt; ApplicationController
  before_action :authenticate_user!
  before_action :set_account
  before_action :authorize_reports!

  def index
    @reports = @account.reports.visible_to(current_user)
  end
end</code></pre></div><p>Now the action has a cleaner job. Authentication, account lookup, and request-boundary checks no longer crowd the method body.</p><p>It can assume the prerequisites declared above it.</p><p>The flow is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">request enters controller
  -&gt;
preconditions and setup run
  -&gt;
action-specific code runs</code></pre></div><p><code>before_action</code> fits naturally into Rails controllers because controllers already operate around shared request setup.</p><p>They are not collections of unrelated Ruby methods. They are request handlers.</p><p><code>before_action</code> is one way Rails lets you name those boundaries.</p><h2>What Belongs Before an Action</h2><p>Good <code>before_action</code> callbacks usually do one of three things.</p><p>They can protect the request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">before_action :authenticate_user!
before_action :require_admin!</code></pre></div><p>They can establish a request context:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">before_action :set_locale
before_action :set_current_account</code></pre></div><p>They can load the state that several actions need:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">before_action :load_project
before_action :load_invoice, only: [:show, :edit, :update]</code></pre></div><p>In each case, the callback answers a question that the action should not have to keep asking from scratch.</p><p>By the time this action runs:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  @line_items = @invoice.line_items.order(:created_at)
end</code></pre></div><p>the controller has already established:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">there is a current user
there is a current account
there is an invoice
the current user can reach it</code></pre></div><p>The promise is useful, and it is where callbacks can sometimes become dangerous.</p><p>The action looks small because some of the actual execution has moved elsewhere.</p><p>When the moved code is genuinely prerequisite work, the action gets cleaner. When the callback is doing the action&#8217;s real job in disguise, the controller gets harder to read.</p><p>A good practical rule:</p><blockquote><p>Use <code>before_action</code> for things the action should be able to assume, not for things the action is supposed to decide.</p></blockquote><p>That is more a readability rule than a Rails rule, but it explains why some callbacks make a controller cleaner while others make the actual behavior harder to find.</p><h2>Why Callback Helpers Are Usually Private</h2><p>One small Rails detail hides in most controller examples.</p><p>Callback methods are usually placed under <code>private</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ReportsController &lt; ApplicationController
  before_action :authenticate_user!

  def index
    @reports = Report.visible_to(current_user)
  end

  private

  def authenticate_user!
    redirect_to login_path unless current_user
  end
end</code></pre></div><p>The placement is intentional.</p><p>Rails treats public controller methods as possible actions. Internally, <a href="https://api.rubyonrails.org/classes/AbstractController/Base.html#method-c-action_methods">AbstractController::Base.action_methods</a> builds the set of action names from public instance methods, after removing Rails&#8217; own internal methods.</p><p>The visibility tells Rails and future readers which methods are endpoints and which are supporting code.</p><p>Since <code>before_action</code> sites near the top of the controller, right next to the action-facing surface, marking callback helpers private keeps the distinction clear:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">public methods are actions
private methods support execution</code></pre></div><p>Not every controller method is meant to be reachable as an endpoint.</p><h2>How Rails Turns That Idea Into Execution</h2><p>When you write:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">before_action :authenticate_user!</code></pre></div><p>Rails is not inserting a method call at the top of your action method. It is registering a callback around action processing.</p><p>The controller callback machinery lives in Action Pack, primarily through:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">AbstractController::Callbacks</code></pre></div><p>That module uses Rails&#8217; general callback system:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">ActiveSupport::Callbacks</code></pre></div><p>The internal shape is small:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def process_action(...)
  run_callbacks(:process_action) do
    super
  end
end</code></pre></div><p>Rails has a <code>process_action</code> step to execute controller actions, and callbacks are registered around it.</p><p>Conceptually, this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">before_action :authenticate_user!</code></pre></div><p>adds a before callback to the <code>:process_action</code> callback chain.</p><p>The source-shaped version is roughly:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">set_callback(:process_action, :before, :authenticate_user!)</code></pre></div><p>The declaration at the top of the controller is not metadata. It becomes executable control flow around the action.</p><p>It is part of the framework path that leads to your action. The public API for this behavior is documented in <a href="https://api.rubyonrails.org/classes/AbstractController/Callbacks.html">AbstractController::Callbacks</a>, <a href="https://api.rubyonrails.org/classes/AbstractController/Callbacks/ClassMethods.html">AbstractController::Callbacks::ClassMethods</a>, and <a href="https://api.rubyonrails.org/classes/ActiveSupport/Callbacks.html">ActiveSupport::Callbacks</a>.</p><h2>A Callback Can Decide the Action Should Not Run</h2><p>When a precondition fails, <code>before_action</code> becomes more than setup: it can stop the action from running.</p><p>The most common example is authentication:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ReportsController &lt; ApplicationController
  before_action :authenticate_user!

  def index
    @reports = Report.visible_to(current_user)
  end

  private

  def authenticate_user!
    redirect_to login_path unless current_user
  end
end</code></pre></div><p>For a signed-in user, the flow is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">authenticate user
  -&gt;
index</code></pre></div><p>For a guest, the flow is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">authenticate user
  -&gt;
redirect to login
  -&gt;
index does not run</code></pre></div><p>If the request is not allowed to reach the action, the callback should be able to choose a response before the action-specific code runs.</p><p>In controller callbacks, rendering, redirecting, or calling <code>head</code> marks the response as already performed. Rails uses that to halt the normal path before the action runs.</p><p>The internal callback setup is built around that idea. The source-level shape checks whether the controller has already performed a response:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">controller.performed?</code></pre></div><p>You may also see callbacks being halted with:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">throw(:abort)</code></pre></div><p>That belongs to the broader Rails callback vocabulary, especially around model callbacks. For controller <code>before_action</code> debugging, the everyday path is usually more concrete: a callback rendered, redirected, or called <code>head</code>, so the controller has already performed a response.</p><p>That leads to a common debugging question:</p><blockquote><p>Why is my action not running?</p></blockquote><p>The answer is not always routing.</p><p>It may be:</p><blockquote><p>A before callback has already performed the response.</p></blockquote><h2><code>redirect_to</code> Does Not Stop the Current Method</h2><p>One small Ruby detail sits inside that controller behavior.</p><p><code>redirect_to</code> can prevent the action from running, but it does not automatically stop Ruby from executing the rest of the current callback method.</p><p>The subtle bug appears here:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def authenticate_user!
  redirect_to login_path unless current_user

  AuditLoginAttempt.create!(user_agent: request.user_agent)
end</code></pre></div><p>If <code>current_user</code> is missing, Rails will mark a redirect response as performed.</p><p>But Ruby can still continue to the next line inside <code>authenticate_user!</code>.</p><p>So if you want the callback method itself to stop, say so:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def authenticate_user!
  return if current_user

  redirect_to login_path
end</code></pre></div><p>or:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def authenticate_user!
  redirect_to login_path and return unless current_user
end</code></pre></div><p>The distinction:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">redirect_to affects the controller response
return affects the Ruby method</code></pre></div><p>Rails uses the performed response to decide whether the action should continue. Ruby still uses ordinary method control flow inside the callback.</p><h2>Order Matters Because Preconditions Depend on Each Other</h2><p>Once you think of <code>before_action</code> as prerequisites, order becomes easier to reason about. Some prerequisites depend on earlier prerequisites.</p><p>This order makes sense:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ProjectsController &lt; ApplicationController
  before_action :authenticate_user!
  before_action :set_account
  before_action :load_project

  def show
  end

  private

  def set_account
    @account = current_user.accounts.find(params[:account_id])
  end

  def load_project
    @project = @account.projects.find(params[:id])
  end
end</code></pre></div><p>The chain has a dependency:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">authenticate_user!
  -&gt;
set_account
  -&gt;
load_project
  -&gt;
show</code></pre></div><p><code>set_account</code> assumes <code>current_user</code>.</p><p><code>load_project</code> assumes <code>@account</code>.</p><p>The action assumes <code>@project</code>.</p><p>If you reverse the first two callbacks, the controller may try to find an account through a missing user. If you load the project before the account, the lookup has the wrong boundary.</p><p>In complex apps, the chain often grows gradually. One callback is added for authentication. Another for tenancy. Another for a feature flag. Another for authorization. Eventually, the order is not just a list. It is a small execution graph written as a list.</p><p>When this list changes, behavior changes.</p><h2>A Quick Word About <code>around_action</code></h2><p>Most controller callback discussions start with <code>before_action</code>, because it is the one you see most often.</p><p>Rails also has <code>after_action</code>, which runs action processing, and <code>around_action</code>, which wraps action processing.</p><p>For example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">around_action :measure_runtime</code></pre></div><p>An <code>around_action</code> is useful when the callback needs to surround the work rather than simply run before it.</p><p>The flow is close to:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">around_action begins
  -&gt;
action processing yields
  -&gt;
around_action finishes</code></pre></div><p>The exact order can depend on how the callbacks are declared, which is a deeper topic, perhaps for another time. For now, it is enough to see that these callbacks belong to the same controller execution story. Rails runs an action-processing chain, not just calls a method named after the action.</p><h2>The Chain May Be Longer Than the Controller File</h2><p>One reason callbacks surprise people is that the visible controller file may not contain the whole chain.</p><p>Most Rails apps put broad request prerequisites in <code>ApplicationController</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ApplicationController &lt; ActionController::Base
  before_action :set_locale
  before_action :authenticate_user!
end</code></pre></div><p>Then a specific controller adds its own prerequisites:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;2913c9a1-9443-4d9d-a582-266cb3c3a838&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class Admin::ReportsController &lt; ApplicationController
  before_action :require_admin!
  before_action :load_report

  def show
  end
end</code></pre></div><p>The local file shows:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">require_admin!
load_report
show</code></pre></div><p>But the request may actually pass through:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">set_locale
authenticate_user!
require_admin!
load_report
show</code></pre></div><p>Inheritance keeps broad request boundaries out of every individual controller. It also means an action can be affected by code that lives above it.</p><p>So, when debugging callbacks, do not read only the action&#8217;s file.</p><p>Read the controller ancestry:</p><ul><li><p><code>ApplicationController</code></p></li><li><p>namespace base controllers like <code>Admin::BaseController</code></p></li><li><p>included controller concerns</p></li><li><p>authentication or authorization modules</p></li><li><p>any <code>skip_before_action</code> or <code>prepend_before_action</code></p></li></ul><p>The action may be local, but the chain is often inherited.</p><h2>When a Controller Needs an Exception</h2><p>Inherited callbacks are useful until one controller needs a different boundary.</p><p>Suppose <code>ApplicationController</code> requires authentication:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ApplicationController &lt; ActionController::Base
  before_action :authenticate_user!
end</code></pre></div><p>Most controllers should inherit that.</p><p>But a public marketing page may need to skip it:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class PagesController &lt; ApplicationController
  skip_before_action :authenticate_user!, only: [:home]

  def home
  end
end</code></pre></div><p>That is what <code>skip_before_action</code> is for. It removes a callback from the chain for the actions you specify.</p><p>It doesn&#8217;t pass authentication. It changes the callback chain so the authentication callback doesn&#8217;t run for that action.</p><p>Rails also gives you <code>prepend_before_action</code> when a callback must run before callbacks that were already registered.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">prepend_before_action :set_current_tenant</code></pre></div><p>Use that sparingly, however. If callback order is already hard to see, prepending can make the chain even less local. But when a prerequisite must happen first, it is part of the callback toolbox.</p><h2>Conditions Make Preconditions Selective</h2><p>Not every action needs the same prerequisites.</p><p>Rails lets you write:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">before_action :load_invoice, only: [:show, :edit, :update]</code></pre></div><p>or:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">before_action :authenticate_user!, except: [:index, :show]</code></pre></div><p>Conceptually:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">only: :show</code></pre></div><p>means:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">if: -&gt;(controller) { controller.action_name == "show" }</code></pre></div><p>So a callback can be present in the controller and still not apply to the action you are debugging.</p><p>There are two different failure modes:</p><ul><li><p>the callback was not in the chain</p></li><li><p>the callback was in the chain, but its conditions did not match</p></li></ul><p>Rails may have skipped it correctly.</p><h2>A Practical Debugging Path</h2><p>Suppose this request keeps redirecting to login:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">GET /reports</code></pre></div><p>You expected:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">ReportsController#index</code></pre></div><p>to run, but your log line inside the action never appears.</p><p>The action may not have been the first application method Rails tried to run. Something earlier in the chain may have answered the request first.</p><p>Instead of asking only why Rails did not call the action, ask: </p><blockquote><p>What happened before the action?</p></blockquote><p>Walk the request in this order:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Did the route match the controller and action I expected?
  -&gt;
Which before_action callbacks apply?
  -&gt;
Are any inherited from ApplicationController or a parent controller?
  -&gt;
Did one render, redirect, or call head?
  -&gt;
Did the callback order cause an earlier failure?</code></pre></div><p>If you need to inspect the assembled callback chain, Rails exposes it:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">ReportsController._process_action_callbacks.map do |callback|
  [callback.kind, callback.filter]
end</code></pre></div><p>Do not build application behavior on that. Use it as a debugging aid.</p><p>It can show you what Rails has registered around <code>process_action</code>, including callbacks inherited from parent controllers.</p><h2>The Trade-Off</h2><p><code>before_action</code> gives Rails controllers a clean way to express shared request prerequisites.</p><p>The cost is locality. The action body no longer tells the whole story.</p><p>There are request prerequisites:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">before_action :authenticate_user!
before_action :set_current_account
before_action :load_project</code></pre></div><p>These start hiding behavior:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">before_action :calculate_dashboard_metrics
before_action :choose_pricing_experiment
before_action :maybe_create_trial_subscription</code></pre></div><p>Now the callback list is doing more than preparing the request; it is hiding behavior the action probably ought to make visible.</p><p>The question is not: </p><blockquote><p>Should I use callbacks or avoid them?</p></blockquote><p>Instead, ask: </p><blockquote><p>Is this code a prerequisite for the action, or is it the action&#8217;s real work?</p></blockquote><p>If it is a prerequisite, <code>before_action</code> may be a good fit.</p><p>If it is the real work, hiding it in a callback makes the controller harder to read.</p><h2>What to Remember</h2><p><code>before_action</code> exists because Rails actions often need shared preconditions.</p><p>The action should be able to say:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  @comments = @project.comments.recent
end</code></pre></div><p>without repeating every step required to make <code>@project</code> safe and meaningful.</p><p>Rails gives controllers a pre-action layer:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">route matched
  -&gt;
params assembled
  -&gt;
controller instance prepared
  -&gt;
before_action prerequisites
  -&gt;
action-specific code
  -&gt;
response</code></pre></div><p>Internally, that layer is grounded in <code>AbstractController::Callbacks</code>, using <code>ActiveSupport::Callbacks</code>, around <code>the process_action </code>method.</p><p>Once you see callbacks as request preconditions, the trade-offs become easier to evaluate: cleaner action bodies, less local control flow, and a chain you now know how to inspect.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading RailsRevelry. Subscribe for free to follow the next chapter in how Rails works in production.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[How Rails Builds params Before Your Action Runs]]></title><description><![CDATA[`params` is not one thing. It is a dynamic combination of route, query, and request body parameters, making it a flexible and powerful tool for handling requests.]]></description><link>https://railsrevelry.substack.com/p/how-rails-builds-params</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/how-rails-builds-params</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 10 May 2026 10:31:03 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!pqwy!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the last article, we looked at how Rails turns a matched route into a <a href="https://railsrevelry.substack.com/p/how-rails-dispatches-a-request-to">controller action execution</a>. One important idea appeared there briefly:</p><p><code>params</code><em> is assembled before your action runs.</em></p><p>We use <code>params</code> so often that it can feel like it simply shows up in the controller, ready to use.</p><p>But <code>params</code> is not just one thing.</p><p>It is a mashup, pieced together from different parts of the request.</p><p>This is worth understanding because many controller bugs come down to the shape of the <code>params</code>. Maybe a value is missing, or a nested key is not where you thought it would be. Sometimes JSON does not get parsed. Sometimes an <code>id</code> shows up from an unexpected place. Other times, Strong Parameters quietly filters something out, making it seem like Rails never received it in the first place.</p><p>To debug these kinds of problems, it helps to have a clearer picture of where <code>the params come</code> from.</p><p>By the end of this article, you should be able to answer this question clearly:</p><blockquote><p>How does Rails build the <code>params</code> object your controller action reads?</p></blockquote><h2>Let&#8217;s Get the Right Mental Picture</h2><p><code>params</code><em><strong> is a merged request structure built from route, query, and request body params.</strong></em></p><p>When your controller action runs, you get to reach for a single object:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">params</code></pre></div><p>But behind the scenes, Rails pieced that object together from several places:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Route params
Query params
Body params
  -&gt;
controller params</code></pre></div><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!pqwy!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!pqwy!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!pqwy!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!pqwy!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!pqwy!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!pqwy!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1386507,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/196937401?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!pqwy!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!pqwy!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!pqwy!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!pqwy!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1c4d51ad-0dc9-4427-b561-659640ba257a_1536x1024.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>So when you write:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  User.find(params[:id])
end</code></pre></div><p>That line looks like ordinary Ruby.</p><p>But <code>params[:id]</code> is already the result of work Rails did earlier: matching the route, parsing the request, and preparing a controller-facing structure before the action began.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading RailsRevelry. Subscribe for free to get the next Rails systems essay in your inbox.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><h2>The Three Sources of <code>params</code></h2><p>For most controller actions, the useful way to read <code>params</code> is to separate them back into their sources.</p><p>Take this request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">PATCH /users/42?tab=settings</code></pre></div><p>with a form body like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">user[name]=Syed</code></pre></div><p>By the time this request reaches a controller action, Rails may expose all of those values through <code>params</code>. But each piece entered the request through a different door.</p><h3>Route Params Come From the Matched Route</h3><p>Route params are born in the router.</p><p>Suppose your routes include:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">patch "/users/:id", to: "users#update"</code></pre></div><p>and the request is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">PATCH /users/42</code></pre></div><p>The router matches the path pattern and extracts the dynamic segment:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">{ id: "42" }</code></pre></div><p>That value came from the path itself.</p><p>As we saw while <a href="https://railsrevelry.substack.com/p/how-rails-routing-works-turning-urls">understanding how routing worked</a>, dynamic segments become route params. The controller action doesn&#8217;t inspect the URL path and pull out <code>"42"</code> manually. The router already did that work before the dispatch reached the action.</p><p>So when <code>UsersController#update</code> reads:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">params[:id]</code></pre></div><p>It is usually reading a value that the router extracted from the path.</p><p>This shows up quickly when <code>params[:id]</code> is missing. The first question shouldn&#8217;t be &#8220;what did my action do wrong?&#8221; The first question should be:</p><blockquote><p>Did the route that matched this request actually define an <code>:id</code> segment?</p></blockquote><h3>Query Params Come From the URL After <code>?</code></h3><p>Query params come from the query string, the part of the URL after the question mark.</p><p>For a request like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">PATCH /users/42?tab=settings</code></pre></div><p>Rails can parse:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">tab=settings</code></pre></div><p>Query params are common for filters, search terms, pagination, and view state:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">GET /users?page=2&amp;sort=name</code></pre></div><p>Those values are not route params, nor body params. They are request data encoded in the URL itself. But once Rails assembles controller params, you usually access them the same way:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">params[:page]
params[:sort]</code></pre></div><p>That is convenient, but it can hide the source of a value. <code>params[:tab]</code> doesn&#8217;t tell you where <code>tab</code> came from. It only tells you that the <code>tab</code> is present in the merged structure.</p><h3>Body Params Come From Submitted Request Data</h3><p>Request body params come from data submitted in the request body. For a normal HTML form submission, the browser sends fields using names like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">user[name]=Aslam</code></pre></div><p>For a JSON API request, the client might send:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;json&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-json">{
  "user": {
    "name": "Aslam"
  }
}</code></pre></div><p>In controller code, though, Rails still presents them through <code>params</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">params[:user]</code></pre></div><p>That means a single request can contribute to <code>params</code> from multiple places at once.</p><p>For example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">PATCH /users/42?tab=settings</code></pre></div><p>with:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">user[name]=Aslam</code></pre></div><p>can give the controller a merged params share like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;json&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-json">{
  "id" =&gt; "42",
  "tab" =&gt; "settings",
  "user" =&gt; {
    "name" =&gt; "Aslam"
  }
}</code></pre></div><p>At the controller boundary, all of that data shows up through one object, but it didn&#8217;t enter the request as one thing.</p><h3>How Rails Merges Them</h3><p>Once Rails has these pieces, it has to combine them into one structure. The precedence looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">body params &lt; query params &lt; route/path params</code></pre></div><p>Rails starts with request body params, overlays query params, and then overlays route params.</p><p>The source-level shape is roughly:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">request_parameters.merge(query_parameters).merge!(path_parameters)</code></pre></div><ul><li><p><code>request_parameters</code> are body params.</p></li><li><p><code>query_parameters</code> are query string params.</p></li><li><p><code>path_parameters</code> are the route params.</p></li></ul><p>Because later params win, route params take precedence when the same top-level key appears in multiple sources.</p><p>So if you have a route like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">patch "/users/:id", to: "users#update"</code></pre></div><p>and a request like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">PATCH /users/42/id=99</code></pre></div><p>The path still says:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">id = "42"</code></pre></div><p>The query string also says:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">id = "99"</code></pre></div><p>But in the final merged params, the route value wins:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">params[:id] # 42</code></pre></div><p>If a top-level param value seems to come from the &#8220;wrong&#8221; place, remember that <code>params</code> is merged. The final object doesn&#8217;t show you the source history. It only shows you the winning value.</p><div class="callout-block" data-callout="true"><p>A small version note: this merge order has been stable across Rails 5, 6, 7, and 8. Some older 5.x and 6.0 code paths had additional encoding handling around this step, but the precedence remained the same. You can see the current shape in <a href="https://api.rubyonrails.org/classes/ActionDispatch/Http/Parameters.html">ActionDispatch::Http::Parameters#parameters</a>, and the same precedence appears in the older <a href="https://api.rubyonrails.org/v5.1.7/classes/ActionDispatch/Http/Parameters.html">Rails 5.1 API docs</a>.</p></div><h2>How Nested Params Get Their Shape</h2><p>One of the most common surprises with <code>params</code> is that it can contain nested hashes and arrays. That shape often starts with bracket notation.</p><p>For example, an HTML form might submit fields named:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">user[name]=Aslam
user[email]=aslam@example.com</code></pre></div><p>Rails parses those names into a nested structure:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;json&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-json">{
  "user" =&gt; {
    "name" =&gt; "Aslam",
    "email" =&gt; "Aslam@example.com"
  }
}</code></pre></div><p>So the controller code can say:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">params[:user][:name]</code></pre></div><p>The nested hash is not something Rails guessed. It came from the way the submitted fields were named. The same idea applies to deeper nesting:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">user[address][city]=Bangalore</code></pre></div><p>which becomes conceptually:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:&quot;74537a6e-e7c5-4890-a64d-b77bf885e70f&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">{
  "user" =&gt; {
    "address" =&gt; {
      "city" =&gt; "Bangalore"
    }
  }
}</code></pre></div><p>Arrays use bracket notation too:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">ids[]=1&amp;ids[]=2</code></pre></div><p>There are plenty of deeper parser details here, especially around arrays of hashes and invalid shapes. But the rule is enough for most controller debugging:</p><blockquote><p>Nested params usually come from nested parameter names.</p></blockquote><p>If the submitted name is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">name=Aslam</code></pre></div><p>Then you shouldn&#8217;t expect:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">params[:user][:name]</code></pre></div><p>to exist.</p><p>The shape of <code>params</code> follows the shape of the submitted keys.</p><h3>How JSON Body Params Join the Same Structure</h3><p>JSON follows the same broad idea: data in the request body can be included in the <code>params</code>. The difference is the format Rails has to parse.</p><p>Suppose the client sends:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;json&quot;,&quot;nodeId&quot;:&quot;7bbdd389-e841-4b05-929d-cf79fa0f1bcc&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-json">{
  "user": {
    "name": "Syed"
  }
}</code></pre></div><p>with a content type like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Content-Type: application/json</code></pre></div><p>Rails can parse that JSON body and make it available through <code>params</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">params[:user][:name] # Syed</code></pre></div><p>From inside the controller, it can look almost the same as a form submission.</p><p>For form-encoded data, Rails is parsing bracket-style parameter names. For JSON, Rails is parsing a JSON document.</p><p>Either way, the result becomes request body params. Then those body paras are merged into the same final <code>params</code> object as the route and query params.</p><p>If a client sends something that looks like JSON but doesn&#8217;t identify it as JSON, Rails may not parse it the way you expect. API debugging often starts with boring-looking details:</p><ul><li><p>What body did the client send?</p></li><li><p>What <code>Content-Type</code> header did it send?</p></li><li><p>Did Rails parse the body into <code>request.request_parameters</code>?</p></li></ul><p>There is one small Rails behavior worth keeping in mind here.</p><p>If the JSON body is not an object at the root, Rails wraps it under <code>_json</code>.</p><p>The default JSON parameter parser decodes the body and returns the decoded hash directly. But if the decoded value is not a hash, Rails returns:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">{ _json: data }</code></pre></div><p>So a body like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;json&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-json">["a", "b"]</code></pre></div><p>is not merged as a top-level key. Rails needs a key to attach it to, so it becomes available under <code>_json</code>.</p><p>That is not usually the main path in standard Rails controllers, but it is useful when debugging API requests. The behavior is visible in the <code>DEFAULT_PARSERS</code> definition in the <a href="https://api.rubyonrails.org/classes/ActionDispatch/Http/Parameters.html">ActionDispatch::Http::Parameters API docs</a>.</p><h2>Strong Parameters Come Later</h2><p>Strong Parameters are related to <code>params</code>, but they happen at a different step.</p><p><code>params</code> is built first, and the Strong Parameters step decides later which parts are allowed for mass assignment.</p><p>For example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def user_params
  params.require(:user).permit(:name)
end</code></pre></div><p>This code doesn&#8217;t create the incoming <code>user</code> params. It filters the params structure that Rails already built.</p><p>Suppose the <code>email</code> parameter was expected and didn&#8217;t make it through. That doesn&#8217;t mean Rails failed to receive <code>email</code>. Rails might&#8217;ve received it, built it into <code>prams,</code> and then Strong Parameters filtered it out because <code>:email</code> was not permitted.</p><p>So when debugging, separate the two questions:</p><ul><li><p>Did Rails receive and parse the param?</p></li><li><p>Did Strong Parameters permit it?</p></li></ul><p>Those are different problems: one is about request parsing and merging; the other is about controller-level filtering.</p><h2>Debugging When <code>params</code> Look Wrong</h2><p>When <code>params</code> don&#8217;t look right, it&#8217;s easy to focus on the controller action. But often, the issue starts earlier in the request process.</p><p>Here are some helpful questions to ask:</p><h3>Is the Missing Param Actually in the Route?</h3><p>If <code>params[:id]</code> is missing, start with the route. A route like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/users", to: "users#show"</code></pre></div><p>doesn&#8217;t define an <code>:id</code> segment.</p><p>So this request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">GET /users?id=42</code></pre></div><p>may still give you an <code>id</code>, but that value came from the query string, not the route.</p><p>With you have defined a route like the following, the <code>id</code> comes from the path:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/users/:id", to: "users#show"</code></pre></div><p>When in doubt, inspect the route set:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">bin/rails routes</code></pre></div><p>The route pattern tells you which path parameter can exist.</p><h3>Did a Collision Hide the Value You Expected?</h3><p>If the same key appears in more than one source, the final <code>params</code> object only shows one value.</p><p>For example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">PATCH /users/42?id=99</code></pre></div><p>You might expect the query string value to take precedence because it appears at the end of the URL. But Rails merges route params last, so:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">params[:id] # "42"</code></pre></div><p>If a value looks surprising, compare the sources instead of only looking at the merged result:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">request.path_parameters
request.query_parameters
request.request_parameters</code></pre></div><p>Those three methods show you the pieces before they become the final controller-facing <code>params</code>. Often, comparing those three sources is the fastest way to find where the confusion entered.</p><h3>Did the Submitted Shape Match the Code?</h3><p>Nested params depend on submitted key names. If your controller expects:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">params[:user][:name]</code></pre></div><p>then the submitted form field needs a nested name like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">user[name]</code></pre></div><p>If the field was submitted just as <code>name</code>, then the shape is different: <code>params[:name]</code>.</p><p>The issue isn&#8217;t with <code>the params themselves</code>; it&#8217;s a mismatch between the structure of the submitted data and what the controller expects.</p><p>This happens often in custom forms, API clients, JavaScript submissions, and tests that build params by hand.</p><h3>Did Rails Parse the Body?</h3><p>If the body params are missing, check whether Rails parsed the body at all. For JSON requests, the first place to look is the content type.</p><p>A client might send a body that looks like JSON:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;markdown&quot;,&quot;nodeId&quot;:&quot;43a9c9da-2ba1-4908-bf5d-dffdc0805507&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-markdown">{
  "user": {
    "name": "Syed"
  }
}</code></pre></div><p>But if the request doesn&#8217;t identify it as JSON, Rails may not put it into <code>params</code> the way your controller expects. So this header matters:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Content-Type: application/json</code></pre></div><p>Again, the useful debugging move is to separate the sources:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">request.request_parameters</code></pre></div><p>If the body params are missing, the issue is earlier than Strong Parameters and before the action logic.</p><h3>Is This Really a Symbol or a String?</h3><p>Inside controllers, <code>params</code> is usually forgiving about symbol and string access:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">params[:user]
params["user"]</code></pre></div><p>Both work in normal controller code.</p><p>Rails wraps params in <code>ActionController::Parameters</code>, which supports indifferent access for this kind of lookup. But the confusion can appear after you convert or pass data around:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">params[:user].to_h</code></pre></div><p>Or, when a plain Ruby hash enters the picture in tests or service objects.</p><blockquote><p>Controller params are forgiving. Plain hashes may not be.</p></blockquote><p>So if symbol and string access behave differently, ask whether you are still working with <code>ActionController::Parameters</code> or whether the data has been converted into an ordinary hash.</p><h2>What to Remember</h2><p><code>params</code> is not a single object that simply appears inside your action. It is the result of parsing and merging request data.</p><p>The shape looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">request body params
  + 
query string params
  + 
route/path params
  -&gt;
params before your action runs</code></pre></div><p>And the precedence is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">body params &lt; query params &lt; route/path params</code></pre></div><p>That means:</p><ul><li><p>body data supplies submitted form or JSON values</p></li><li><p>query data supplies values from the URL after <code>?</code></p></li><li><p>route data supplies values extracted from the matched path</p></li><li><p>route params win when top-level keys collide</p></li><li><p>Strong Parameters filter the structure after it has already been built</p></li></ul><p>The controller action in not where request data begins. It reads a request structure that Rails has already assembled.</p><p>And once the action has read that request structure, Rails still has another job ahead: <em>turning the action&#8217;s result into an HTTP response</em>.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading RailsRevelry. Subscribe for free to follow the next chapter in how Rails works in production.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p></p>]]></content:encoded></item><item><title><![CDATA[How Rails Dispatches a Request to a Controller]]></title><description><![CDATA[A clear explanation of what happens after routing chooses an endpoint: controller resolution, params setup, callback execution, and response handling.]]></description><link>https://railsrevelry.substack.com/p/how-rails-dispatches-a-request-to</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/how-rails-dispatches-a-request-to</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 03 May 2026 15:57:26 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!6C4w!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the <a href="https://railsrevelry.substack.com/p/how-rails-routing-works-turning-urls">last article</a>, we looked at how the router matches a request and decides which controller action should handle it.</p><p>But that decision is only the handoff.</p><p>Once a route is chosen, Rails still has to turn that decision into an actual method call on a controller. It has to resolve the controller, build a fresh controller instance, assemble the request state, run callbacks, and only then execute the action.</p><p>That step is called <em><strong>dispatch</strong></em>.</p><p>From the outside, this part of Rails feels deceptively simple:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">route matches &#11106; action runs</code></pre></div><p>Internally, there is more structure than that.</p><p>By the end, you should be able to answer this question clearly:</p><blockquote><p>What actually happens between the router choosing a route and my controller action running?</p></blockquote><h2>Start With the Right Mental Model</h2><p><strong>Controller dispatch is the process of turning a matched route into a controller action execution.</strong></p><p>The router decides where the request should go. Dispatch decides how that decision becomes live application code.</p><p>The flow looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Route match
  &#11106;
Controller class identified
  &#11106;
Controller instance created
  &#11106;
Request context and params assigned
  &#11106;
Callbacks run
  &#11106;
Action invoked
  &#11106;
Response returned</code></pre></div><p>That flow explains most controller-level confusion:</p><blockquote><p>The router decides where the request goes.</p><p>Dispatch decides how it gets executed.</p></blockquote><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!6C4w!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!6C4w!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png 424w, https://substackcdn.com/image/fetch/$s_!6C4w!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png 848w, https://substackcdn.com/image/fetch/$s_!6C4w!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png 1272w, https://substackcdn.com/image/fetch/$s_!6C4w!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!6C4w!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png" width="1456" height="799" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:799,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:926521,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/196318017?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!6C4w!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png 424w, https://substackcdn.com/image/fetch/$s_!6C4w!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png 848w, https://substackcdn.com/image/fetch/$s_!6C4w!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png 1272w, https://substackcdn.com/image/fetch/$s_!6C4w!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb674c2d0-a850-42db-ab66-8e7ae991976a_1693x929.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Rails request dispatch in one view: routing identifies the target, and the dispatch layer turns that target into controller execution.</figcaption></figure></div><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading RailsRevelry. Subscribe for free to get the next Rails systems essay in your inbox.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><h2>What the Router Actually Hands Off</h2><p>By the time dispatch begins, the router has already done its job. </p><p>For a route like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">get "/users/:id", to: "users#show"</code></pre></div><p>and a request like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">GET /users/42</code></pre></div><p>Rails has already identified the basic routing outcome:</p><ul><li><p>controller name: <code>"users"</code></p></li><li><p>action name: <code>"show"</code></p></li><li><p>route params: <code>{ id: "42" }</code></p></li></ul><p>At this point, Rails has still not run controller code. It has only identified what should run.</p><p><a href="https://railsrevelry.substack.com/p/how-rails-routing-works-turning-urls">Route recognition gives Rails a target</a>. Dispatch turns that target into execution.</p><h2>Routing Produces Identifiers. Dispatch Turns Them into Objects</h2><p>The route target usually appears in Rails code as something like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">"users#show"</code></pre></div><p>That is not yet a controller instance. It is a pair of identifiers:</p><ul><li><p>controller path: <code>"users"</code></p></li><li><p>action name: <code>"show"</code></p></li></ul><p>During dispatch, Rails resolves that controller path into the actual controller class:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">"users" &#11106; UsersController</code></pre></div><p>For a namespaced controller, the same pattern applies:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">"admin/reports" &#11106; Admin::ReportsController</code></pre></div><p>The routing layer and controller layer are separate parts of the framework. The route set identifies the target in a form Rails can carry forward, and then the controller dispatch layer resolves that target into the controller class that will actually handle the request.</p><p>There is no need to memorize the internal classes involved for this distinction to be useful.</p><blockquote><p>Routing produces identifiers. Dispatch turns them into objects.</p></blockquote><h2>Each Request Gets a Fresh Controller Instance</h2><p>Once Rails knows which controller class should handle the request, it creates a new instance of that controller for the request.</p><p>That means if the request is headed to:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">UsersController#show</code></pre></div><p>Rails doesn&#8217;t reuse some long-lived controller object sitting around in memory waiting for work. It instantiates a fresh controller object for this request.</p><ul><li><p>controller instance variables are request-scoped</p></li><li><p>one request does not share the controller instance state with another</p></li><li><p>controller objects are part of per-request execution, not long-lived application state</p></li></ul><blockquote><p>Each request gets a fresh controller instance.</p></blockquote><p>The object exists for one request-shaped unit of work.</p><h2>Before the Action Runs, Rails Builds the Request Context</h2><p>Once the controller instance exists, Rails still is not ready to run your action method.</p><p>It first has to attach the request context that controller code expects to exist.</p><ul><li><p><code>request</code></p></li><li><p><code>response</code></p></li><li><p><code>params</code></p></li><li><p><code>session</code></p></li><li><p><code>cookies</code></p></li></ul><p>become available through the controller layer.</p><p>That doesn&#8217;t mean all of that state is born in the controller itself. Much of it was prepared earlier by <a href="https://railsrevelry.substack.com/p/inside-the-rails-middleware-stack">middleware</a> or <a href="https://railsrevelry.substack.com/p/how-rails-routing-works-turning-urls">routing</a>. But by the time dispatch sets up the controller execution context, Rails has gathered that state into the shape the controller code knows how to use.</p><p>By the time your action starts, a lot of the system work has already happened.</p><h2>How <code>Params Are</code> Built Before Your Action Starts</h2><p>One of the most important pieces of that request context is <code>params</code>.</p><p>We use <code>params</code> constantly, but it is easy to treat them as if they just appear fully formed inside the action. They do not.</p><p><code>params</code> is assembled before your action runs.</p><p>Conceptually, Rails is combining multiple sources of request data:</p><ul><li><p>route params</p></li><li><p>query params</p></li><li><p>request body params</p></li></ul><p>So if you have a route like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/users/:id", to: "users#show"</code></pre></div><p>and a request like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /users/42?tab=profile</code></pre></div><p>then by the time the action runs, the controller can see values that came from different places:</p><ul><li><p>route param: <code>id = &#8220;42&#8221;</code></p></li><li><p>query param: <code>tab = &#8220;profile&#8221;</code></p></li></ul><p>Similarly, for a form submission or JSON request, body data joins the picture too.</p><p>The exact internal mechanics of parameter parsing deserve their own article<a class="footnote-anchor" data-component-name="FootnoteAnchorToDOM" id="footnote-anchor-1" href="#footnote-1" target="_self">1</a>, so this piece should not overload that part of the system. But the distinction belongs here:</p><blockquote><p><code>params</code> is not created by your action. It is assembled before your action begins. </p></blockquote><p>It also explains one common class of confusion: when <code>params</code> look wrong, the cause is often earlier than the action itself.</p><h2>Your Action Does Not Run in Isolation</h2><p>At this point, Rails has:</p><ul><li><p>identified the controller class</p></li><li><p>created a controller instance</p></li><li><p>prepared request state</p></li><li><p>made <code>params</code> available</p></li></ul><p>At first glance, the next step seems simple:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">call the action method</code></pre></div><p>But there is still one more layer that matters a lot in real applications. </p><p><strong>Callbacks</strong>.</p><blockquote><p>Your action runs inside a callback chain.</p></blockquote><p>The simplified shape looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">before_action &#11106; action &#11106; after_action</code></pre></div><p>That means the controller action is not an isolated Ruby method call. It runs inside a framework-managed pipeline that can prepare the state before the action and react after it.</p><p>Sometimes the thing affecting the request happened before the method you are looking at ever started.</p><h2>Why <code>before_action</code> Order Matters</h2><p><code>before_action</code> is a good example of how dispatch gives Rails structure and also hides some visibility.</p><p>Suppose you have:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class UsersController &lt; ApplicationController
  before_action :authenticate_user!
  before_action :load_user

  def show
  end

  private
 
  def authenticate_user!
    redirect_to login_path unless current_user
  end

  def load_user
    @user = User.find(params[:id])
  end
end</code></pre></div><p>This looks straightforward, but the order matters.</p><p>The <code>authenticate_user!</code> redirects, Rails can halt the normal path before the action runs. Depending on the callback setup, later callbacks may not behave the way you expected either.</p><p>Callback order is part of request execution, not decoration around it.</p><p>If something feels wrong in a controller, it is not enough to ask only what the action does.</p><p>Also ask:</p><ul><li><p>which callbacks ran first?</p></li><li><p>did one of them redirect or render early?</p></li><li><p>did the action run at all?</p></li></ul><h2>Then Rails Invokes the Action Method</h2><p>Once the callback chain has reached the action, Rails finally invokes the method corresponding to the action name. So if dispatch resolved:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">controller: "users"
action: "show"</code></pre></div><p>Rails eventually executes the <code>show</code> instance method on the fresh <code>UsersController</code> instance created for the request.</p><p>That method call is the result of a sequence, not the whole story.</p><p>By the time Rails gets there, the request has already passed through:</p><ul><li><p>middleware</p></li><li><p>routing</p></li><li><p>controller resolution</p></li><li><p>request-context setup</p></li><li><p>params assembly</p></li><li><p>callback execution</p></li></ul><h2>Rendering vs Returning</h2><p>There is one more controller misconception worth clearing up here.</p><p>Many Ruby methods are understood mainly through their return value. Controllers are different.</p><p>In Rails, what matters most is not the action&#8217;s final Ruby expression. What matters is whether the action performs a response.</p><p>That response might happen through:</p><ul><li><p><code>render</code></p></li><li><p><code>redirect_to</code></p></li><li><p><code>head</code></p></li><li><p>or implicit rendering when Rails chooses a template for you</p></li></ul><blockquote><p>The controller action does not &#8220;return HTML&#8221; directly. It performs a response.</p></blockquote><p>That is why an action can end with something like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  @user = User.find(params[:id])
end</code></pre></div><p>and still produce a response through implicit rendering.</p><p>It also explains why this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">def show
  return "hello"
end</code></pre></div><p>doesn&#8217;t mean what a normal Ruby method return would suggest in a controller context.</p><p>Rails is not treating your action like an ordinary function whose final value becomes the HTTP response body.</p><p>It is treating the action as one step in a controller dispatch and response-building process.</p><h2>A Simple Dispatch Diagram</h2><p>Here is the whole controller-dispatch path in one view:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Matched route

controller + action identified
  &#11106;
controller instance created
  &#11106;
params and request context prepared
  &#11106;
before_action chain
  &#11106;
action method
  &#11106;
render/redirect/performed response</code></pre></div><p>And the callback portion in isolation:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">before_action &#11106; action &#11106; after_action</code></pre></div><h2>A Real Debugging Scenario</h2><p>Let us make this practical with one example.</p><p>Suppose you are debugging a controller and asking: <em>Why is my </em><code>before_action</code><em> not running?</em></p><p>The first instinct is often to open the controller and stare at the callback declarations. Sometimes that is the right place.</p><p>But sometimes, the issue is earlier in dispatch:</p><ul><li><p>the route matched a different controller than you expected</p></li><li><p>the request never reached this action</p></li><li><p>another callback halted execution earlier</p></li><li><p>the action name was different from what you thought</p></li></ul><p>Or take another question: <em>Why is </em><code>params[:id]</code><em> missing?</em></p><p>Again, the problem may not be inside the action at all.</p><p>It may be:</p><ul><li><p>the route pattern was different from what you assumed</p></li><li><p>the request matched a different route</p></li><li><p>the expected value was in the query string instead of the path</p></li><li><p>the request body did not parse the way you thought</p></li></ul><blockquote><p>When something feels wrong in a controller, the issue is often earlier in dispatch.</p></blockquote><h2>Why Rails Is Designed This Way</h2><p>This dispatch layer exists because Rails is trying to separate several concerns that would otherwise blur together:</p><ul><li><p>route recognition</p></li><li><p>controller resolution</p></li><li><p>request preparation</p></li><li><p>callback execution</p></li><li><p>response handling</p></li></ul><p>That separation gives Rails a lot:</p><ul><li><p>structure</p></li><li><p>extensibility</p></li><li><p>a place for callbacks</p></li><li><p>a place for request setup</p></li><li><p>a controller abstraction that feels consistent</p></li></ul><p>But it also creates distance between &#8220;I matched a route&#8221; and &#8220;my code ran.&#8221;</p><p>You gain structure, but you lose some visibility.</p><h2>The Final Mental Model</h2><p>Routing decides where a request should go. Dispatch decides how that request gets executed.</p><p>The full flow now looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Request
  &#11106;
Middleware
  &#11106;
Dispatch
  &#11106;
Controller
  &#11106;
Response
  &#11106;
Middleware
  &#11106;
Client</code></pre></div><p>And inside dispatch, the shape is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">matched route
  &#11106;
controller resolved
  &#11106;
controller instantiated
  &#11106;
params prepared
  &#11106;
callbacks run
  &#11106;
action executed
  &#11106;
response performed</code></pre></div><p>Internally, Rails uses ActionController&#8217;s dispatch mechanism to orchestrate this sequence, but you do not need to understand those classes to reason about how requests flow.</p><p>Once you understand dispatch, the controller is no longer the starting point.</p><p>It becomes one step in a larger system.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading RailsRevelry. Subscribe for free to follow the next chapter in how Rails works in production.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><div class="footnote" data-component-name="FootnoteToDOM"><a id="footnote-1" href="#footnote-anchor-1" class="footnote-number" contenteditable="false" target="_self">1</a><div class="footnote-content"><p>See <a href="https://railsrevelry.substack.com/p/how-rails-builds-params">How Rails Builds </a><code>params</code><a href="https://railsrevelry.substack.com/p/how-rails-builds-params"> Before Your Action Runs</a></p></div></div>]]></content:encoded></item><item><title><![CDATA[How Rails Routing Works: Turning URLs into Controller Actions]]></title><description><![CDATA[A clear mental model for how Rails matches requests, extracts params, and dispatches controller actions.]]></description><link>https://railsrevelry.substack.com/p/how-rails-routing-works-turning-urls</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/how-rails-routing-works-turning-urls</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 26 Apr 2026 09:30:20 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!-I0h!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the first RailsRevelry article, we traced a <a href="https://railsrevelry.substack.com/p/from-rack-to-controller-understanding">request from Rack to the controller</a>. In the second, we zoomed in on the <a href="https://railsrevelry.substack.com/p/inside-the-rails-middleware-stack">middleware stack</a> and looked at the layers that shape the request before it ever reaches your application code.</p><p>That brings us to the <em>router</em>.</p><p>Once a request makes it through middleware, Rails still has to answer a simple question before any controller action can run: <em> </em><strong>Where does this request go?</strong></p><p>That sounds obvious when the route is small and familiar. You write <code>get "/users/:id", to: "users#show"</code>, the request comes in, and Rails dispatches to <code>UsersController#show</code>. But the more time you spend in real Rails applications, the more routing starts to carry real weight. A route file stops being a small list of declarations and starts deciding which requests exist, which controllers see them, which params get extracted from the path, which HTTP verbs are allowed, and in what order all of those decisions are made.</p><p>By the end of this article, you should be able to answer this question clearly:</p><blockquote><p>How does Rails take an incoming request and decide which controller action should handle it?</p></blockquote><h2>Start With the Right Mental Model</h2><p>The cleanest mental model for Rails routing is this:</p><p><em>Routing is a pattern matching plus dispatch.</em></p><p>An incoming request reaches the router with a method, a path, and some request metadata. The router compares that request against the routes you defined in <code>config/routes.rb</code>. When it finds the first matching route, it extracts any dynamic segments from the path, builds the route params, and dispatches to the target endpoint.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!-I0h!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!-I0h!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!-I0h!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!-I0h!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!-I0h!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!-I0h!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1050407,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/195440758?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!-I0h!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!-I0h!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!-I0h!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!-I0h!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0c456a1e-eaef-425c-a5d6-e7fcba0b1846_1536x1024.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Rails routing in one view: match the request, extract path params, and dispatch to the controller action.</figcaption></figure></div><p>So for a request like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /users/42</code></pre></div><p>and a route like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/users/:id, to: "users#show"</code></pre></div><p>Rails recognizes that:</p><ul><li><p>the HTTP verb is <code>GET</code></p></li><li><p>the path pattern matches <code>/users/:id</code></p></li><li><p>the dynamic segment <code>:id</code> has the value <code>"42"</code></p></li><li><p>the endpoint is <code>UsersController#show</code></p></li></ul><p>So the router hands off control with route params that include: <code>{ id: "42" }</code></p><p>It decides where the request goes.</p><p>Here is the routing flow in its simplest, useful form:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;845e9845-ba39-4e94-98b3-f7f53656f581&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Incoming request (verb + path)
        &#8595;
Router scans the route set in order
        &#8595;
First matching route wins
        &#8595;
Dynamic segments become params
        &#8595;
Controller action dispatch</code></pre></div><h2>What the Router Actually Receives</h2><p>By the time a request reaches the router, Rails has already done some work around it. The app server received the raw HTTP request. Rack defined the application interface. Middleware may already have assigned a request ID, parsed cookies, loaded the session, or even stopped the request early.</p><p>If the request survives that path, the router sees something much closer to an application-level request.</p><p>But the router is still not thinking in terms of models or views. It is looking at a smaller set of questions:</p><ul><li><p>What HTTP method is this request using?</p></li><li><p>What path did the client ask for?</p></li><li><p>Do any route constraints apply?</p></li><li><p>Which route matches first?</p></li><li><p>What dynamic values should be extracted from the path?</p></li><li><p>Which controller action or Rack endpoint should receive the request?</p></li></ul><p>Routing is not about &#8220;what the request means&#8221; in a business sense. It is about recognition and handoff.</p><h2><code>config/routes.rb</code> Builds the Route Set</h2><p>When you define routes in Rails, you are not writing imperative code that runs for every request. You are declaring the route set that Rails will use later to recognize requests and generate URLs.</p><p>That route set usually lives in <code>config/routes.rb</code>.</p><p>A small example might look like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">Rails.application.routes.draw do
  root "home#index"

  get "/users/:id", to: "users#show"
  post "/users", to: "users#create"
end</code></pre></div><p>This file defines the shapes of requests that your application can recognize.</p><p>The route file is part of your application&#8217;s public surface area. It says:</p><ul><li><p>these URLs exist</p></li><li><p>these HTTP verbs are valid for them</p></li><li><p>these requests map to these endpoints</p></li></ul><p>Routes define how the outside world enters your system.</p><h2>Resourceful Routing Is a DSL Over Concrete Routes</h2><p>Most Rails applications lean heavily on resourceful routing.</p><p>Your write: </p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">resources :users</code></pre></div><p>and Rails expands that into a set of concrete routes for the standard CRUD actions.</p><p>Conceptually, that single line becomes something like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET     /users            -&gt; users#index
GET     /users/new        -&gt; users#new
POST    /users            -&gt; users#create
GET     /users/:id        -&gt; users#show
GET     /users/:id/edit   -&gt; users#edit
PATCH   /users/:id        -&gt; users#update
PUT     /users/:id        -&gt; users#update
DELETE  /users/:id        -&gt; users#destroy</code></pre></div><p>The <code>resources</code> DSL feels high-level, but the router still matches concrete route patterns.</p><p>Resourceful routing is a compact way to generate a predictable set of routes.</p><h2>Route Order Matters More Than Many People Expect</h2><p>One of the most important properties of the Rails router is also one of the easiest to miss:</p><blockquote><p>The router uses the first route that matches.</p></blockquote><p>That means order is behavior.</p><p>Suppose your routes look like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/users/:id", to: "users#show"
get "/users/new", to: "users#new</code></pre></div><p>At first glance, both routes seem valid. But the order is wrong.</p><p>For a request is like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /users/new</code></pre></div><p>the first route also matches, because <code>"new"</code> can be captured as <code>:id</code>.</p><p>So Rails routes the request to <code>UsersController#show</code> with:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">{ id: "new" }</code></pre></div><p>The correct order is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/users/new", to: "users#new"
get "/users/:id", to: "users#show"</code></pre></div><p>That follows directly from the router&#8217;s model.</p><h2> Dynamic Segments Become Route Params</h2><p>When a route includes dynamic path segments, Rails extracts those values from the path and makes them available as route params.</p><p>For example:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/users/:id", to: "users#show"</code></pre></div><p>paired with:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /users/42</code></pre></div><p>produces:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">{ id: "42" }</code></pre></div><p>If the route contains more than one dynamic segment, Rails extracts all of them:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/accounts/:account_id/users/:id", to: "users#show"</code></pre></div><p>with:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /accounts/7/users/42</code></pre></div><p>becomes:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">{ account_id: "7", id: "42" }</code></pre></div><p>Path params are born in the router.</p><p>By the time the request reaches your controller, those values have already been extracted from the URL and merged into the request&#8217;s params structure.</p><h2>Namespaces and Nested Routes Change the Shape, Not the Core Model</h2><p>As route files grow, you start seeing things like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">namespace :admin do
  resources :users
end</code></pre></div><p>or:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">resources :accounts do
  resources :users
end</code></pre></div><p>These can make routing feel more abstract, but the recognition model has not changed.</p><p>Namespacing changes the URL prefix and the controller target.</p><p>Nesting changes the path shape and gives the router more dynamic segments to extract, such as <code>:account_id</code>.</p><p>But in both cases, Rails is still doing the same job: <em>matching the request against a concrete route set and dispatching to the first matching endpoint.</em> </p><h2>The Router Also Generates URLs, But That Is a Separate Direction</h2><p>The Rails router has a second major responsibility: It generates paths and URLs through helpers like <code>users_path(@user)</code>.</p><p>That matters a lot in real applications, but it is a different direction of travel.</p><p>In this article, we are focused on incoming request recognition:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Request &#8594; route match &#8594; controller action</code></pre></div><p>URL generation works in the opposite direction:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Application code &#8594; router helper &#8594; URL/path string</code></pre></div><p>These two responsibilities reside in the same routing system, but they are not the same.</p><p>For now, the important idea is that when an incoming request reaches the router, Rails solves a recognition problem before it does anything else.</p><h2>The HTTP Vert Is Part of the Route</h2><p>Developers often think of routes mostly in terms of paths, but Rails routes are defined by both the path and the HTTP verb.</p><p>These two routes are not the same:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/users", to: "users#index"
post "/users", to: "users#create"</code></pre></div><p>They share the same path. They differ by method.</p><p>That means:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /users</code></pre></div><p>and:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">POST /users</code></pre></div><p>are different requests from the router&#8217;s perspective, even though the URL path is identical.</p><p>This is one reason resourceful routing works so well for CRUD. Rails can reuse the same path shape while dispatching to different actions based on the HTTP verb.</p><p>It is also why route bugs sometimes appear when forms, JavaScript requests, or proxies send a method you were not expecting. If the verb does not match the route, the route does not match the request.</p><h2>Query Parameters Are Not Usually What the Router Matches On</h2><p>The router mainly matches on the request path and the HTTP verb.</p><p>So for a route like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/users", to: "users#index</code></pre></div><p>Both of these requests match the same route:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /users
GET /users?page=2&amp;sort=name</code></pre></div><p>The query string affects the request params your controller can read, but it does not usually determine which route matches.</p><p>Route definitions tend to describe structural path shapes rather than every possible filtering or pagination parameter.</p><p>There are advanced forms of request-based constraints, but the core mental model should stay simple:</p><blockquote><p>The router mainly decides based on the method and path.</p></blockquote><h2>Routing Is the Boundary Between URL Shape and Controller Dispatch</h2><p>Routing is the boundary where the external request shape becomes the internal application structure.</p><p>A route like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/admin/reports", to: "admin/reports#index</code></pre></div><p>does two things at once.</p><p>It defines the public URL shape.</p><p>It also defines an internal handoff to a controller namespace and action.</p><p>Routing choices influence:</p><ul><li><p>how obvious your application&#8217;s entry points are</p></li><li><p>whether related endpoints feel coherent</p></li><li><p>how much meaning is embedded in the URL structure</p></li><li><p>whether controller boundaries stay understandable</p></li></ul><p>The route file is not business logic, but it absolutely shapes the architecture around your application&#8217;s boundaries.</p><h2>Where Routing Bugs Usually Come From</h2><p>Once you understand the router as an ordered pattern matcher, several common bugs become much easier to explain.</p><p>Wrong action runs: <em>Usually, a route higher in the file is matched first.</em></p><p>Expected path does not work: <em>Often, the HTTP verb does not match the route definition, or the path shape is different from what you assumed.</em></p><p>Unexpected params: <em>Often, a dynamic segment captured part of the path you did not realize it could capture.</em></p><p>Specific route never seems to run: <em>Often, a more general route appears above it and absorbs the request first.</em></p><p>Nested controller gets surprising IDs: <em>Those values usually come directly from route segments like </em><code>:account_id</code><em> or </em><code>:project_id</code><em>.</em></p><p>The router is deterministic. When routing feels strange, your mental model of the route set usually does not yet match the actual one.</p><h2>How to Inspect What Rails Thinks the Routes Are</h2><p>When route behavior feels confusing, do not guess. Inspect the route set.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">bin/rails routes
bin/rails routes -g users</code></pre></div><p>These let you see the concrete routes Rails built from your DSL.</p><p>That matters especially when you are using:</p><ul><li><p><code>resources</code></p></li><li><p>namespaces</p></li><li><p>nesting</p></li><li><p>concerns</p></li><li><p>custom member or collection routes</p></li><li><p>constraints</p></li></ul><p>The route file can look elegant while still producing a route set you did not fully anticipate. Inspecting the actual routes closes that gap.</p><h2>A Request Through the Router</h2><p>Let us trace one request cleanly from the router&#8217;s point of view:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /users/42</code></pre></div><p>Suppose the route set contains:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/users/new", to: "users#new"
get "/users/:id", to: "users#show"</code></pre></div><p>The router checks the request against the route set in order.</p><p>First route:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /users/new</code></pre></div><p>That doesn&#8217;t match <code>/users/42</code></p><p>Second route:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /users/:id</code></pre></div><p>That does match.</p><p>Rails extracts:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">{ id: "42" }</code></pre></div><p>and dispatches to:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">UsersController#show</code></pre></div><p>In route definitions, Rails expresses the target as "users#show": controller "users", action: "show". During dispatch, Rails resolves that controller path into <code>UsersController</code>, so the request ultimately lands in <code>UsersController#show</code>.</p><p>At that point, the router&#8217;s main job for this request is done.</p><p>The request moves into controller dispatch with a chosen endpoint and a set of route params.</p><h2>The Final Mental Model</h2><p>The Rails router is the decision point between a request surviving middleware and a controller action beginning to run.</p><p>Its job is simpler than people sometimes imagine, but also more important.</p><p>It receives an incoming request.</p><p>It compares that request against the route set.</p><p>It finds the first route that matches the method, path, and any relevant constraints.</p><p>It extracts dynamic path values into route params.</p><p>It dispatches to the target endpoint.</p><p>Routing is not &#8220;just URLs.&#8221; It turns an external request shape into an internal application handoff.</p><p>So when you think about Rails routing, keep the model small and precise:</p><blockquote><p>Routing is pattern matching plus dispatch.</p></blockquote><p>Order matters.</p><p>Dynamic segments become params.</p><p>HTTP verbs are part of the route.</p><p>And by the time your controller starts running, the router has already decided both where the request goes and which path data accompanies it.</p><p>Once you see routing that way,  <code>config/routes.rb</code> becomes visible, an inspectable boundary between the web and your application code.</p><p></p>]]></content:encoded></item><item><title><![CDATA[Inside the Rails Middleware Stack: How Requests Actually Move Through Your App]]></title><description><![CDATA[Rails middleware is not just a list of steps that run before your controller. It is a chain of Rack wrappers around your app, and that one mental model explains request IDs, sessions, redirects, headers, and why some requests never reach your controller at all.]]></description><link>https://railsrevelry.substack.com/p/inside-the-rails-middleware-stack</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/inside-the-rails-middleware-stack</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 19 Apr 2026 13:01:05 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!VQC-!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa9c2d72d-eaf1-46bc-bf53-fe489f91c036_1254x1254.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the <a href="https://railsrevelry.substack.com/p/from-rack-to-controller-understanding">last article</a>, we traced a request from Rack to the controller. If you followed that flow closely, one part of the system should&#8217;ve stood out immediately: the middleware stack does an enormous amount of work before your controller ever sees the request.</p><p>When Rails behaves in a way that is hard to explain from the controller alone, middleware is often somewhere in the story. A request gets redirected before your action runs. A session appears to exist before you touched it. A response leaves the app with headers you never set. A request never reaches the controller at all. None of that is random. The middleware stack is usually one of the first places where the explanation begins.</p><p>Most Rails developers know middleware exists. They&#8217;ve seen <code>bin/rails middleware</code>, they have seen classes like <code>ActionDispatch::Cookies</code> or <code>Rack::MethodOverride</code>, and they know these layers sit &#8220;somewhere before the controller&#8221;. That vague sense is not enough when you need to reason about the system.</p><p>By the end, you should be able to answer this question with confidence:</p><blockquote><p>What exactly is happening inside the middleware stack, and how do individual layers affect my request and response?</p></blockquote><h2>Start With the Right Mental Model</h2><p>The most useful place to start is with a correction.</p><p>Middleware is not a list of steps that Rails runs before your controller.</p><p>It is a set of nested wrappers around your application.</p><p>That changes how you think about the request lifecycle. If middleware were just a list of pre-controller steps, then it would only matter on the way in. But that is not how Rack works, and Rails middleware inherits its shape from Rack.</p><p>The better mental model looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Request
-&gt; Middleware A
  -&gt; Middleware B
    -&gt; Middleware C
      -&gt; Rails router/controller
    &lt;- Middleware C
  &lt;- Middleware B
&lt;- Middleware A
Response</code></pre></div><p>The request travels inward through the stack until it reaches Rails routing and controller dispatch. Then the response travels back out through those same layers in reverse order.</p><p>Middleware can change the request before your action runs, change the response after your action finishes, or stop the request before it ever reaches your routes.</p><h2>Why Middleware Exists at All</h2><p>Rails middleware exists because Rails is a Rack application.</p><p>Rack is the shared interface between Ruby web servers and Ruby web frameworks. Instead of Puma needing to understand every detail of Rails' internals, Rack provides a common contract. A Rack application is just an object that responds to <code>call</code> and returns a response in a standard shape.</p><p>That shape is small:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">call(env) # =&gt; [status, headers, body]</code></pre></div><p>You can write a complete Rack app in a few lines:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class HelloWorld
  def call(env)
    [
      200,
      { "Content-Type" =&gt; "text/plain" },
      ["Hello from Rack"]
    ]
  end
end</code></pre></div><p>That app receives a request environment and returns a three-part response:</p><ul><li><p>a status code</p></li><li><p>a headers hash</p></li><li><p>a body</p></li></ul><p>Rails is obviously much more sophisticated than that. It includes routers, controllers, rendering, sessions, cookies, callbacks, and much more. But underneath those abstractions, it still participates in the same Rack contract. At the server boundary, Rails is just another Rack app that can receive <code>env</code> and return <code>[status, headers, body]</code>.</p><p>Middleware can wrap Rails because it speaks the same protocol as the application it wraps.</p><h2>A Middleware Is Just a Rack App That Wraps Another Rack App</h2><p>The Rack contract makes middleware concrete.</p><p>A middleware usually looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ExampleMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    @app.call(env)
  end
end</code></pre></div><p>At first glance, this looks almost too simple to matter. But the whole stack lives inside that shape.</p><p>The <code>@app</code> object is the next layer inward. Sometimes that next layer is another middleware. Eventually, the innermost layer is the Rails route set, which leads to controller dispatch.</p><p>The real power of middleware comes from what happens around <code>@app.call(env)</code>:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class ExampleMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    # work on the way in

    status, headers, body = @app.call(env)

    # work on the way out

    [status, headers, body]
  end
end</code></pre></div><p>Anything before <code>@app.call(env)</code> runs while the request moves inward.</p><p>Anything after <code>@app.call(env)</code> runs while the response is still moving outward.</p><p>So when people say middleware runs &#8220;before the controller&#8221;, that is only half true. Middleware also runs after the controller. More precisely, middleware surrounds the controller and the rest of the Rails application.</p><h2>What the Stack Is Actually Doing on the Way In</h2><p>Before the request reaches your controller, middleware layers can inspect it, annotate it, normalise it, reject it, or route it elsewhere.</p><p>At the Rack level, the request is represented as <code>env</code>, a mutable hash containing request data and Rack-specific values. Early middleware sees a more raw version of the Request. Later middleware may see a richer request because earlier layers have already added information to <code>env</code>.</p><p>Middleware layers are not isolated from one another. They cooperate by working on the same request state.</p><p>For example, one middleware might assign a request ID:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class RequestIdMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    env["my_app.request_id"] = SecureRandom.uuid
    @app.call(env)
  end
end</code></pre></div><p>Anything deeper in the stack now reads that value. Middleware prepares the request so that later layers, including your controller, can rely on a richer set of assumptions.</p><p>In a real Rails stack, middleware commonly handles concerns like:</p><ul><li><p>assigning request IDs</p></li><li><p>parsing cookies</p></li><li><p>loading session state</p></li><li><p>applying HTTP method overrides</p></li><li><p>validating hosts</p></li><li><p>resolving the client IP</p></li><li><p>serving static files</p></li><li><p>setting up framework-level request context</p></li></ul><p>By the time your controller action runs, a lot of work has already happened. Controller-level reasoning can be incomplete when you debug a request. The request did not begin at the controller boundary. It arrived there after several other layers had already shaped it.</p><h2>What the Stack Is Doing on the Way Out</h2><p>Once the controller and the rest of the inner application return a response, the stack does not disappear. The response still has to travel back outward through the same middleware chain.</p><p>That means middleware can still change what the client ultimately receives.</p><p>A simple example looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class SecurityHeaders
  def initialize(app)
    @app = app
  end

  def call(env)
    status, headers, body = @app.call(env)

    headers["X-Content-Type-Options"] = "nosniff"
    headers["Referrer-Policy"] = "strict-origin-when-cross-origin"

    [status, headers, body]
  end
end</code></pre></div><p>The controller doesn&#8217;t need to know this middleware exists. It renders the response it wants to render. Then middleware adds behavior that belongs at the edge of the application rather than inside a specific controller action.</p><p>Cookies may be written after the controller runs. Session changes may be committed after the controller runs. Logging may finish after the controller runs. Cache-related headers may be added after the controller runs.</p><p>So the response your controller builds is often not the final response the client receives. It is the inner response. Middleware may still wrap it, adjust it, and pass it outward.</p><h2>Middleware Can End the Story Early</h2><p>There is another piece of the middleware behavior that matters a lot in practice: a middleware doesn&#8217;t have to call the next layer at all.</p><p>It can return a response directly.</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class MaintenanceMode
  def initialize(app)
    @app = app
  end

  def call(env)
    if ENV["MAINTENANCE_MODE"] == "true"
      [
        503,
        { "Content-Type" =&gt; "text/plain" },
        ["Maintenance mode"]
      ]
    else
      @app.call(env)
    end
  end
end</code></pre></div><p>If this middleware returns that <code>503</code> response, the request never reaches the router. No controller runs. No view renders. The stack simply returns a response from that point.</p><p>This is called short-circuiting.</p><p>Static file middleware can serve files directly from <code>public</code> without ever involving your controllers. Host authorisation middleware can reject a request before routing. SSL enforcement can redirect a request before your application code executes. In all of these cases, the request stops moving inward because one layer has already decided what the response should be.</p><p>One of the most useful debugging questions in Rails is not &#8220;why did my controller do this?&#8221; but &#8220;Did the request even reach my controller?&#8221;</p><p>Middleware is often where that answer lives.</p><h2>Why Order Matters So Much</h2><p>Once you start thinking in terms of nested wrappers, middleware order becomes part of the design.</p><p>Suppose the stack looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">use A
use B
run app</code></pre></div><p>This is effectively:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">A.new(B.new(app))</code></pre></div><p>So <code>A</code> sees the request before <code>B</code>, but <code>B</code> sees the response before <code>A</code>.</p><p>That means the same middleware components can produce different behavior when you reorder them.</p><p>Take logging and exception handling as a simple example. If logging wraps exception handling, it may see the final 500 response produced by exception handling. If exception handling wraps logging, then the logger may never reach its normal &#8220;after&#8221; path unless it uses <code>ensure</code>. Same pieces, different order, different outcome.</p><p>Or think about sessions and cookies. Session behavior depends on cookie behavior. If you move or remove the cookie layer incorrectly, problems often appear later, in places that seem unrelated to the middleware itself.</p><p>Middleware order determines what state exists at each point in the request, what work can be wrapped, and what behavior can be observed on the way back out.</p><p><code>bin/rails middleware</code> does not just show you a list of implementation details. It shows you the shape of the application boundary.</p><h2>How to Read <code>bin/rails middleware</code></h2><p>When Rails prints the middleware stack, it is tempting to read it as a checklist.</p><p>Do not read it that way.</p><p>Read it as a set of wrappers.</p><p>If the stack says:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">use OuterMiddleware
use InnerMiddleware
run Rails.application.routes</code></pre></div><p>then you should mentally translate it into:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">OuterMiddleware sees the request first.
InnerMiddleware sees the request after that.
Rails routes run inside both
InnerMiddleware sees the response first.
OuterMiddleware sees the response last.</code></pre></div><p>That translation is enough to make the output useful.</p><p>You don&#8217;t need to memorise every default middleware in Rails to reason well about the stack. But you need to understand what kinds of layers you&#8217;re looking at and what role they play.</p><p>In practice, three questions help:</p><ol><li><p>Which middleware can change the request before routing?</p></li><li><p>Which middleware can return a response before routing?</p></li><li><p>Which middleware can change the response after the controller?</p></li></ol><p>Those questions cover most of the surprises.</p><h2>Trace One Request Through the Stack</h2><p>Let us make this concrete with a simple request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">GET /users/42</code></pre></div><p>The app server receives the HTTP request and hands it to the Rails Rack app.</p><p>The request enters the middleware stack.</p><p>An early middleware may reject the host if the request came in for the wrong domain. Another may assign a request ID. Another may determine the client's IP. Another may parse cookies. Another may load the session. Another may wrap the inner app for logging or exception handling.</p><p>If no middleware stops the request, Rails routing finally receives it and matches it against something like:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">get "/users/:id", to: "users#show"</code></pre></div><p>Rails dispatches to <code>UsersController#show</code>, the action runs, and Rails builds a response.</p><p>Now the response heads back out.</p><p>The session layer may persist changes. Cookie middleware may add <code>Set-Cookie</code> headers. Logging middleware may finish recording the request. Response-handling middleware may add headers or adjust cache behaviour.</p><p>Finally, the outermost layer returns the response to the server, and the server sends it back to the client.</p><p>If you compare that to the simplified mental model many people carry around, the difference becomes obvious.</p><p>The request did not go straight from the URL to the controller.</p><p>The response did not go straight from the controller to the browser.</p><p>Both travelled through a stack of boundary layers that could shape the result at each step.</p><h2>Why This Matters in Real Rails Work</h2><p>A lot of unexpected Rails behavior becomes easier to explain once you know which layer owns it.</p><p>Request IDs make sense because middleware can assign request-wide state before the action runs. Sessions make sense because middleware can load and later commit session data around the controller. Cookies make sense because middleware can parse them on the way in and write them on the way out. Static files bypass controllers because middleware can short-circuit the request early. Response headers appear &#8220;by themselves&#8221; because middleware can still modify the response after rendering.</p><p>Instead of asking, &#8220;Why did Rails do this?&#8221; you start asking, &#8220;Which layer owns this behavior?&#8221;</p><p>Rails is not one thing. It is a composed system. Middleware is one of the most important places where that composition becomes visible.</p><h2>The Final Mental Model</h2><p>A Rails middleware stack is a chain of Rack applications wrapped around your Rails app.</p><p>Each middleware receives the request as <code>env</code>.</p><p>Each middleware can inspect or modify that request before calling the next layer.</p><p>Each middleware receives the response when the inner app returns.</p><p>Each middleware can inspect or modify that response before passing it back outward.</p><p>Any middleware can stop the request early by returning a response directly.</p><p>And the order of the stack determines what each layer can see, change, and wrap.</p><p>That means a Rails request is not:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">URL -&gt; Controller</code></pre></div><p>It is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Request -&gt; Middleware stack -&gt; Router -&gt; Controller -&gt; Response -&gt; Middleware stack -&gt; Client</code></pre></div><p>Once you see the stack that way, middleware becomes part of the architecture you can inspect, reason about, and debug with confidence.</p>]]></content:encoded></item><item><title><![CDATA[From Rack to Controller: Understanding the Rails Request Lifecycle]]></title><description><![CDATA[What actually happens before your controller action runs]]></description><link>https://railsrevelry.substack.com/p/from-rack-to-controller-understanding</link><guid isPermaLink="false">https://railsrevelry.substack.com/p/from-rack-to-controller-understanding</guid><dc:creator><![CDATA[Syed Aslam]]></dc:creator><pubDate>Sun, 12 Apr 2026 15:30:22 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!fKWa!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You hit an endpoint in your Rails app.</p><p>Your controller action runs. A response comes back.</p><p>But what happens before that?</p><p>That gap is where request behavior becomes hard to explain.</p><p>You know the URL.</p><p>You know the controller.</p><p>You know what gets rendered.</p><p>But before your action runs, Rails moves the request through several layers:</p><ul><li><p>the app server hands the request to Rails</p></li><li><p>middleware processes it</p></li><li><p>the router decides where it goes</p></li><li><p>the controller action runs</p></li><li><p>the response travels back through the stack</p></li></ul><p>Most of that stays invisible - until something breaks.</p><p>This article makes that flow visible.</p><p>By the end, you should be able to answer one question clearly:</p><p>What exactly happens between an HTTP request hitting my app and my controller action running?</p><h2>The Mental Model</h2><p>Here is the shortest accurate version:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">App server &#8594; Rails Rack app &#8594; middleware &#8594; router &#8594; controller &#8594; response &#8594; middleware &#8594; client</code></pre></div><p>A Rails request is not a direct jump from URL to controller. It is a pipeline.</p><p>We will walk through that pipeline by tracing a single request:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /users/42</code></pre></div><h2>The Full Request Flow</h2><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!fKWa!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!fKWa!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!fKWa!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!fKWa!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!fKWa!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!fKWa!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/c246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:433618,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://railsrevelry.substack.com/i/193969461?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!fKWa!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!fKWa!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!fKWa!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!fKWa!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc246a99c-9a55-47e5-adc2-ca36c62a5e7f_1536x1024.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Keep that diagram in mind as we trace the request.</p><p>Our example request comes in:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /users/42</code></pre></div><p>Now let&#8217;s follow it, step by step.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading RailsRevelry. Subscribe for free to get the next Rails systems essay in your inbox.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><h2>1. The Request Reaches Your Rails App</h2><p>Before Rails routing, controllers, or views are involved, something has to receive the raw HTTP request.</p><p>That job belongs to the app server.</p><p>In development, that might be Puma. In production, it is still a Rack-compatible server sitting in front of your Rails application.</p><p>The app server receives the HTTP request and invokes your Rails application as a Rack app.</p><p>That is the first key mental model.</p><p>Rails is a Rack application. Everything Rails does builds on top of this contract. At the lowest level, Rack defines a very small interface:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">call(env) # =&gt; [status, headers, body]</code></pre></div><p>That means a Rack app receives request data through <code>env</code> and returns a response as:</p><ul><li><p>a status code</p></li><li><p>a headers hash</p></li><li><p>a body</p></li></ul><p>A minimal Rack app looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class App
  def call(env)
    [200, { "Content-Type" =&gt; "test/plain" }, ["Hello World"]]
  end
end</code></pre></div><p>Rails is much more sophisticated than that, but it still plugs into that exact same contract. Underneath controllers, helpers and render methods, your Rails app is still just something that responds to <code>call(env)</code>.</p><h2>2. The Request Enters the Middleware Stack</h2><p>Once the app server invokes the Rails Rack app, the request enters the middleware stack. </p><p>A lot of Rails behavior starts here.</p><p>Middleware is a chain of Rack applications wrapped around your app. Each middleware layer can:</p><ul><li><p>inspect the request</p></li><li><p>modify the request</p></li><li><p>stop the request early</p></li><li><p>pass the request to the next layer</p></li><li><p>inspect or modify the response on the way back out</p></li></ul><p>So the shape is not:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Request &#8594; Rails somehow &#8594; Controller</code></pre></div><p>It is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Request &#8594;
  Middleware A &#8594;
    Middleware B &#8594;
      Rails endpoint &#8594;
    &#8592;
  &#8592;
Response</code></pre></div><p>Each layer wraps the next, as nested function calls.</p><p>Some middleware examples:</p><ul><li><p>logging</p></li><li><p>static file serving</p></li><li><p>cookies</p></li><li><p>sessions</p></li><li><p>method overrides</p></li><li><p>error handling</p></li><li><p>response headers</p></li></ul><p>By the time the request reaches your controller, it may already have been inspected, modified, or even short-circuited by several layers.</p><p>Sometimes, the thing you think Rails is doing in your action actually happens before the router even runs.</p><h2>3. The Router Decides Where the Request Goes</h2><p>If the request makes it through the middleware, it reaches the router. Now Rails needs to answer a simple question:</p><p>Which controller action should handle this request?</p><p>Suppose you have this route:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">get "/users/:id", to: "users#show"</code></pre></div><p>Our request is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">GET /users/42</code></pre></div><p>The router pattern-matches the request path against the route definition. It sees that:</p><ul><li><p>the path matches <code>/users/:id</code></p></li><li><p>the dynamic segment is <code>42</code></p></li><li><p>the target is <code>UsersController#show</code></p></li></ul><p>So Rails now knows where to dispatch the request, and it builds the params needed for that action:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">{ id: "42" }</code></pre></div><p>Routing is pattern matching plus dispatch.</p><p>It is not business logic.</p><p>It is not rendering.</p><p>It is not querying the database.</p><p>It is the decision point that says, &#8220;this request goes here.&#8221;</p><h2>4. Rails Instantiates the Controller and Runs the Action</h2><p>Once routing has selected the endpoint, Rails can hand off control to the controller layer.</p><p>For our example, that means instantiating <code>UsersController</code> and calling <code>show</code>.</p><p>At that point, the request has been transformed from a raw HTTP request into Rails-friendly abstractions.</p><p>Inside the controller, you work with things like:</p><ul><li><p><code>params</code></p></li><li><p><code>request</code></p></li><li><p><code>session</code></p></li><li><p><code>cookies</code></p></li><li><p><code>render</code></p></li><li><p><code>redirect_to</code></p></li></ul><p>A simplified controller might look like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">class UsersController &lt; ApplicationController
  def show
    @user = User.find(params[:id])
    render :show
  end
end</code></pre></div><p>Most Rails developers are comfortable at this layer, which is why it is easy to treat the controller as the beginning of the request lifecycle.</p><p>But it is not the start of the story. The controller is a late stage in the pipeline.</p><p>By the time your action runs:</p><ul><li><p>the app server has already accepted the HTTP request</p></li><li><p>Rack has already defined the interface Rails is using</p></li><li><p>middleware has already had a chance to inspect or alter the request</p></li><li><p>the router has already decided where the request belongs</p></li></ul><h2>5. Rendering Produces a Response</h2><p>Once the action finishes, Rails turns that result into a response. That response might come from:</p><ul><li><p>rendering a template</p></li><li><p>redirecting</p></li><li><p>returning JSON</p></li><li><p>sending just headers and a status code</p></li></ul><p>However it is produced, the end result still has to fit the Rack response shape:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;ruby&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-ruby">[status, headers, body]</code></pre></div><p>So even though Rails gives you higher-level tools like <code>render</code> and <code>redirect_to</code>, it eventually converts everything back into the same low-level response structure.</p><p>Rails abstractions are built on top of a very small protocol.</p><h2>6. The Response Travels Back Out Through Middleware</h2><p>This part is easy to miss, but it is essential.</p><p>The response does not go directly from the controller to the browser. It goes back through the same layers that handled the request.</p><p>That means middleware can still change behaviour after your controller action has finished.</p><p>For example, middleware might:</p><ul><li><p>add headers</p></li><li><p>write cookies</p></li><li><p>log response details</p></li><li><p>compress the body</p></li><li><p>manage caching</p></li><li><p>transform error responses</p></li></ul><p>So the request lifecycle is not one-way. It is an in-and-out pipeline:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">Request in &#8594; middleware &#8594; router &#8594; controller &#8594; response out &#8594; middleware</code></pre></div><p>If you see a header you never set, or response behaviour you did not explicitly code in the action, middleware is one of the first places to look.</p><h2>Where This Breaks in Real Life</h2><p>Most surprising Rails behaviour comes from not knowing which layer owns the behaviour.</p><p>A few real debugging patterns:</p><ul><li><p>Unexpected redirect before your action runs: You expect <code>UsersController#show</code> to execute, but a middleware redirects the request before routing or controller dispatch happens.</p></li><li><p>Session or cookie bugs: You inspect controller code looking for the problem, but the real issue is that session or cookie middleware is reading, writing, or rejecting request state earlier in the stack.</p></li><li><p>Middleware ordering bugs: One middleware assumes another has already set something up. If the order is wrong, behaviour becomes inconsistent and hard to trace.</p></li><li><p>Performance overhead before application code: You focus on controller and database time, but meaningful request cost is happening before the request even reaches the action.</p></li></ul><p>When something feels mysterious in Rails, ask which layer owns the behaviour.</p><p>Very often, the answer is middleware.</p><h2>The Final Mental Model</h2><p>A Rails request is not:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">URL &#8594; Controller</code></pre></div><p>It is:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:null}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">HTTP request
&#8594; app server
&#8594; Rails Rack app
&#8594; middleware
&#8594; router
&#8594; controller action
&#8594; router
&#8594; middleware
&#8594; client</code></pre></div><p>Once you see that flow clearly, Rails stops feeling like a black box.</p><p>It becomes a system you can trace, debug, and reason about with confidence. </p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://railsrevelry.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">RailsRevelry is a series of deep dives into how Rails actually behaves in production. Subscribe for free to get the next article in your inbox.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item></channel></rss>