Prevent N+1 Queries in Rails

Flags Active Record associations read in loops, views, and serializers without includes or preload, and per-row count queries.

What it flags

Flaggedapp/controllers/projects_controller.rb
class ProjectsController < ApplicationController
  def index
    projects = current_account.projects.order(created_at: :desc).limit(50)

    payload = projects.map do |project|
      {
        id: project.id,
        name: project.name,
        owner_name: project.owner.name, # one owner query and one COUNT query per project
        open_tasks: project.tasks.where(status: "open").count
      }
    end

    render json: payload
  end
end
Passesapp/controllers/projects_controller.rb
class ProjectsController < ApplicationController
  def index
    projects = current_account.projects
      .includes(:owner)
      .order(created_at: :desc)
      .limit(50)
      .to_a
    open_tasks = Task.where(project_id: projects.map(&:id), status: "open")
      .group(:project_id)
      .count

    payload = projects.map do |project|
      {
        id: project.id,
        name: project.name,
        owner_name: project.owner.name,
        open_tasks: open_tasks.fetch(project.id, 0)
      }
    end

    render json: payload
  end
end

Why it matters

An N+1 query passes every test that runs against a handful of fixture rows, then issues hundreds of queries per request once a customer has real data. The two halves usually live in different files: the controller builds the relation, and a partial, jbuilder view, or serializer reads the association. A reviewer who reads only the changed file sees neither half as wrong.

Agent instructions

cubic checks every pull request against these instructions. Paste them into AGENTS.md or Cursor rules and your AI coding tool follows them while it writes code.

AGENTS.md

Add this section to AGENTS.md at the repository root. Codex, Cursor, and other AI coding tools read it.

## Prevent N+1 Queries in Rails

Applies to `**/*.rb`, `**/*.rake`, `**/*.erb`, `**/*.jbuilder`, `**/*.haml`, `**/*.slim`.

Review Active Record access in controllers, views, serializers, mailers, and jobs for queries that run once per record.

Flag changes that:
- Iterate records (`each`, `map`, `find_each`, `render collection:`, `json.array!`) and read a `belongs_to`, `has_one`, or `has_many` association inside the loop when the relation has no `includes`, `preload`, or `eager_load` for it.
- Call `count` or `exists?` on an association for each row. `count` always runs a query, even on a preloaded association; use `size` with a preload, a `counter_cache`, or one grouped count keyed by id.
- Chain `where`, `order`, `limit`, or `find_by` onto an association inside a loop. Each call builds a new query, so preloading the association does not help; preload a scoped association instead.
- Read an association in a serializer, jbuilder view, partial, or helper when the controller that renders it does not preload it.
- Preload only the first level, such as `includes(:author)`, when the loop reads a nested association such as `author.company`.
- Remove an `includes`, `preload`, or `eager_load` call while rendering code still reads that association.

Do not flag:
- Code that handles a single record, such as a show action or a job that loads one row.
- Relations marked `strict_loading` in apps that keep the default `action_on_strict_loading_violation = :raise`, where a missed preload raises instead of querying. Under `:log`, the lazy load still queries, so this exemption does not apply.
- Loops over a small, fixed collection, such as a hard-coded list of a few records.

Checks files matching **/*.rb, **/*.rake, **/*.erb, **/*.jbuilder, **/*.haml, **/*.slim.

More Ruby on Rails agents

See all Ruby on Rails code review standards

Run this agent on every pull request

cubic reviews every pull request against the agents you turn on and comments on the lines that break them.

Catch this in your code