A Deep Dive into Rails Loading Patterns: Eager, Lazy, and Strict Loading Explained

Building efficient web applications often comes down to how we handle data loading. In Ruby on Rails, choosing the right strategy for loading records and their associations can significantly impact performance.
In this post, we’ll explore lazy loading, eager loading, and strict loading. We’ll discuss their uses, how to implement them, and common pitfalls like the N+1 query problem.
Lt’s begin with the most intuitive loading design pattern.
To illustrate these concepts, we will work with the following model definitions:
1
2
3
4
5
6
7
class Post < ApplicationRecord
has_many :comments, dependent: :destroy
end
class Comment < ApplicationRecord
belongs_to :post
end
Let’s begin with the most intuitive loading design pattern.
Lazy Loading:
Lazy loading means that the data will be loaded on-demand.
Instead of fetching all related data upfront (as is done in case of eager loading
), Rails will retrieve the related records only when you explicitly access them. As the name suggests, lazy loading will defer the loading of an object until the moment that it is actually needed.
This loading pattern can enhance the performance of an application by reducing the initial load times and conserving system resources, especially when dealing with a large amounts of data.
1
2
3
4
5
6
7
8
posts = Post.all
posts.each do |post|
# This will generate a separate query for each post's comments
post.comments.each do |comment|
puts comment.text
end
end
and the resulting SQL will be:
1
2
3
4
5
6
7
8
9
10
SELECT "posts".* FROM "posts";
/* assuming there are N posts */
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1;
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 2;
.
.
.
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = N;
The N+1 Problem
This approach leads to what is known as the N+1 query problem
. In the example above, the first query retrieves all posts, and then for each post (N posts), an additional query is executed to retrieve its comments. This results in:
- 1 query to load all posts.
- N queries to load comments for each post.
This can significantly increase the number of database queries, leading to performance issues, especially when dealing with a large number of posts. As the dataset grows, the performance worsens!
This brings us to our next loading pattern.
Eager Loading:
In Ruby on Rails, eager loading is a loading pattern which optimizes database queries by loading the associated records of an object in advance.
So using eager loading we can reduce the previous example to only 2 SQL queries.
1
2
3
4
5
6
7
8
9
posts = Post.includes(:comments).all
posts.each do |post|
# No additional SQL queries will be generated here for post.comments
post.comments.each do |comment|
puts comment.text
end
end
This will result in the following SQL queries:
1
2
3
4
5
6
7
8
9
/* the following queries will be generated by
Post.includes(:comments).all */
SELECT "posts".* FROM "posts";
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, .. N);
/* in this case, post.comments will not fire another query,
since the comments have already been loaded in-advance. */
Types of Eager Loading
Rails offers a variety of ways to implement eager loading. You can make use of preload
, includes
, or eager_load
. Each of them serves the same purpose but works differently depending on the use case.
Preload
.preload
loads the associated records in separate queries from the main query. This method should be used when you want to avoid N+1 queries but don’t need to join the associated records in the same query.
Here’s an example:
1
2
3
4
5
6
7
8
posts = Post.preload(:comments).where(published: true)
This will generate the following separate queries:
/* One to load all posts. */
SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE
/* One to load comments for all those posts using their post_id. */
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 3..N)
If you need to filter or sort based on the associated records, it won’t be possible to do so with .preload
because the original model and the associations are loaded in separate queries.
Eager Load
.eager_load
forces Rails to perform a single query with LEFT OUTER JOIN to fetch both the main and associated records.
This method should be used when you want to ensure everything is loaded in a single query, especially if you plan to filter or sort based on the associated records.
Example:
1
2
3
4
5
6
posts = Post.eager_load(:comments).where("comments.created_at >= ?", 1.week.ago)
In this case, a single SQL query will be created as follows:
SELECT "posts".*, "comments".* FROM "posts"
LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id"
WHERE (comments.created_at >= '2024-09-26 00:00:00')
However, a single large query with joins can be slower for large datasets, especially if there are many associated records.
Includes
includes
is flexible— it can either perform eager loading by issuing multiple queries (like preload
) or a single query with a LEFT OUTER JOIN (like eager_load
), depending on the situation.
If you want to leave the choice to Rails, regarding whether a single query or separate queries are required to fetch the original model and the associations, then includes
is what you need!
Here’s an example:
When no conditions, filters, or sorting are applied to the associated records, includes
will behave like preload
.
1
2
3
4
5
6
posts = Post.includes(:comments).where(published: true)
# the following two queries will be generated:
SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1,2, ..N)
However, when you apply conditions, filters, or sorting on the associated records:
1
2
3
4
5
6
posts = Post.includes(:comments).where("comments.created_at >= ?", 1.week.ago)
# in this case, a single query will be generated:
SELECT "posts".*, "comments".* FROM "posts"
LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id"
WHERE (comments.created_at >= '2024-09-26 00:00:00')
Strict Loading
Now that lazy loading and eager loading are out of the way, we can tackle the simpler bits. You may have also heard of strict loading.
Is this another loading pattern in Rails? The answer is NO.
Simply put, strict loading is a Rails feature introduced in Rails 6 which ensures that a class makes use of eager loading, or that lazy loading is avoided.
The motivation behind this is to be able to easily identify situations where database queries can be optimized.
For example, if strict loading is enabled the following would result in an ActiveRecord::StrictLoadingViolationError
.
1
2
3
4
5
6
7
8
9
10
11
# Enable strict loading for posts
posts = Post.strict_loading.all
# Trying to access an association that hasn't been loaded
posts.each do |post|
# This will raise an ActiveRecord::StrictLoadingViolationError
post.comments.each do |comment|
puts "Comment: #{comment.body}"
end
end
With this feature active, it becomes easier for the developer to identify and optimize loading across models, resulting in:
1
2
3
4
5
6
7
8
9
10
11
# Properly eager load comments to avoid the error
posts = Post.strict_loading.includes(:comments).all
posts.each do |post|
puts "Post Title: #{post.title}"
# This will work since comments were loaded in-advance
post.comments.each do |comment|
puts "Comment: #{comment.body}"
end
end
Summary
- Use Lazy Loading when you are sure you won’t need associated records.
- Use Eager Loading when you need associated records upfront.
- Use
.preload
wen you want to avoid N+1 without filtering/sorting. - Use
.eager_load
when you need to filter/sort based on associated records. - Use
.includes
when you want Rails to choose the best method for loading.
- Use
Hope this blog was helpful to you! See you in the next one.