Question
Rails includes vs joins: Understanding Eager Loading and SQL Joins in ActiveRecord
Question
In Rails, I often read that if I know I will use associated records, I should use :include to avoid N+1 queries, and that it will perform a join.
For example:
Post.all(:include => :comments)
However, when I inspect the logs, Rails does not issue a single SQL join. Instead, it runs two queries:
SELECT * FROM "posts";
SELECT "comments".* FROM "comments"
WHERE ("comments".post_id IN (1,2,3,4))
ORDER BY created_at ASC;
So Rails is loading all comments in one additional query, which does avoid many extra queries, but it is still not a SQL JOIN.
If I explicitly use :joins instead:
Post.all(:joins => :comments)
then Rails generates a real join:
SELECT "posts".* FROM "posts"
INNER JOIN "comments" ON "posts".id = "comments".post_id;
What is the actual difference between :include and :joins in Rails?
Why does :include sometimes use separate queries instead of a join, even though many explanations say it performs a join?
Is Rails intentionally choosing between eager loading with multiple queries and a joined query based on performance or query behavior?
Short Answer
By the end of this page, you will understand how Rails includes and joins serve different purposes. You will learn that includes is mainly for eager loading associations to prevent N+1 queries, while joins is mainly for combining tables in SQL for filtering or matching rows. You will also see why includes often uses separate queries, when it may switch to a join-like query, and how to choose the right tool in real applications.
Concept
includes and joins are related, but they solve different problems.
includes: load associated records efficiently
The main job of includes is eager loading. That means Rails loads the parent records and their associated records ahead of time so that later access does not trigger one query per record.
Without eager loading:
posts = Post.all
posts.each do |post|
puts post.comments.size
end
This can cause an N+1 query problem:
- 1 query to load posts
- 1 query per post to load comments
With eager loading:
posts = Post.includes(:comments)
Rails can load:
- posts in one query
- all comments for those posts in one additional query
That is still eager loading, even though it is not a single SQL join.
joins: combine tables in SQL
The main job of joins is to tell the database to combine rows from multiple tables in one SQL statement.
Example:
Post.joins(:comments)
This produces an SQL INNER JOIN, which is useful when you want to:
- filter posts based on comments
- sort by comment columns
- require matching associated rows
But joins does not automatically load the associated records into memory in the same way includes does.
Why includes often uses two queries
Rails often chooses separate queries for eager loading because it can be more efficient and less error-prone than one large joined result set.
A join duplicates parent row data for every matching child row.
If one post has 10 comments, a join may return that same post row 10 times. Rails then has to rebuild objects from duplicated data.
Using two queries avoids this duplication:
- Load all posts once
- Load all comments whose
post_idis in the set of loaded post IDs
This is often a better eager loading strategy.
When includes may behave more like a join
If your query references the included table in conditions or ordering, Rails may need a join-like strategy.
For example:
Post.includes(:comments).where(comments: { approved: true })
In such cases, Rails may generate a LEFT OUTER JOIN because the associated table is needed by the SQL query itself.
So the key idea is:
includesis about avoiding extra queries when reading associationsjoinsis about building SQL across tables
Sometimes includes uses joins internally, but that is a side effect of what the query needs, not its main purpose.
Mental Model
Think of includes and joins as two different ways of preparing for a meeting.
includes is like sending two organized lists
Imagine you need a list of posts and their comments.
Instead of writing one giant document where each post is repeated for every comment, you send:
- one list of posts
- one list of comments grouped by post ID
That is what Rails often does with includes.
It is tidy and avoids repeating the same post data over and over.
joins is like merging everything into one spreadsheet
Now imagine you need one table where each row combines post and comment data together.
That is what a SQL join does.
It is useful for searching, filtering, and sorting across both tables, but it can repeat post data many times.
Easy rule to remember
- Use
includeswhen you plan to access associated objects - Use
joinswhen you need to query through associated tables
Syntax and Examples
Basic syntax
Eager load associations
Post.includes(:comments)
Join associated tables
Post.joins(:comments)
Eager load multiple associations
Post.includes(:comments, :author, :category)
Join nested associations
Post.joins(comments: :user)
Example: includes
posts = Post.includes(:comments)
posts.each do |post|
puts "#{post.title}: #{post.comments.count} comments"
end
What happens
Rails typically does:
SELECT * FROM posts;
SELECT * FROM comments WHERE post_id IN (...);
This prevents one query per post when post.comments is accessed.
Example: joins
posts = Post.joins(:comments).where(comments: { approved: true })
What happens
Rails generates SQL that joins posts and comments, then filters rows where comments are approved.
This is good for selecting posts based on comment data.
Example: includes with conditions on the association
posts = Post.includes(:comments).where(comments: { approved: true })
When the query needs comments inside WHERE, Rails may use a join strategy, often a LEFT OUTER JOIN.
Important note
joins does not mean comments are fully preloaded for later use.
posts = Post.joins(:comments)
posts.first.comments
Accessing comments later can still trigger extra queries unless you also eager load them.
Step by Step Execution
Consider this code:
posts = Post.includes(:comments)
posts.each do |post|
puts post.comments.map(&:body)
end
Step 1: Load posts
Rails first loads all posts:
SELECT * FROM posts;
Suppose it gets these IDs:
- 1
- 2
- 3
Step 2: Load comments for all loaded posts
Rails then loads all related comments in one query:
SELECT * FROM comments WHERE post_id IN (1, 2, 3);
Step 3: Match comments to posts in memory
Rails groups comments by post_id.
- comments with
post_id = 1belong to post 1 - comments with
post_id = 2belong to post 2 - comments with
post_id = 3belong to post 3
Step 4: Loop through posts
When the loop calls post.comments, Rails already has those comments in memory.
So no extra query is needed inside the loop.
Compare with no includes
posts = Post.all
posts.each do |post|
puts post.comments.map(&:body)
end
This typically becomes:
- Query all posts
- Query comments for post 1
- Query comments for post 2
- Query comments for post 3
That is the N+1 problem.
Real World Use Cases
1. Rendering pages with associated data
If a page shows posts, authors, and comments, includes helps load everything needed for display without many repeated queries.
Post.includes(:author, :comments)
2. Filtering records by association data
If you want posts that have approved comments, joins is a natural fit.
Post.joins(:comments).where(comments: { approved: true })
3. Admin dashboards
Dashboards often list objects plus related counts or details. includes helps avoid query explosions when rendering tables.
4. Reporting and search
Search screens often need SQL conditions across related tables. joins is useful for combining tables in one query.
5. API endpoints
When serializing nested data in JSON, includes can reduce database round trips.
Post.includes(:comments, :author)
Real Codebase Usage
In real Rails applications, developers often combine these tools carefully.
Common pattern: eager load for views and serializers
@posts = Post.includes(:author, :comments).recent
This is common in controllers, API endpoints, and background jobs that read associations.
Common pattern: join for filtering, includes for later access
@posts = Post.joins(:comments)
.where(comments: { approved: true })
.includes(:comments)
.distinct
Why this pattern is useful:
joinsfilters posts using the comments tableincludespreloads comments for later usedistinctavoids duplicate posts caused by joins
Guarding against N+1 in views
A team may notice this in a template:
@posts.each do |post|
post.comments.each do |comment|
# render comment
end
end
They will often add includes(:comments) to the query feeding that view.
Validation and reporting queries
Developers may use joins for data checks:
Post.joins(:comments).where(comments: { spam: true })
Performance tuning
Real codebases usually do not assume one query is always better. Developers inspect:
- SQL logs
- duplicate rows
- memory usage
- query plans
- page rendering behavior
The best choice depends on whether the goal is filtering, loading associations, or both.
Common Mistakes
Mistake 1: Assuming includes always means one SQL join
Broken expectation:
Post.includes(:comments)
Many beginners expect a single JOIN query every time. But includes often uses separate queries for eager loading.
How to avoid it
Remember:
includesmeans preload associations- it does not guarantee one joined SQL statement
Mistake 2: Using joins and expecting associations to be preloaded
Broken code:
posts = Post.joins(:comments)
posts.each do |post|
puts post.comments.size
end
Problem:
joinshelps build the SQL query- later calls to
post.commentsmay still trigger extra queries
How to avoid it
If you need both filtering and preloading, combine them:
posts = Post.joins(:comments).includes(:comments).distinct
Mistake 3: Forgetting duplicate parent records with joins
Broken code:
Post.joins(:comments)
If a post has many comments, that post may appear multiple times in the SQL result.
How to avoid it
Use distinct when needed:
Post.joins(:comments).distinct
Mistake 4: Choosing joins for display-only data
If you only want to render associated records, a join may produce unnecessary duplication.
Better approach
Post.includes(:comments)
Mistake 5: Mixing conditions without understanding query behavior
Example:
Post.includes(:comments).where(comments: { approved: true })
This can change how Rails builds SQL because the associated table is now part of the query conditions.
How to avoid it
Be aware that conditions on associated tables can force join-like behavior.
Comparisons
| Feature | includes | joins |
|---|---|---|
| Main purpose | Eager load associations | Combine tables in SQL |
| Prevents N+1 when reading associations | Yes | Not by itself |
| Usually loads associated objects for later access | Yes | No |
| May run multiple queries | Yes | Usually one query |
| Useful for filtering by associated table | Sometimes, but may need join behavior | Yes |
| Can duplicate parent rows | Usually no | Yes, often |
| Typical SQL join type | May use separate queries or LEFT OUTER JOIN | Usually INNER JOIN |
Quick rule
- Choose
includesfor loading data you know you will read - Choose
joinsfor querying across associations - Use both when you need filtering and eager loading
Cheat Sheet
# Eager load association
Post.includes(:comments)
# Join association
Post.joins(:comments)
# Filter by associated table
Post.joins(:comments).where(comments: { approved: true })
# Filter and also preload
Post.joins(:comments).where(comments: { approved: true }).includes(:comments).distinct
Key rules
includesis for eager loadingjoinsis for SQL joinsincludesoften uses 2 queries, not 1 joinjoinsdoes not automatically preload associations- joins can create duplicate parent rows
- use
distinctwhen needed with joins - conditions on included tables can make Rails use join-like SQL
Watch for
- N+1 queries in loops
- duplicate rows from
joins - assuming eager loading and SQL joining are the same thing
FAQ
Does includes always generate a SQL join in Rails?
No. includes often uses separate queries: one for the parent records and one for the associated records.
Why does Rails use two queries for includes?
Because eager loading with separate queries often avoids duplicated parent rows and can be more efficient for loading associations.
When does includes use a join?
When the query needs the associated table for conditions, ordering, or references, Rails may switch to a join-like strategy.
What is the main difference between includes and joins?
includes is for eager loading associations. joins is for combining tables in SQL.
Can joins cause N+1 queries?
Yes. If you use joins and later access the association objects, Rails may still issue additional queries.
Why do joined queries sometimes look slower?
Joined queries may return more duplicated data and require more work from the database. One query is not always faster than two smaller ones.
Should I always prefer includes over joins?
No. Use includes when reading associations, and joins when filtering or querying across related tables.
Mini Project
Description
Build a small Rails query example that lists posts with their comments and also filters posts that have approved comments. This project demonstrates when to use includes, when to use joins, and when to combine both in a realistic controller-style query.
Goal
Create queries that avoid N+1 problems, filter by associated records correctly, and prevent duplicate parent rows.
Requirements
- Define a
Postmodel with manyCommentrecords. - Write one query that loads posts and comments for display without N+1 queries.
- Write one query that returns only posts with approved comments.
- Write one query that both filters by approved comments and preloads comments for later rendering.
- Ensure duplicate posts are removed when using joins.
Keep learning
Related questions
Add a Default Value to an Existing Column in a Rails Migration
Learn how to add or change a default value on an existing database column using Rails Active Record migrations safely.
Calling a Class Method from an Instance in Ruby
Learn how to call a class method from an instance in Ruby using self.class, with examples, pitfalls, and practical usage patterns.
Calling an Overridden Monkey-Patched Method in Ruby
Learn how to call the original method when monkey patching in Ruby, including alias_method patterns, examples, pitfalls, and practical usage.