100%
From MarkUs

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.students

And 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 the WHERE 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, since preload can’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 }
end

This 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.