# ActiveRecord: N+1-queryproblemen oplossen in Ruby on Rails > Volledige gids voor het detecteren en oplossen van N+1-queries in Rails met ActiveRecord. Beheers includes, preload, eager_load en geautomatiseerde detectietools. - 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 --- N+1-queries vormen een van de meest voorkomende prestatieproblemen in Rails-applicaties. Een eenvoudige loop over records kan honderden onnodige SQL-queries veroorzaken en de responstijden drastisch vertragen. Deze gids behandelt detectie- en oplostechnieken om performante Rails-applicaties te garanderen. > **Impact in productie** > > Een pagina met 50 artikelen en hun auteurs kan 51 SQL-queries genereren in plaats van slechts één. In productie met duizenden gebruikers wordt dit probleem kritiek voor responstijden en serverbelasting. ## Het N+1-probleem begrijpen Het N+1-probleem treedt op wanneer de code één query uitvoert om een lijst met records op te halen (1 query) en vervolgens een extra query uitvoert voor elk record om toegang te krijgen tot de bijbehorende associaties (N queries). De naam "N+1" beschrijft precies dit patroon: 1 initiële query + N queries voor de associaties. Een concreet voorbeeld met artikelen en hun auteurs maakt het probleem duidelijk. Zonder optimalisatie veroorzaakt elke toegang tot de auteur van een artikel een nieuwe SQL-query. ```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 ``` In de view veroorzaakt elke aanroep van `article.author` een extra query naar de database. ```erb <% @articles.each do |article| %>

<%= article.title %>

By <%= article.author.name %>

<% end %> ``` Voor 100 artikelen genereert deze code 101 SQL-queries. De Rails-logs tonen het probleem duidelijk via repetitieve queries. ```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 ``` ## Oplossen met includes De methode `includes` is de meest gangbare en aanbevolen oplossing voor het verhelpen van N+1-problemen. Ze geeft ActiveRecord opdracht om de associaties vooraf te laden in één of twee geoptimaliseerde queries. ```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 ``` Met `includes` voert ActiveRecord slechts twee queries uit, ongeacht het aantal artikelen. De eerste haalt alle artikelen op, de tweede haalt alle relevante auteurs op. ```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, ...) ``` Geneste associaties kunnen ook vooraf worden geladen met hash-syntaxis. Deze aanpak is essentieel wanneer views toegang hebben tot meerdere niveaus van associaties. ```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 ``` > **Gouden regel** > > Als een view binnen een loop toegang heeft tot een associatie, moet die associatie in de controller vooraf worden geladen met `includes`. Het is belangrijk om altijd de toegangspatronen tot associaties in de views te controleren. ## Verschillen tussen includes, preload en eager_load Rails biedt drie methoden om associaties vooraf te laden. Elk gebruikt een andere SQL-strategie, met specifieke gebruiksgevallen. ### preload: aparte queries De methode `preload` voert altijd aparte queries uit voor elke associatie. Ze werkt efficiënt wanneer geen WHERE-conditie filtert op de associaties. ```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 De methode `eager_load` gebruikt een LEFT OUTER JOIN om de gegevens in één query te laden. Ze wordt verplicht bij het filteren of sorteren op kolommen van associaties. ```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: slim gedrag De methode `includes` kiest automatisch de beste strategie. Standaard gebruikt ze `preload`, maar schakelt over op `eager_load` als een WHERE-clausule naar de associatie verwijst. ```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 ``` De volgende tabel vat de verschillen tussen de drie methoden samen. | Methode | SQL-strategie | Toepassing | |---------|---------------|------------| | `preload` | Aparte queries | Eenvoudige preloading, geen filtering | | `eager_load` | LEFT OUTER JOIN | Filteren/sorteren op associaties | | `includes` | Automatisch | Algemeen gebruik, aanbevolen standaard | ## Geautomatiseerde N+1-detectie Het handmatig opsporen van N+1-problemen is moeizaam en foutgevoelig. Verschillende tools automatiseren deze detectie in development en CI. ### Bullet: realtime detectie De gem Bullet analyseert SQL-queries in realtime en waarschuwt voor gedetecteerde N+1-problemen. Daarnaast stelt ze passende oplossingen voor. ```ruby # Gemfile # Bullet detects N+1 in development group :development do gem 'bullet' end ``` De configuratie in de development-omgeving maakt het mogelijk om verschillende waarschuwingsmodi in te schakelen. ```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 ``` Wanneer een N+1-probleem wordt gedetecteerd, toont Bullet een duidelijke melding met de aanbevolen oplossing. ```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 continuous integration zorgt het inschakelen van `Bullet.raise = true` ervoor dat tests falen als een N+1-probleem wordt gedetecteerd. Zo worden prestatieregressies voorkomen. ### Prosopite: lichtgewicht alternatief De gem Prosopite biedt een lichter alternatief voor Bullet, met minimale configuratie en compatibiliteit met tests. ```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 ``` ## Geavanceerde optimalisatietechnieken Naast de basismethoden zijn er meerdere technieken om de optimalisatie van ActiveRecord-queries fijn af te stemmen. ### Strict Loading: preventie als standaard Rails 6.1+ biedt een strict-loadingmodus die een uitzondering oproept als er toegang wordt verkregen tot een niet vooraf geladen associatie. Deze preventieve aanpak dwingt om N+1's tijdens de ontwikkeling op te lossen. ```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 ``` Strict loading kan ook per specifieke query worden ingeschakeld. ```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 en Pluck voor gedeeltelijke gegevens Wanneer slechts bepaalde kolommen nodig zijn, verminderen `select` en `pluck` de hoeveelheid gegevens die uit de database wordt overgedragen. ```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 voor tellingen Associatietellingen (`article.comments.count`) genereren bij elke aanroep een SQL-query. Counter cache slaat deze telling rechtstreeks op in de bovenliggende tabel. ```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 ``` De migratie voegt de tellingskolom toe met een standaardwaarde. ```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 ``` Na deze configuratie leest `article.comments_count` de kolom rechtstreeks zonder extra SQL-queries. ```ruby # app/views/articles/index.html.erb # Using counter cache (no SQL query) <% @articles.each do |article| %>

