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.
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:
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
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, )
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|
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:
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 |
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 ?
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
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.
Convert a Unix Timestamp to Ruby DateTime
Learn how to convert Unix timestamps to Ruby DateTime and Time objects, with examples, differences, pitfalls, and practical Ruby usage.