El problema N+1 en ActiveRecord: tres formas de resolverlo, una de prevenirlo
TL;DR
includeselige automáticamente entrepreloadyeager_load— funciona en la mayoría de casospreloadsiempre hace queries separadas (2 queries) — mejor cuando no filtras por la asociacióneager_loadsiempre hace JOIN — necesario cuando filtras por campos de la asociación enwhere- Bullet gem detecta N+1 en desarrollo pero tiene falsos positivos — úsala, pero no la sigas ciegamente
- La prevención real: profiling en desarrollo antes de que llegue a producción —
rack-mini-profilero logs de queries SQL
El N+1 viene gratis con el kit del dev Rails. Te lo enseñan (o lo descubres solo cuando la app se pone lenta), lo arreglas con includes, y piensas que ya quedó. Y sí, quedó… hasta que alguien escribe un scope con .map dentro, o hasta que le agregas un where a una asociación y includes silenciosamente cambia de comportamiento. Este post es sobre esos casos.
El N+1 clásico
El problema lo conoces de memoria, pero lo revisamos rápido:
# En el controller
@posts = Post.all
# En la vista
@posts.each do |post|
puts post.author.name # ← query por cada post
end
Con 50 posts: 1 query para los posts + 50 queries para los autores = 51 queries. N+1.
La solución que todos conocen:
@posts = Post.includes(:author).all
# 2 queries: SELECT posts, SELECT users WHERE id IN (...)
Pero hay tres formas de hacer eager loading, y no son intercambiables.
includes vs preload vs eager_load
preload — siempre queries separadas
Post.preload(:author)
# => SELECT * FROM posts
# => SELECT * FROM users WHERE id IN (1, 2, 3, ...)
Dos queries, siempre. Funciona bien para asociaciones simples. El problema: no puedes filtrar por campos del autor en el mismo query:
# Esto NO funciona con preload
Post.preload(:author).where('authors.verified = true')
# => ActiveRecord::StatementInvalid: column "authors.verified" does not exist
eager_load — siempre LEFT OUTER JOIN
Post.eager_load(:author)
# => SELECT posts.*, users.* FROM posts
# LEFT OUTER JOIN users ON users.id = posts.author_id
Un solo query con JOIN. Necesario cuando filtras por la asociación:
# Esto SÍ funciona con eager_load
Post.eager_load(:author).where('users.verified = true')
# => SELECT posts.*, users.* FROM posts
# LEFT OUTER JOIN users ON users.id = posts.author_id
# WHERE users.verified = true
El costo: si hay muchos registros o la asociación tiene muchas columnas, el JOIN puede ser más pesado que dos queries separadas.
includes — elige automáticamente
Post.includes(:author)
# → usa preload por defecto (2 queries separadas)
Post.includes(:author).where('users.verified = true')
# → detecta referencia a la tabla users → cambia a eager_load (JOIN)
includes es inteligente: revisa si el where referencia tablas de la asociación y decide qué usar. Para el 90% de los casos, includes es la elección correcta.
El N+1 que aparece en scopes — el que te muerde de noche
Este es el más traicionero porque está oculto. Lo escribes una vez, parece inocente, y cada controller que lo usa sin saberlo te genera N queries extra:
class Post < ApplicationRecord
belongs_to :author
has_many :comments
# Este scope crea N+1 para cualquiera que lo use sin saberlo
def self.with_comment_count
all.map { |post| post.comments.count }
# 1 query para posts + 1 query por post para el count
end
end
# En el controller — parece inocente
@posts = Post.published.with_comment_count
El fix: hacer el trabajo en SQL, no en Ruby:
def self.with_comment_count
left_joins(:comments)
.group('posts.id')
.select('posts.*, COUNT(comments.id) AS comments_count')
end
# Ahora posts tienen comments_count como atributo virtual
@posts.first.comments_count # => 12 — sin query extra
La gem Bullet: útil pero no perfecta
Bullet detecta N+1 automáticamente en desarrollo:
# Gemfile
gem 'bullet', group: :development
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true # alerta en browser
Bullet.rails_logger = true # log en Rails logs
Bullet.add_footer = true # footer en cada página
end
Con Bullet activo, el N+1 aparece en logs:
USE eager loading detected
Post => [:author]
Add to your query: .includes([:author])
Pero tiene falsos positivos. El más común:
# Bullet grita N+1 aquí...
@user = User.find(params[:id])
@user.posts.count # Un count SQL, no un N+1 real
# ...pero es un solo query:
# SELECT COUNT(*) FROM posts WHERE user_id = ?
Bullet no distingue bien entre user.posts.count (un query SQL) y iterar sobre user.posts para hacer algo con cada uno (N+1 real). Úsala como guía, no como árbitro final.
strict_loading: N+1 como error en producción
Rails 6.1 introdujo strict_loading — lanza un error si intentas acceder a una asociación no cargada:
# A nivel de modelo — todas las asociaciones requieren eager loading
class Post < ApplicationRecord
self.strict_loading_by_default = true
end
# O por query
@posts = Post.strict_loading.all
@posts.first.author.name
# => ActiveRecord::StrictLoadingViolationError:
# `Post` is marked for strict_loading. The `author` association named
# cannot be lazily loaded.
Úsalo en staging o en un porcentaje del tráfico de producción para detectar N+1 que Bullet no ve en desarrollo. No lo actives en producción al 100% — puede romper funcionalidad inesperadamente si hay N+1 en paths poco testeados.
El caso de las asociaciones nested
# Eager loading de múltiples niveles
@posts = Post.includes(comments: :author)
# => posts con comments, cada comment con su author
# => 3 queries: posts, comments IN (...), users IN (...)
# Múltiples asociaciones en paralelo
@posts = Post.includes(:author, :tags, comments: [:author, :reactions])
# => posts → authors, tags, comments → comment authors, reactions
Para más de dos niveles de nesting, verifica el query plan — a veces es más eficiente hacer queries separadas que un JOIN masivo.
La prevención que realmente funciona
rack-mini-profiler muestra el número de queries en cada request durante desarrollo:
# Gemfile
gem 'rack-mini-profiler', group: :development
gem 'flamegraph', group: :development # para profiling de CPU
gem 'memory_profiler', group: :development
# config/initializers/rack_mini_profiler.rb
if Rails.env.development?
Rack::MiniProfiler.config.position = 'bottom-right'
end
Aparece una badge en el browser con el número de queries y el tiempo de cada uno. Si ves “47 queries” en una página que debería hacer 3, tienes un N+1.
La diferencia con Bullet: rack-mini-profiler te muestra el estado real de cada request, incluyendo paths que no tienes en tests. Es passive monitoring — siempre está ahí mientras desarrollas.
# También puedes agregar al log de Rails cuántos queries hace cada request
# config/environments/development.rb
config.log_level = :debug # logea cada query SQL
N+1 no es un bug, es un patrón que aparece naturalmente cuando no pensás en eager loading desde el inicio. La clave es detectarlo en desarrollo, no en producción a las 3am. rack-mini-profiler + SQL logs + Bullet para alertas automáticas te da suficiente cobertura para el 95% de los casos.
El 5% restante son los scopes que escribiste tú mismo sin darte cuenta. Ya te la sabes.