<%= article.title %> - <%= article.comments_count %> comments

<% end %> ``` ## Best practices en checklist Een systematische aanpak voorkomt N+1-problemen bij nieuwe ontwikkelingen en lost bestaande code geleidelijk op. ### View-analyse vóór de code Voordat de controllercode wordt geschreven, is het verstandig om de view te analyseren om alle benaderde associaties te identificeren. Deze vroege inventarisatie voorkomt omissies. ```ruby # app/controllers/articles_controller.rb # Pre-analyze view to identify required includes class ArticlesController < ApplicationController def show # View accesses: author, author.company, comments, comments.user # All these must be preloaded @article = Article.includes( author: :company, comments: :user ).find(params[:id]) end end ``` ### Herbruikbare scopes Frequente includes centraliseren in scopes vereenvoudigt het onderhoud en zorgt voor consistentie. ```ruby # app/models/article.rb # Reusable scopes for preloading class Article < ApplicationRecord # Scope for list display scope :with_author, -> { includes(:author) } # Scope for detailed display scope :with_full_details, -> { includes( author: :company, comments: { user: :avatar_attachment }, tags: [] ) } # Scope for admin with all relations scope :for_admin, -> { includes(:author, :comments, :tags, :category) .with_attached_cover_image } end ``` ### Preventieve checklist Deze checklist vat de essentiële controlepunten samen om N+1-problemen te vermijden: - Bullet of Prosopite installeren en configureren in development - Bullet.raise inschakelen in CI om regressies te blokkeren - Views analyseren om associaties te identificeren voordat controllers worden geschreven - Standaard `includes` gebruiken, `eager_load` bij filtering op associaties - Herbruikbare scopes maken voor frequente preloadingpatronen - Strict loading toepassen op gevoelige modellen - Counter caches toevoegen voor frequente tellingen - Regelmatig de SQL-logs in development controleren > **Pas op voor te veel preloaden** > > Te veel associaties vooraf laden verbruikt onnodig geheugen. Alleen associaties die de view daadwerkelijk gebruikt, dienen te worden vooraf geladen. Tools zoals Bullet detecteren ook "unused eager loading". ## Conclusie N+1-queries vormen een belangrijk prestatieprobleem dat in Rails-applicaties eenvoudig te voorkomen is. De combinatie van geautomatiseerde detectietools en goede ontwikkelpraktijken elimineert dit probleem effectief. **Belangrijkste punten:** - `includes` lost de meeste N+1-gevallen op door associaties vooraf te laden - `eager_load` is vereist bij het filteren of sorteren op associaties - Bullet en Prosopite detecteren problemen automatisch in development - Strict loading voorkomt N+1's door uitzonderingen op te roepen - Counter caches optimaliseren frequente tellingen - Views analyseren vóór het schrijven van controllers voorkomt omissies --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/nl/blog/ruby-on-rails/activerecord-fixing-n-plus-1-query-problems