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
Comments
No comments yet. Be the first.