Rails N+1 Queries: Vinden, Fixen en Voorkomen in Productie
Een N+1 query ontstaat wanneer je code een collectie records laadt en vervolgens één extra query uitvoert per record om een associatie op te halen. 100 orders laden, 100 queries om elke klant op te halen. Dat zijn 101 queries waar 1 of 2 voldoende zijn.
Dit is het meest voorkomende performanceprobleem in Rails-applicaties. Ik heb N+1s gezien die 3-8 seconden toevoegden aan laadtijden in productie-apps met een paar honderd records. De fix kost meestal minder dan een minuut zodra je het probleem gevonden hebt.
Hoe een N+1 Eruitziet
Een controller-action die er onschuldig uitziet:
# app/controllers/orders_controller.rb
def index
@orders = Order.where(status: :recent).limit(50)
end
En een view die het probleem triggert:
<%# app/views/orders/index.html.erb %>
<% @orders.each do |order| %>
<tr>
<td><%= order.customer.name %></td>
<td><%= order.product.title %></td>
<td><%= order.total %></td>
</tr>
<% end %>
In je logs zie je dan:
SELECT "orders".* FROM "orders" WHERE "orders"."status" = 'recent' LIMIT 50
SELECT "customers".* FROM "customers" WHERE "customers"."id" = 1
SELECT "products".* FROM "products" WHERE "products"."id" = 42
SELECT "customers".* FROM "customers" WHERE "customers"."id" = 2
SELECT "products".* FROM "products" WHERE "products"."id" = 17
-- ... nog 96 queries
Dat zijn 101 queries. Elke query heeft netwerk-overhead naar de database, query-parsing tijd en result-serialisatie. Zelfs als elke query maar 2ms duurt, besteed je 200ms aan wat een enkele 5ms-query kon zijn.
De Drie Fixes: includes, preload en eager_load
Rails geeft je drie methoden om N+1 queries te elimineren. Ze zijn niet uitwisselbaar.
includes — De Slimme Standaard
@orders = Order.where(status: :recent)
.includes(:customer, :product)
.limit(50)
includes laat Rails de laadstrategie bepalen. Standaard gebruikt het twee aparte queries (zoals preload), maar schakelt over naar een LEFT OUTER JOIN (zoals eager_load) als je de associatie refereert in een where- of order-clause.
Dit produceert:
SELECT "orders".* FROM "orders" WHERE "orders"."status" = 'recent' LIMIT 50
SELECT "customers".* FROM "customers" WHERE "customers"."id" IN (1, 2, 3, ...)
SELECT "products".* FROM "products" WHERE "products"."id" IN (42, 17, 8, ...)
Drie queries totaal. Van 101 naar 3.
preload — Altijd Aparte Queries
@orders = Order.where(status: :recent)
.preload(:customer, :product)
.limit(50)
preload voert altijd aparte queries uit. Gebruik het als je niet hoeft te filteren of sorteren op kolommen van de associatie. Het is iets efficiënter dan een JOIN voor simpele gevallen omdat elke query de indexes van één tabel raakt.
eager_load — Altijd JOIN
@orders = Order.where(status: :recent)
.eager_load(:customer)
.where(customers: { vip: true })
.limit(50)
eager_load gebruikt LEFT OUTER JOIN en laadt alles in één query. Gebruik het als je moet filteren of sorteren op kolommen van de geassocieerde tabel.
De trade-off: JOINs retourneren bredere result sets met gedupliceerde data bij one-to-many associaties. Als een order 5 regelitems heeft, retourneert de JOIN 5 rijen per order. Rails dedupliceert ze in geheugen, maar je transfereert meer data van de database.
Wanneer Gebruik je Wat
| Situatie | Methode |
|---|---|
| Simpel laden, geen filtering | includes of preload |
| Filteren/sorteren op associatiekolommen | eager_load |
| Polymorphic associaties | preload (JOINs werken niet met polymorphic) |
| Diep geneste associaties | includes met hash-syntax |
| Expliciete controle | preload of eager_load direct |
Geneste Eager Loading
Echte applicaties hebben geneste associaties. Rails handelt dit af met hash-syntax:
@orders = Order.includes(
customer: :address,
line_items: { product: :category }
).where(status: :recent).limit(50)
Dit voert 6 queries uit in plaats van potentieel honderden.
N+1s Vinden met Bullet
De Bullet gem detecteert N+1 queries in development- en testomgevingen. Het bestaat sinds 2009 en blijft de standaardtool hiervoor.
# Gemfile
group :development, :test do
gem "bullet"
end
Configureer het in je omgevingsbestanden:
# config/environments/development.rb
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.console = true
Bullet.rails_logger = true
Bullet.add_footer = true
end
end
Voor je testsuite, configureer het om exceptions te gooien zodat N+1s je CI-pipeline breken:
# config/environments/test.rb
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.raise = true
end
end
Bullet detecteert ook ongebruikte eager loading — wanneer je een associatie preloadt die nooit wordt benaderd. Dit verspilt geheugen en querytijd.
strict_loading: Rails’ Ingebouwde Preventie (Rails 6.1+)
Sinds Rails 6.1 kun je lazy loading een error laten gooien in plaats van stilletjes queries afvuren.
Per-query strict loading
@orders = Order.strict_loading.where(status: :recent).limit(50)
# Dit gooit nu ActiveRecord::StrictLoadingViolationError:
@orders.first.customer
Applicatie-breed strict loading
In Rails 7+ kun je het globaal inschakelen:
# config/application.rb
config.active_record.strict_loading_by_default = true
Dit is agressief. Elke lazy load gooit een error. Handig voor nieuwe applicaties waar je eager loading vanaf dag één wilt afdwingen. Voor bestaande apps: begin met per-model of per-query en werk geleidelijk door de violations.
strict_loading met :n_plus_one_only (Rails 7.1+)
Rails 7.1 voegde een praktischere modus toe die alleen echte N+1 patronen flagged:
class Order < ApplicationRecord
self.strict_loading_mode = :n_plus_one_only
end
Dit laat single-record lazy loads werken (Order.find(1).customer) maar gooit bij collectie lazy loads (Order.all.each { |o| o.customer }). Minder ruis, vangt de queries die daadwerkelijk performance raken.
De Counter Cache
Een specifieke N+1 variant bij het tonen van aantallen:
<% @categories.each do |category| %>
<td><%= category.products.count %></td> <%# COUNT query per rij %>
<% end %>
Gebruik een counter cache:
# migration
add_column :categories, :products_count, :integer, default: 0, null: false
class Product < ApplicationRecord
belongs_to :category, counter_cache: true
end
Nu leest category.products_count uit een kolom — helemaal geen query. Rails houdt het aantal automatisch bij.
N+1s Monitoren in Productie
Development-detectie mist N+1s die alleen verschijnen met echte data. Gebruik Prosopite voor productie-monitoring of je APM-tool.
Als je OpenTelemetry voor observability gebruikt, tonen je traces herhaalde database-spans met identieke querypatronen.
Performance: Hoe Erg Kan Het Worden?
Benchmark op PostgreSQL 16 met 10.000 orders, elk met een klant en 3 regelitems:
| Aanpak | Queries | Tijd |
|---|---|---|
| N+1 (geen eager loading) | 10.001 | 4.200ms |
includes(:customer) |
2 | 18ms |
eager_load(:customer) |
1 | 22ms |
preload(:customer, line_items: :product) |
4 | 45ms |
De N+1 versie is 230x trager. In productie met netwerklatentie wordt die factor nog groter.
FAQ
Hoe fix ik N+1 queries in Rails API-only applicaties?
De fix is identiek: voeg includes, preload of eager_load toe aan je controller-queries voordat de serializer ze raakt. API-apps hebben vaker N+1s omdat serializers diepe associatieketens benaderen. Gebruik strict_loading op je API-controllers om ze vroeg te vangen.
Vertraagt includes queries als ik de associatie niet nodig heb?
Ja. Bullet detecteert dit als “unused eager loading.” Als je includes(:customer) doet maar nooit order.customer benadert, heb je data voor niets geladen. Laad alleen eager wat je daadwerkelijk gebruikt.
Moet ik strict_loading in productie gebruiken?
Gebruik strict_loading_mode: :n_plus_one_only in productie met een custom violation handler die logt in plaats van een error te gooien. Je wilt geen gebruiker-zichtbare fouten, maar wel zichtbaarheid. Volle strict_loading met errors hoort in development en CI.
Kunnen N+1 queries mijn database crashen?
Ze crashen PostgreSQL of MySQL niet direct, maar kunnen je connection pool uitputten. Als elk webverzoek 500 queries afvuurt en een connectie 2 seconden vasthoudt in plaats van 20ms, raken je connecties snel op. Ik heb gezien dat een pool van 50 connecties verzadigd raakte door 25 gelijktijdige gebruikers vanwege N+1s op een dashboardpagina.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation