Killing an N+1 in the Grading Interface
PR #7678 — the student listing page was executing 200+ SQL queries where 2 would suffice. Page load went from ~3s to ~200ms.
The shape of it
The student listing is the page TAs hit constantly during grading. It renders a table of students in a section, with each row showing the student’s name (pulled from the associated User record). The controller looked something like:
@students = @section.studentsAnd the view did the obvious thing:
<% @students.each do |student| %>
<td><%= student.user.name %></td>
<% end %>This is the textbook N+1. The first query loads N student records. The view then accesses .user on each one, and because the User association wasn’t preloaded, Rails fires a fresh SELECT * FROM users WHERE id = ? for every single row. For a class of 200, that’s 201 queries. The server log makes it obvious — 200 identical statements differing only in the WHERE-clause parameter.
The per-query cost isn’t the SQL execution. It’s the round-trip: connection acquisition, parse, send, wait, parse the response, release the connection. At single-digit milliseconds each, 200 of them adds up to multiple seconds of wall-clock latency. And during a grading rush — 50 TAs hitting the page simultaneously — that pattern saturates the connection pool and the whole app starts queuing.
The fix
One word:
@students = @section.students.includes(:user)Rails now issues two queries total, regardless of N. First the students, then a single batched SELECT * FROM users WHERE id IN (1, 2, ..., 200). The associated User records get cached on each Student instance, and student.user becomes a memory lookup instead of a database trip.
Which eager-load method
Rails gives you three, and the differences matter when you start filtering or joining:
includes— Rails decides between two queries (preload-style) or one LEFT OUTER JOIN (eager_load-style) based on whether you’re filtering on the association. This is the default reach.preload— always two queries. Forces theWHERE id IN (...)pattern. Use when you know you don’t want a JOIN.eager_load— always one LEFT OUTER JOIN. Use when you need to filter or sort on association columns, sincepreloadcan’t see them.
For this fix, includes is right. We’re not filtering on users.*, just reading users.name. Rails picks the two-query strategy, which is the right call here because a JOIN would produce a wider result set with one row per student-user pair.
Catching it next time
Three things in MarkUs caught this and would catch the next one:
The first is reading the server log. N+1s have a signature you can’t miss once you’ve seen it: a wall of nearly-identical queries. Any time a page feels slow, the log is the first place to look.
The second is the Bullet gem, which I left enabled in development. Bullet hooks into ActiveRecord and emits warnings any time it detects a query pattern that would benefit from eager loading. It catches the bug before it ships.
The third is a query-count assertion in the test for this controller action:
assert_queries(2) do
get :index, params: { section_id: section.id }
endThis is the test that prevents regression. If a future PR removes the includes(:user) or adds an unrelated association access in the view, the test count jumps from 2 to 200-and-change, and CI fails. It’s a small piece of armor against a class of bug that’s easy to reintroduce by accident.
The fix was a one-line patch. The test was the harder thing to write, and it’s the part that has the longest tail of value.