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

render partial in Rails, printed

render "sidebar" and render partial: "sidebar" and render @sidebar are three spellings that reach the same file through three different code paths, and the one thing nobody tells you is that none of them look in the directory of the file you wrote them in. Everything below was run against Rails 8.1.3.1 and Ruby 4.0.5 in a scratch application on an M2 Max, and every output block is pasted from that terminal.

What render dispatches on

ActionView::Helpers::RenderingHelper#render is eighteen lines, at actionview-8.1.3.1/lib/action_view/helpers/rendering_helper.rb:138:

def render(options = {}, locals = {}, &block)
  case options
  when Hash
    in_rendering_context(options) do |renderer|
      if block_given?
        view_renderer.render_partial(self, options.merge(partial: options[:layout]), &block)
      else
        view_renderer.render(self, options)
      end
    end
  else
    if options.respond_to?(:render_in)
      options.render_in(self, &block)
    else
      view_renderer.render_partial(self, partial: options, locals: locals, &block)
    end
  end
end

The branch is on class, not on content. A Hash is the only form that gets inspected for :partial, :template, :file, :inline, :layout. Everything else is a partial, which is why render "posts/post", render @post and render @posts all end up in render_partial and why there is no render template: "post" spelling that works with a bare string from a view. The render_in clause in the middle is the ViewComponent and Phlex entry point, and it is checked before the partial fallback, which is how a component object rendered with the same helper never touches the partial renderer at all.

These three are the same render:

<li>Post 0 by Author 0</li>
<li>Post 0 by Author 0</li>
<li>Post 0 by Author 0</li>

produced by, in order, render "posts/post", post: @p, render partial: "posts/post", locals: { post: @p } and render @p. The second positional argument of the bare form is the locals hash, so render "posts/post", post: @p and locals: { post: @p } are the same hash arriving at the same place.

Where Rails actually looks

The documentation above that method says the partial renderer "looks for the partial template in the directory of the calling template first". It does not. It looks in the controller's _prefixes.

The difference is invisible until a partial moves. Set up three files with the same name:

app/views/posts/_bare.html.erb          -> bare-in-posts
app/views/shared/_bare.html.erb         -> bare-in-shared
app/views/application/_bare.html.erb    -> bare-in-application

then put <%= render "bare" %> inside app/views/shared/_wrapper.html.erb, and render app/views/posts/relative.html.erb which renders that wrapper. The calling template is in shared/. A request through PostsController answers:

PostsController._prefixes        = ["posts", "application"]
ApplicationController._prefixes  = ["application"]
GET /posts/relative -> bare-in-posts

app/views/shared/_bare.html.erb is sitting right next to the file that asked for it and is never consulted. Render the identical template through ApplicationController.render instead and it answers bare-in-application, because that controller's prefix list is one entry long. The lookup never knows which file the render call was written in.

The practical consequence is that a bare name in a shared partial is a name whose meaning depends on which controller is running, and a partial extracted into app/views/shared/ keeps working right up until someone adds app/views/posts/_bare.html.erb for something else. Write the full path in anything that lives outside its own controller's directory. The cost is that render "shared/wrapper" is nine characters longer than render "wrapper" and you will type it a lot.

When the name resolves to nothing, the message names the underscored file, and then prints every format Rails knows:

Missing partial shared/_nope with {locale: [:en], formats: [:html, :text, :js, :css, :ics, :csv, :vcf, :vtt, :md, :png, :jpeg, :gif, :bmp, :tiff, :svg, :webp, :mpeg, :mp3, :ogg, :m4a, :webm, :mp4, :otf, :ttf, :woff, :woff2, :xml, :rss, :atom, :yaml, :multipart_form, :url_encoded_form, :json, :pdf, :zip, :gzip], variants: [], handlers: [:raw, :erb, :html, :builder, :ruby]}.

That is the first line of the exception only; the Searched in: list follows it and is the part worth reading, because it prints the view paths that were consulted and you can see the prefix that is missing.

Locals, and the comment that makes them an interface

A partial's parameter list is whatever the caller happened to pass, which is survivable in a codebase one person maintains and is the single reason people leave partials for something else. The Rails 7.1 magic comment closes it. app/views/shared/_badge.html.erb here is two lines:

<%# locals: (label:, tone: "grey") %>
<span class="<%= tone %>"><%= label %></span>

Four calls, printed with the locals hash that produced each one:

