Beyond N+1 Queries in Rails

How includes, preload, and eager_load actually differ, where serializers hide N+1s, and how strict_loading plus a CI check kept the portfolio honest.

Thursday afternoon, deep into the DDD migration at the digital product agency I led engineering at. I had Bullet running locally against one of the older client Rails apps, and the log was just a wall of yellow warnings. Forty-three N+1s on one dashboard request. The PM wanted to know why a “simple list page” took 3.2 seconds to render. I didn’t even need to open Datadog.

I’ve shipped a lot of Rails across that agency portfolio, and then later in floating-engineer mode against the creator-economy platform’s monolith on a multi-terabyte Aurora cluster. Every time N+1 comes up, somebody waves .includes(:author) at me like it’s the answer. It isn’t. Not always. Not even usually.

What includes really does

Most Rails engineers know includes “fixes” N+1. Fewer know that includes is actually a router. It picks between preload and eager_load at runtime, based on whether your query references the included association.

# Two queries. Posts, then users WHERE id IN (...). This is preload behavior.
Post.includes(:author).limit(20)

# One query with a LEFT OUTER JOIN. This is eager_load behavior.
# Triggered because we reference authors in the WHERE clause.
Post.includes(:author).where(authors: { verified: true }).limit(20)

That switch is a feature until it’s a bug. The moment you filter on the joined table, you go from two clean queries to one giant join. On a hot table, that join can blow your buffer cache and starve everything on the same reader.

I prefer explicit. If I want two queries, I write preload. If I want a join, I write eager_load. includes is fine for prototypes. In production code reviews I push back on it.

# Explicit. Two queries. No surprises.
Post.preload(:author, :tags, comments: :author).limit(20)

# Explicit. One query with JOINs because I'm filtering across the join.
Post.eager_load(:author).where(authors: { verified: true })

# Explicit but heavier - useful when you need both joined filtering AND
# to load nested associations without re-querying.
Post
  .eager_load(:author)
  .preload(comments: [:author, :reactions])
  .where(authors: { verified: true })

The hidden trap: eager_load flattens everything into one SQL row, then ActiveRecord deduplicates in Ruby. On big result sets this hurts more than you’d think. I’ve watched a single “innocent” eager_load chew 1.4 GB of Ruby heap before garbage collection caught up.

Serializer-layer N+1

This is the one that bites teams I’ve worked with the most. The query looks clean. Bullet is silent. Then you open ActiveModel::Serializer or a Jbuilder template or a Blueprinter view, and there’s a .tags.map(&:name) buried in a virtual attribute. Every record fires another query.

class PostSerializer < Blueprinter::Base
  identifier :id
  fields :title, :published_at

  field :tag_names do |post|
    # This is fine if posts were loaded with preload(:tags).
    # It's a disaster if they weren't.
    post.tags.map(&:name)
  end

  field :latest_comment_author do |post|
    # Even with preload(:comments), this still N+1s because
    # of the chained .author lookup. preload(comments: :author) is needed.
    post.comments.order(created_at: :desc).first&.author&.display_name
  end
end

The query that feeds this serializer needs to know what the serializer touches. That’s tight coupling, and pretending it isn’t is how you get the kind of latency creep that takes a quarter to debug. On the agency portfolio we ended up putting a comment block at the top of every serializer listing the required preloads. Ugly. Worked.

A cleaner pattern is a query object that owns the preload contract for a given serializer.

class PostFeedQuery
  REQUIRED_PRELOADS = [:author, :tags, { comments: :author }].freeze

  def self.call(scope: Post.all, page:, per_page: 20)
    scope
      .preload(REQUIRED_PRELOADS)
      .order(published_at: :desc)
      .page(page)
      .per(per_page)
  end
end

# In the controller:
@posts = PostFeedQuery.call(scope: current_user.feed_posts, page: params[:page])

Now if somebody adds a new virtual attribute to PostSerializer, they have to update REQUIRED_PRELOADS in the same PR. That edit becomes the review prompt. The contract is visible.

strict_loading is non-optional

Rails shipped strict_loading in 6.1 and it changed how I write code. Any association access on a record loaded with strict_loading: true raises (or warns, depending on config) if the association wasn’t already loaded. It turns lazy loading into a CI-catchable error.

I default it on at the model level for any new model I introduce on a hot path.

class Post < ApplicationRecord
  self.strict_loading_by_default = true

  has_many :comments, dependent: :destroy
  has_many :tags, through: :post_tags
  belongs_to :author, class_name: "User"
end

In production I set Rails to raise on violations in test and development, and warn-only in production with a Datadog log metric. That lets the existing legacy code keep running while every new code path is held to the higher bar.

# config/environments/development.rb
config.active_record.action_on_strict_loading_violation = :raise

# config/environments/production.rb
config.active_record.action_on_strict_loading_violation = :log

The warn-only-in-prod call matters. When I rolled strict_loading across the agency portfolio, I tried raise-in-prod first on one client app. It took down a search page within ten minutes. A legacy serializer was reaching into an unloaded association on a code path nobody had touched in two years. We rolled back, switched to log mode, and burned the violations down over six weeks.

CI enforcement

The technical fix is one thing. Keeping the regression from coming back is the other. Three things in CI made the difference:

# .github/workflows/rails-ci.yml
- name: Run tests with Bullet enabled
  env:
    BULLET_RAISE: "true"
    RAILS_ENV: test
  run: bundle exec rspec
# config/environments/test.rb
Bullet.enable        = true
Bullet.bullet_logger = true
Bullet.raise         = ENV["BULLET_RAISE"] == "true"
# spec/support/strict_loading.rb
RSpec.configure do |config|
  config.before(:each) do
    ActiveRecord::Base.action_on_strict_loading_violation = :raise
  end
end

That combination - Bullet in raise mode on CI, strict_loading raising in test - means a new N+1 fails the build. Not a warning in a log somewhere a developer might read someday. A red X on the PR. The team got tired of the red X faster than they got tired of writing preloads.

Takeaways

  • Don’t use includes. Use preload for two-query loading, eager_load for joined filtering, and write your intent down.
  • Serializers cause silent N+1s. Put a REQUIRED_PRELOADS contract next to the serializer, or use a query object.
  • Turn on strict_loading for new models on hot paths. Raise in test, log in production.
  • Make Bullet fail the CI build. Warnings get ignored. Red Xs don’t.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.