Question

Rails includes vs joins: Understanding Eager Loading and SQL Joins in ActiveRecord

rubyruby-on-railsdatabasejoinrails-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:

  1. Load all posts once
  2. Load all comments whose post_id is 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:

  • includes is about avoiding extra queries when reading associations
  • joins is 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 includes when you plan to access associated objects
  • Use joins when you need to query through associated tables
Take Quiz

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 = 1 belong to post 1
  • comments with post_id = 2 belong to post 2
  • comments with post_id = 3 belong 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:

  1. Query all posts
  2. Query comments for post 1
  3. Query comments for post 2
  4. 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)
Take Quiz

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:

  • joins filters posts using the comments table
  • includes preloads comments for later use
  • distinct avoids 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:

  • includes means 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:

  • joins helps build the SQL query
  • later calls to post.comments may 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

Featureincludesjoins
Main purposeEager load associationsCombine tables in SQL
Prevents N+1 when reading associationsYesNot by itself
Usually loads associated objects for later accessYesNo
May run multiple queriesYesUsually one query
Useful for filtering by associated tableSometimes, but may need join behaviorYes
Can duplicate parent rowsUsually noYes, often
Typical SQL join typeMay use separate queries or LEFT OUTER JOINUsually INNER JOIN

Quick rule

  • Choose includes for loading data you know you will read
  • Choose joins for querying across associations
  • Use both when you need filtering and eager loading
Take Quiz

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

  • includes is for eager loading
  • joins is for SQL joins
  • includes often uses 2 queries, not 1 join
  • joins does not automatically preload associations
  • joins can create duplicate parent rows
  • use distinct when 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.

Take Quiz

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

  1. Define a Post model with many Comment records.
  2. Write one query that loads posts and comments for display without N+1 queries.
  3. Write one query that returns only posts with approved comments.
  4. Write one query that both filters by approved comments and preloads comments for later rendering.
  5. Ensure duplicate posts are removed when using joins.
Take Quiz

Keep learning

Related questions

View more