{} -> ActionView::StrictLocalsError: missing local: :label for app/views/shared/_badge.html.erb
{label: "hi", icon: "x"} -> ActionView::StrictLocalsError: unknown local: :icon for app/views/shared/_badge.html.erb
{label: "hi"} -> <span class="grey">hi</span>
{label: "hi", tone: "red"} -> <span class="red">hi</span>

Both errors arrive wrapped in ActionView::Template::Error with the same message, so the rescue you want is on the cause. Defaults work the way keyword arguments work. A **rest in the comment reopens the partial to anything, and the captured hash is a plain Hash: locals: (label:, **attrs) called with label: "L", x: 1, y: 2 gives attrs={x: 1, y: 2} while local_assigns still shows all three.

The cost of the comment is that it is a comment. Nothing makes you write one, bin/rails has no task that finds the partials without one, and a partial that gains a local six months later gains it in two places. It is still the only thing in this article that turns a partial from a text substitution into something with a signature, and it belongs in place before anyone argues about view components against partials.

One thing the comment quietly breaks: defined? stops answering the question it used to answer. In a partial with no magic comment, defined?(tone) is no when the caller omitted tone and yes when it passed it. Declare tone: nil in the comment and it is yes either way, because the local now always exists. local_assigns stays honest in both:

loose   defined?(tone)=no  local_assigns=[:label]   passed=[:label]
loose   defined?(tone)=yes  local_assigns=[:label, :tone]   passed=[:label, :tone]
strict  defined?(tone)=yes  local_assigns=[:label]   passed=[:label]
strict  defined?(tone)=yes  local_assigns=[:label, :tone]   passed=[:label, :tone]

So a strict partial that wants to know whether a value was supplied asks local_assigns.key?(:tone), not defined?. Converting a partial to strict locals silently changes the meaning of every defined? already in it.

Collections

render @posts finds _post.html.erb from the class name, renders it once per element, and joins the results with nothing between them. The locals it passes are worth printing, because two of the three are not the one you asked for:

[a counter=0 iteration=0/3 first=true last=false locals=[:ix, :ix_counter, :ix_iteration]]
[b counter=1 iteration=1/3 first=false last=false locals=[:ix, :ix_counter, :ix_iteration]]
[c counter=2 iteration=2/3 first=false last=true locals=[:ix, :ix_counter, :ix_iteration]]

ix_counter is zero-based. Every table that wants a row number wants ix_counter + 1, and the off-by-one is the most common bug in this whole area. ix_iteration is an ActionView::PartialIteration and carries index, size, first? and last?, which is what you want for a separator or a "and 3 more" tail.

The local is named after the partial file, not after the collection or the class. Rename _post.html.erb to _summary.html.erb without touching the body and the partial breaks, but not with the error you expect:

NoMethodError: undefined method 'post' for an instance of #<Class:0x0000000127657818>
  ~/.rvm/gems/ruby-4.0.5/gems/railties-8.1.3.1/lib/rails/engine/lazy_route_set.rb:96:in 'method_missing'

post is not a local any more, so the name falls through to the view's method_missing, which is the route helper proxy, which does not have a post route either. Once the route set has been loaded the same proxy re-raises it as NameError: undefined local variable or method 'post', so the message you see depends on whether routes were touched first: in bin/rails runner on a cold boot it is the NoMethodError above, in a request it is the NameError. Both come out of lazy_route_set.rb:96. The fix is as: :post, or rename the local.

An empty collection renders nil rather than an empty string, which is a deliberate affordance:

nil
No posts yet

from render(Post.none).inspect and render(Post.none) || "No posts yet". That is the idiom for an empty state and it is why render returns nil instead of "".

Two options are worth knowing and neither is worth a paragraph. spacer_template: renders a partial between items and not around them, so two items get one spacer. A heterogeneous array asks for one partial per class: render [@post, @author] raises Missing partial authors/_author.

What the collection form actually buys

The two templates below produce byte-identical output on 1000 rows:

<%# app/views/posts/index.html.erb %>
<ul><%= render @posts %></ul>
<%# app/views/posts/loop.html.erb %>
<ul><% @posts.each do |post| %><%= render partial: "posts/post", locals: { post: post } %><% end %></ul>

They are not the same amount of work. Subscribing to the Action View notifications for three posts, with Rails.root stripped off each identifier:

== render @posts
  render_collection.action_view {identifier: "app/views/posts/_post.html.erb", count: 3}
  render_template.action_view {identifier: "app/views/posts/index.html.erb"}
