# ActiveRecord: Resolver problemas de consultas N+1 en Ruby on Rails
> Guía completa para detectar y resolver consultas N+1 en Rails con ActiveRecord. Domina includes, preload, eager_load y herramientas de detección automática.
- Published: 2026-02-23
- Updated: 2026-04-29
- Author: Anthony Fillion-Maillet
- Tags: ruby on rails, activerecord, performance, n+1 queries, sql optimization
- Reading time: 10 min
---
Las consultas N+1 representan uno de los problemas de rendimiento más comunes en aplicaciones Rails. Un simple bucle sobre registros puede desencadenar cientos de consultas SQL innecesarias, ralentizando drásticamente los tiempos de respuesta. Esta guía cubre las técnicas de detección y resolución para garantizar aplicaciones Rails performantes.
> **Impacto en producción**
>
> Una página que muestra 50 artículos con sus autores puede generar 51 consultas SQL en lugar de una sola. En producción con miles de usuarios, este problema se vuelve crítico para los tiempos de respuesta y la carga del servidor.
## Comprender el problema N+1
El problema N+1 ocurre cuando el código ejecuta una consulta para obtener una lista de registros (1 consulta), y luego ejecuta una consulta adicional por cada registro para acceder a sus asociaciones (N consultas). El nombre "N+1" describe exactamente este patrón: 1 consulta inicial + N consultas para las asociaciones.
Un ejemplo concreto con artículos y sus autores ilustra el problema. Sin optimización, cada acceso al autor de un artículo desencadena una nueva consulta SQL.
```ruby
# app/controllers/articles_controller.rb
# Example code generating an N+1 problem
class ArticlesController < ApplicationController
def index
# 1 query: SELECT * FROM articles
@articles = Article.all
end
end
```
En la vista, cada llamada a `article.author` desencadena una consulta adicional a la base de datos.
```erb
<% @articles.each do |article| %>
<%= article.title %>
By <%= article.author.name %>
<% end %>
```
Para 100 artículos, este código genera 101 consultas SQL. Los logs de Rails muestran claramente el problema con consultas repetitivas.
```sql
-- Rails logs showing the N+1 problem
-- 1 initial query
SELECT "articles".* FROM "articles"
-- N queries for authors (repeated for each article)
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1
-- ... 97 more queries
```
## Resolver con includes
El método `includes` es la solución más común y recomendada para corregir los problemas N+1. Indica a ActiveRecord que precargue las asociaciones en una o dos consultas optimizadas.
```ruby
# app/controllers/articles_controller.rb
# Solution with includes - preloading authors
class ArticlesController < ApplicationController
def index
# Preloads authors with articles
# Generates only 2 queries instead of N+1
@articles = Article.includes(:author).all
end
end
```
Con `includes`, ActiveRecord ejecuta solo dos consultas independientemente del número de artículos. La primera obtiene todos los artículos, la segunda recupera todos los autores relevantes.
```sql
-- Rails logs with includes (only 2 queries)
SELECT "articles".* FROM "articles"
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5, ...)
```
Las asociaciones anidadas también pueden precargarse usando la sintaxis de hash. Este enfoque resulta esencial cuando las vistas acceden a varios niveles de asociaciones.
```ruby
# app/controllers/articles_controller.rb
# Preloading nested associations
class ArticlesController < ApplicationController
def index
# Preloads author -> company and all comments
@articles = Article.includes(author: :company, comments: :user)
end
end
```
> **Regla de oro**
>
> Si una vista accede a una asociación dentro de un bucle, esa asociación debe precargarse en el controlador con `includes`. Verificar siempre los patrones de acceso a asociaciones en las vistas.
## Diferencias entre includes, preload y eager_load
Rails ofrece tres métodos para la precarga de asociaciones. Cada uno utiliza una estrategia SQL diferente, con casos de uso específicos.
### preload: consultas separadas
El método `preload` siempre ejecuta consultas separadas para cada asociación. Funciona eficientemente cuando ninguna condición WHERE filtra sobre las asociaciones.
```ruby
# app/models/article.rb
# preload always uses separate queries
class Article < ApplicationRecord
scope :with_authors, -> { preload(:author) }
end
# Usage in controller
@articles = Article.with_authors.limit(20)
# SQL generated:
# SELECT "articles".* FROM "articles" LIMIT 20
# SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, ...)
```
### eager_load: LEFT OUTER JOIN
El método `eager_load` utiliza un LEFT OUTER JOIN para cargar los datos en una sola consulta. Se vuelve obligatorio al filtrar u ordenar sobre columnas de asociaciones.
```ruby
# app/controllers/articles_controller.rb
# eager_load allows filtering on associations
class ArticlesController < ApplicationController
def verified_authors
# Filters articles by author status
# Requires eager_load because WHERE references users
@articles = Article.eager_load(:author)
.where(users: { verified: true })
.order("users.name ASC")
end
end
# SQL generated (single query with JOIN):
# SELECT "articles"."id", "articles"."title", ...
# FROM "articles"
# LEFT OUTER JOIN "users" ON "users"."id" = "articles"."author_id"
# WHERE "users"."verified" = TRUE
# ORDER BY "users"."name" ASC
```
### includes: comportamiento inteligente
El método `includes` elige automáticamente la mejor estrategia. Utiliza `preload` por defecto, pero cambia a `eager_load` si una cláusula WHERE hace referencia a la asociación.
```ruby
# app/controllers/articles_controller.rb
# includes adapts automatically to context
class ArticlesController < ApplicationController
def index
# No condition on association: uses preload (2 queries)
@articles = Article.includes(:author).all
end
def by_verified_authors
# With condition on association: uses eager_load (JOIN)
@articles = Article.includes(:author)
.where(users: { verified: true })
end
end
```
La siguiente tabla resume las diferencias entre los tres métodos.
| Método | Estrategia SQL | Caso de uso |
|--------|----------------|-------------|
| `preload` | Consultas separadas | Precarga simple, sin filtrado |
| `eager_load` | LEFT OUTER JOIN | Filtrado/ordenación sobre asociaciones |
| `includes` | Automático | Uso general, valor predeterminado recomendado |
## Detección automatizada de N+1
La detección manual de problemas N+1 resulta tediosa y propensa a errores. Varias herramientas automatizan esta detección en desarrollo y CI.
### Bullet: detección en tiempo real
La gema Bullet analiza las consultas SQL en tiempo real y alerta sobre los problemas N+1 detectados. También sugiere las correcciones apropiadas.
```ruby
# Gemfile
# Bullet detects N+1 in development
group :development do
gem 'bullet'
end
```
La configuración en el entorno de desarrollo permite activar varios modos de alerta.
```ruby
# config/environments/development.rb
# Bullet configuration to detect N+1
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
# Display JavaScript alert in browser
Bullet.alert = true
# Add footer with details
Bullet.bullet_logger = true
# Display in Rails logs
Bullet.rails_logger = true
# Raise exception (useful in CI)
Bullet.raise = false
end
end
```
Cuando se detecta un problema N+1, Bullet muestra un mensaje explícito con la solución recomendada.
```text
# Example Bullet alert in logs
USE eager loading detected
Article => [:author]
Add to your query: .includes([:author])
Call stack:
/app/views/articles/index.html.erb:5
```
> **Bullet en CI**
>
> En integración continua, activar `Bullet.raise = true` provoca que las pruebas fallen si se detecta un problema N+1. Esto previene regresiones de rendimiento.
### Prosopite: alternativa ligera
La gema Prosopite ofrece una alternativa más ligera a Bullet, con una configuración mínima y compatibilidad con las pruebas.
```ruby
# Gemfile
# Prosopite as an alternative to Bullet
group :development, :test do
gem 'prosopite'
end
```
```ruby
# config/environments/development.rb
# Prosopite configuration
Rails.application.configure do
config.after_initialize do
Prosopite.rails_logger = true
Prosopite.raise = Rails.env.test?
end
end
```
## Técnicas avanzadas de optimización
Más allá de los métodos básicos, varias técnicas permiten afinar la optimización de las consultas ActiveRecord.
### Strict Loading: prevención por defecto
Rails 6.1+ proporciona un modo de carga estricta que lanza una excepción si se accede a una asociación no precargada. Este enfoque preventivo obliga a resolver los N+1 durante el desarrollo.
```ruby
# app/models/article.rb
# Enable strict loading by default on the model
class Article < ApplicationRecord
# Any non-preloaded association access raises an exception
self.strict_loading_by_default = true
belongs_to :author
has_many :comments
end
```
La carga estricta también puede activarse para una consulta específica.
```ruby
# app/controllers/articles_controller.rb
# Strict loading on a specific query
class ArticlesController < ApplicationController
def index
# Raises StrictLoadingViolationError if a non-included
# association is accessed
@articles = Article.strict_loading.includes(:author)
end
end
```
### Select y Pluck para datos parciales
Cuando solo se necesitan ciertas columnas, `select` y `pluck` reducen la cantidad de datos transferidos desde la base de datos.
```ruby
# app/controllers/reports_controller.rb
# Optimization with select and pluck
class ReportsController < ApplicationController
def titles_only
# select returns Article objects with only id and title
@articles = Article.select(:id, :title)
end
def title_array
# pluck returns an Array of values, not AR objects
# More performant when only values are needed
@titles = Article.pluck(:title)
# => ["First article", "Second article", ...]
end
end
```
### Counter Cache para conteos
Los conteos de asociaciones (`article.comments.count`) generan una consulta SQL en cada llamada. Counter cache almacena este conteo directamente en la tabla padre.
```ruby
# app/models/comment.rb
# Counter cache configuration
class Comment < ApplicationRecord
# Rails automatically maintains the counter in articles.comments_count
belongs_to :article, counter_cache: true
end
```
La migración añade la columna de conteo con un valor predeterminado.
```ruby
# db/migrate/20260223_add_comments_count_to_articles.rb
# Migration to add counter cache
class AddCommentsCountToArticles < ActiveRecord::Migration[7.1]
def change
add_column :articles, :comments_count, :integer, default: 0, null: false
# Initialize counters for existing data
Article.find_each do |article|
Article.reset_counters(article.id, :comments)
end
end
end
```
Después de esta configuración, `article.comments_count` lee directamente la columna sin consultas SQL adicionales.
```ruby
# app/views/articles/index.html.erb
# Using counter cache (no SQL query)
<% @articles.each do |article| %>