ActiveRecord High severity

View Layer Query Execution

Running ActiveRecord queries directly inside ERB, Slim, or Haml templates, typically Model.where(...) or counter lookups inside loops, bypassing the controller’s opportunity to preload data and producing N+1 cascades that cannot be fixed from outside the view.

Before / After

Problematic Pattern
<%# app/views/posts/index.html.erb %>
<% @posts.each do |post| %>
<article>
  <h2><%= post.title %></h2>
  <span>
    <%= Comment.where(post: post).count %>
    comments
  </span>
</article>
<% end %>
<%# 50 posts = 50 extra COUNT queries issued
  while the template is rendering. %>
Target Architecture
# app/controllers/posts_controller.rb
@posts = Post.includes(:author).select(<<~SQL)
posts.*,
(SELECT COUNT(*)
   FROM comments
   WHERE comments.post_id = posts.id
) AS comments_count
SQL

<%# app/views/posts/index.html.erb %>
<% @posts.each do |post| %>
<article>
  <h2><%= post.title %></h2>
  <span><%= post.comments_count %> comments</span>
</article>
<% end %>
<%# One SELECT, scalar subquery runs in the
  database, zero queries during rendering. %>

Why this hurts

Queries issued inside views execute after the controller has returned, so eager loading, fragment caching, and request-level profiling cannot help them. The template engine drives the query workload, which means the observability layer labels the database time as “view rendering” rather than “database”, fooling APM dashboards and hiding the real bottleneck. Engineers chasing slow request traces spend time optimizing partial rendering or Ruby evaluation when the actual cost is SQL latency.

Each loop iteration opens a fresh checkout from the ActiveRecord connection pool. Under concurrent load the pool saturates and later requests queue waiting for a connection to return. Because Puma runs multiple request threads in one process, pool exhaustion in one request path degrades unrelated endpoints served by the same worker. TTFB grows linearly with collection size and becomes unpredictable when row counts differ per request, making SLA commitments impossible.

Fragment caching interacts badly with view-layer queries. The cache key must include every field the template reads, but when the template issues its own queries, those queries execute on cache misses and expire cached HTML in unpredictable patterns. A freshly rendered template writes a cache entry that embeds data it read mid-render, so subsequent requests either serve stale data or force a full re-render. PostgreSQL’s prepared statement cache cannot help because each in-view query has different bind parameters per iteration.

The combination .includes + .left_joins + .group that developers sometimes reach for is fragile: if Rails switches between the preload and eager_load strategies based on heuristics, the generated SQL can lose columns from the GROUP BY clause and raise a PostgreSQL error in production that never appeared in development. A scalar subquery in the SELECT clause is strategy-independent and executes in a single round-trip.

Get Expert Help

Inheriting a legacy Rails codebase with this problem? Request a Technical Debt Audit.