Rails N+1 Queries Explained: Elimination, Preload, and Strict Loading
How accidental query loops degrade database throughput and the exact ActiveRecord techniques to eliminate them.
1. The Anatomy of an N+1 Query
Why a clean 3-line loop spawns hundreds of database round-trips
ActiveRecord associations are lazily loaded by default. When an application queries a list of authors and later iterates over each author's books, ActiveRecord fires a fresh SELECT query for every single iteration step.
With 100 authors, this results in 1 initial query plus 100 individual child queries: 101 round-trips over the network to PostgreSQL. Database connection pool saturation and slow response times inevitably follow.
# BAD: Causes 1 + N queries (1 for authors, 50 for books)
authors = Author.limit(50)
authors.each do |author|
puts "#{author.name}: #{author.books.count} books"
end
# GOOD: Preloads all books in just 2 queries total
authors = Author.includes(:books).limit(50)
authors.each do |author|
puts "#{author.name}: #{author.books.size} books" # Note: use .size, not .count
end2. Under the Hood: Preload vs Eager Load vs Includes
Understanding how ActiveRecord chooses between JOINs and IN (...) queries
Many developers use 'includes' without understanding whether ActiveRecord will perform a SQL JOIN or multiple separate queries. The choice carries significant memory and database performance implications.
Preload executes two clean queries using WHERE foreign_key IN (...). This is usually the fastest strategy when loading large 'has_many' collections because it avoids multiplying result set rows through Cartesian products.
Eager load forces a LEFT OUTER JOIN. This is necessary when you need to filter or sort parent records based on child columns (e.g., Author.eager_load(:books).where('books.published_year > 2020')).
# Strategy 1: Preload (2 separate SQL queries)
# Query 1: SELECT * FROM authors LIMIT 20;
# Query 2: SELECT * FROM books WHERE author_id IN (1, 2, 3...);
Author.preload(:books).limit(20)
# Strategy 2: Eager Load (Single SQL query with LEFT OUTER JOIN)
# Query 1: SELECT authors.*, books.* FROM authors
# LEFT OUTER JOIN books ON books.author_id = authors.id;
Author.eager_load(:books).where("books.in_stock = true")3. Strict Loading: Permanent Prevention in Rails 6.1+
Turning accidental lazy-loading bugs into immediate test failures
Relying on code reviews or log audits to catch N+1 queries is error-prone. Modern Rails applications prevent N+1 regressions automatically using strict_loading.
When strict loading is enabled on an association or relation, any attempt to lazily load an unloaded association raises ActiveRecord::StrictLoadingViolationError in test environments.
# Enable globally on a model association
class Author < ApplicationRecord
has_many :books, strict_loading: true
end
# Or enable per query in controller actions
def index
@authors = Author.strict_loading.includes(:books).page(params[:page])
end
# In config/environments/development.rb and test.rb:
config.active_record.action_on_strict_loading_violation = :raiseRelated Developer Tools & Products
Continue Reading
All Articles →Understanding Database Transactions and Isolation Through a Real Banking Example
Transactions protect data integrity, but default isolation levels can still permit concurrency bugs like lost updates and phantom reads. Here is what happens when two users read and write simultaneously.
Understanding Ruby Blocks, Procs, and Lambdas: Closures in Practice
Master Ruby closures: when to yield, why return behaves differently in procs versus lambdas, arity enforcement, and building clean DSLs in Rails applications.