== each + render partial
  render_partial.action_view {identifier: "app/views/posts/_post.html.erb"}
  render_partial.action_view {identifier: "app/views/posts/_post.html.erb"}
  render_partial.action_view {identifier: "app/views/posts/_post.html.erb"}
  render_template.action_view {identifier: "app/views/posts/loop.html.erb"}

One event against three. The mechanism is in collection_renderer.rb:195, where the template lookup is memoised across the whole collection and a single locals hash is mutated in place per item:

_template = (cache[path] ||= (template || find_template(path, @locals.keys + [as, counter, iteration])))

content = _template.render(view, locals, implicit_locals: [counter, iteration])

Now the numbers. Median of 40 renders, warm, <ul>...</ul> body verified identical between the two, in a bin/rails runner process with Rails.logger.level = :info, the records already loaded into an array with .includes(:author), and annotate_rendered_view_with_filenames off:

50 rows   collection 0.33 ms   loop 0.79 ms   ratio 2.4x
1000 rows collection 3.43 ms   loop 13.92 ms   ratio 4.1x

Set the logger to :debug, which is the development and test default, and the loop gets much worse while the collection form does not move. Three consecutive trials in the same process, same templates, same 1000 rows, alternating the level:

trial=0 level=info  1000: coll   4.10  loop  14.04 (3.4x)   50: coll 0.33 loop 0.78 (delta 452 us)
trial=0 level=debug 1000: coll   3.66  loop  22.89 (6.3x)   50: coll 0.34 loop 1.23 (delta 887 us)
trial=1 level=info  1000: coll   3.19  loop  12.88 (4.0x)   50: coll 0.32 loop 0.74 (delta 422 us)
trial=1 level=debug 1000: coll   3.29  loop  24.17 (7.3x)   50: coll 0.35 loop 1.25 (delta 903 us)
trial=2 level=info  1000: coll   3.43  loop  13.44 (3.9x)   50: coll 0.33 loop 0.79 (delta 452 us)
trial=2 level=debug 1000: coll   3.57  loop  23.86 (6.7x)   50: coll 0.35 loop 1.29 (delta 934 us)

Each render_partial.action_view event writes one of these:

  Rendered posts/_post.html.erb (Duration: 0.0ms | GC: 0.0ms)

The loop form pays for 1000 of them per render. The collection form writes one line for the whole collection:

  Rendered collection of posts/_post.html.erb [1000 times] (Duration: 3.2ms | GC: 0.0ms)

A grep over this application's development log after a morning of benchmarking counted 469077 lines reading exactly Duration: 0.0ms | GC: 0.0ms, against 697 at 0.1ms. That is the shape of the problem: each individual line is too cheap to measure, and there are half a million of them. About ten of the twenty-four milliseconds at :debug are the log subscriber, so a rendering loop profiled in development looks roughly twice as bad as it is. Set the logger level before believing a view benchmark.

That was the dead end. The first pass at this benchmark assumed the whole gap was the log subscriber and that the two forms were within noise of each other once you turned logging down. They are not. Ten milliseconds survive at :info, the ratio is still close to four times, and that number shows up end to end. Puma 8.0.2 in single mode, three threads, a production-env boot of the same app, PostgreSQL 17.7 on port 15432, ab -n 200 -c 1, the two routes interleaved so that load drift on this machine hits both equally:

run1 /posts       Requests per second: 37.07 [#/sec] (mean)  50% 25
run1 /posts/loop  Requests per second: 30.92 [#/sec] (mean)  50% 30
run2 /posts       Requests per second: 36.16 [#/sec] (mean)  50% 25
run2 /posts/loop  Requests per second: 28.71 [#/sec] (mean)  50% 32
run3 /posts       Requests per second: 39.00 [#/sec] (mean)  50% 25
run3 /posts/loop  Requests per second: 27.18 [#/sec] (mean)  50% 31

ab reported Document Length: 30700 bytes for both routes, which is the byte-identical claim checked over the wire rather than in a test. Five to seven milliseconds of median latency, every request, for a spelling difference. Worth saying plainly that this machine was running a dozen other jobs while those ran: an isolated run half an hour earlier gave 43.72 against 30.84 requests per second with the same 25-against-31 medians, so the absolute throughput moves with load and the gap does not.

The position: use render @posts, and the milliseconds are a real part of the reason on a long page, not just the brevity and not just that cached: true and the preloading iterator attach to that form. What would make the difference not worth thinking about is a short page. At 50 rows it is 450 microseconds and you will never find it in a flame graph. What would make it matter far more is a partial with real work in it, which is the cached: true case below.

cached: true, and the store that is not Rails.cache

cached: true on a collection gives you one read_multi for the whole page, keyed on each record's cache_key prefixed with the template digest, and one write_multi for the misses. A key looks like this:

"views/posts/_post:b87909ee30466913fdd939b98c850fd9/posts/1"

It is not free, and on a cheap partial it loses badly. Both lines are 1000 rows with all 1000 fragments already in an in-process MemoryStore:

_post   plain     3.0 ms   cached: true   19.0 ms   (1000 of 1000 hits)
_heavy  plain   270.4 ms   cached: true   19.3 ms   (1000 of 1000 hits)

_post is <li><%= post.title %> by <%= post.author.name %></li>. _heavy is the same line plus sanitize and two hundred MD5 digests per row, which is a stand-in for a partial that does real work. Those two partials differ by a factor of ninety uncached and land within 0.3 ms of each other cached, because 19 ms is what building 1000 cache keys and doing one read_multi costs on this machine regardless of what the partial contains. So the break-even is around 19 microseconds of rendering per row, and a network cache store moves it up rather than down. A two-line partial is nowhere near it: cached: true made that page six times slower.

Then the part that wastes an afternoon. will_cache? in collection_caching.rb:16 requires the controller to be caching, which is easy to remember, and then reads a completely different accessor for the store, declared four lines above it at collection_caching.rb:12:

mattr_accessor :collection_cache, default: ActiveSupport::Cache::MemoryStore.new

actionview/lib/action_view/railtie.rb:116 sets that once, at boot, from app.config.action_controller.cache_store. Assigning Rails.cache afterwards does nothing to it:

Rails.cache                                  = ActiveSupport::Cache::NullStore
ActionView::PartialRenderer.collection_cache = ActiveSupport::Cache::NullStore
after Rails.cache = MemoryStore.new:
ActionView::PartialRenderer.collection_cache = ActiveSupport::Cache::NullStore

The symptom is cached: true that answers cache_hits: 0 forever while making the page slower, with no warning anywhere. In a test or a benchmark, set ActionView::PartialRenderer.collection_cache directly. In an application, this is why toggling the cache store at runtime does not do what you expect and a boot is required.

The fragment digest in that key is a digest of the template and everything it renders, and a render partial: name where name is a variable defeats the dependency tracker entirely. That failure is in Rails caching strategies and is not repeated here.

The collection renderer will not fix your N+1

render @posts where the partial touches post.author does exactly what the loop does. Five rows, counting sql.active_record events:

render Post.order(:id).limit(5)
  6 queries
    SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT $1
    SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2
    SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2
render Post.includes(:author).order(:id).limit(5)
  2 queries
    SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT $1
    SELECT "authors".* FROM "authors" WHERE "authors"."id" IN ($1, $2, $3, $4, $5)

The one thing the collection form does add is PreloadCollectionIterator in collection_renderer.rb:78: when you hand render an unloaded relation rather than an array, it calls relation.skip_preloading! at line 81 and runs preload_associations itself at line 96, at render time. Passing .to_a gave the same two queries here, so the difference is when the preload happens, not whether it happens.

Partials as layouts

render(layout: "shared/box") do ... end renders _box.html.erb with the block available as yield. <div class="box"><%= yield %></div> around inner gives <div class="box">inner</div>. That is the third branch of the method at the top of this page: a Hash plus a block sends options[:layout] in as the partial.

What this page does not cover

Turbo Stream rendering, where the same partial is rendered into a turbo_stream.replace and the id on the root element decides whether the result nests inside itself; that is in Turbo Stream actions. The fragment digest algorithm and the dependency tracker, which decide when a cached partial expires. render_in and the component objects on the other side of that branch. Streaming templates and ActionView::StreamingTemplateRenderer, which changes the order things are rendered in. And JSON partials under app/views/**/*.json.jbuilder, where the collection form behaves the same but the digest and the cache keys do not.

Everything above ran in a generated Rails 8.1.3.1 application on PostgreSQL 17.7, port 15432, Ruby 4.0.5, Apple M2 Max with 12 cores. The in-process timings are the development database, 1000 posts and one author, records preloaded with .includes(:author) so no query is inside the measurement; the ab runs are the production database, 1000 posts across 50 authors. A 20-example Minitest file asserts the error strings, the counter values, the prefix resolution, the notification counts and the query counts printed here:

20 runs, 59 assertions, 0 failures, 0 errors, 0 skips
#rails #views

Comments

No comments yet. Be the first.

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

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