35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails N+1 Queries: Vinden, Fixen en Voorkomen in Productie

Rails N+1 Queries: Vinden, Fixen en Voorkomen in Productie

TTB Software
rails

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.

#rails #activerecord #n-plus-one #performance #bullet #strict-loading #database
T

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 Touch

Share this article

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