# ActiveRecord: risolvere i problemi di query N+1 in Ruby on Rails
> Guida completa per individuare e risolvere le query N+1 in Rails con ActiveRecord. Padroneggia includes, preload, eager_load e gli strumenti di rilevamento automatico.
- 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
---
Le query N+1 rappresentano uno dei problemi di prestazioni più comuni nelle applicazioni Rails. Un semplice ciclo sui record può scatenare centinaia di query SQL inutili, rallentando drasticamente i tempi di risposta. Questa guida copre le tecniche di rilevamento e risoluzione per garantire applicazioni Rails performanti.
> **Impatto in produzione**
>
> Una pagina che mostra 50 articoli con i relativi autori può generare 51 query SQL invece di una sola. In produzione con migliaia di utenti, questo problema diventa critico per i tempi di risposta e il carico del server.
## Comprendere il problema N+1
Il problema N+1 si verifica quando il codice esegue una query per recuperare un elenco di record (1 query), poi esegue una query aggiuntiva per ciascun record per accedere alle sue associazioni (N query). Il nome "N+1" descrive esattamente questo schema: 1 query iniziale + N query per le associazioni.
Un esempio concreto con articoli e relativi autori illustra il problema. Senza ottimizzazione, ogni accesso all'autore di un articolo scatena una nuova query 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
```
Nella view, ogni chiamata a `article.author` scatena una query aggiuntiva al database.
```erb
<% @articles.each do |article| %>
<%= article.title %>
By <%= article.author.name %>
<% end %>
```
Per 100 articoli, questo codice genera 101 query SQL. I log di Rails mostrano chiaramente il problema con query ripetitive.
```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
```
## Risolvere con includes
Il metodo `includes` è la soluzione più comune e raccomandata per correggere i problemi N+1. Indica ad ActiveRecord di precaricare le associazioni in una o due query ottimizzate.
```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 esegue solo due query indipendentemente dal numero di articoli. La prima recupera tutti gli articoli, la seconda recupera tutti gli autori pertinenti.
```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, ...)
```
Le associazioni annidate possono essere precaricate utilizzando la sintassi a hash. Questo approccio è essenziale quando le view accedono a più livelli di associazioni.
```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
```
> **Regola d'oro**
>
> Se una view accede a un'associazione all'interno di un ciclo, tale associazione deve essere precaricata nel controller con `includes`. Conviene verificare sempre i pattern di accesso alle associazioni nelle view.
## Differenze tra includes, preload ed eager_load
Rails offre tre metodi per il precaricamento delle associazioni. Ciascuno utilizza una strategia SQL differente, con casi d'uso specifici.
### preload: query separate
Il metodo `preload` esegue sempre query separate per ciascuna associazione. Funziona in modo efficiente quando nessuna condizione WHERE filtra sulle associazioni.
```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
Il metodo `eager_load` utilizza un LEFT OUTER JOIN per caricare i dati in un'unica query. Diventa obbligatorio quando si filtra o si ordina su colonne delle associazioni.
```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: comportamento intelligente
Il metodo `includes` sceglie automaticamente la strategia migliore. Utilizza `preload` per impostazione predefinita, ma passa a `eager_load` se una clausola WHERE fa riferimento all'associazione.
```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 tabella seguente riassume le differenze tra i tre metodi.
| Metodo | Strategia SQL | Caso d'uso |
|--------|---------------|------------|
| `preload` | Query separate | Precaricamento semplice, senza filtraggio |
| `eager_load` | LEFT OUTER JOIN | Filtraggio/ordinamento su associazioni |
| `includes` | Automatico | Uso generale, valore predefinito raccomandato |
## Rilevamento automatizzato di N+1
Il rilevamento manuale dei problemi N+1 è tedioso e soggetto a errori. Diversi strumenti automatizzano questo rilevamento in sviluppo e CI.
### Bullet: rilevamento in tempo reale
La gem Bullet analizza le query SQL in tempo reale e segnala i problemi N+1 rilevati. Suggerisce inoltre le correzioni appropriate.
```ruby
# Gemfile
# Bullet detects N+1 in development
group :development do
gem 'bullet'
end
```
La configurazione nell'ambiente di sviluppo permette di attivare diverse modalità di avviso.
```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
```
Quando viene rilevato un problema N+1, Bullet visualizza un messaggio esplicito con la soluzione raccomandata.
```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 in CI**
>
> In integrazione continua, abilitare `Bullet.raise = true` fa fallire i test se viene rilevato un problema N+1. Questo previene le regressioni di prestazioni.
### Prosopite: alternativa leggera
La gem Prosopite offre un'alternativa più leggera a Bullet, con configurazione minima e compatibilità con i test.
```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
```
## Tecniche avanzate di ottimizzazione
Oltre ai metodi di base, diverse tecniche permettono di affinare l'ottimizzazione delle query ActiveRecord.
### Strict Loading: prevenzione predefinita
Rails 6.1+ fornisce una modalità di caricamento rigoroso che solleva un'eccezione se viene effettuato l'accesso a un'associazione non precaricata. Questo approccio preventivo costringe a risolvere gli N+1 durante lo sviluppo.
```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
```
Il caricamento rigoroso può essere abilitato anche per una specifica query.
```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 e Pluck per dati parziali
Quando servono solo alcune colonne, `select` e `pluck` riducono la quantità di dati trasferiti dal database.
```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 per i conteggi
I conteggi di associazioni (`article.comments.count`) generano una query SQL a ogni chiamata. Counter cache memorizza questo conteggio direttamente nella tabella 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 migrazione aggiunge la colonna del conteggio con un valore predefinito.
```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
```
Dopo questa configurazione, `article.comments_count` legge direttamente la colonna senza query SQL aggiuntive.
```ruby
# app/views/articles/index.html.erb
# Using counter cache (no SQL query)
<% @articles.each do |article| %>