In this post
The main difference is that when using includes the eager loading is used. Eager loading allows you to optimize data retrieval by avoiding additional queries to the database.
Let’s see how it works with an example:
Joins:

We have joined tables, but we don’t get any data from the users table. Therefore, each time the user record is accessed, and there is an additional query to the database.
Includes:

As you can see, we don’t have any additional queries because we use eager loading and we get the data about the user from memory instead of querying the database.
Look closely at the second screenshot: includes didn’t join anything. It ran two queries, one for the posts and one for all their users at once (WHERE "users"."id" IN (...)). That is the default. Rails turns includes into a single JOIN query when your conditions point at the associated table, or when you combine it with joins (see the end of this post).
joins for filtering #
joins is for filtering or sorting by the other table when you don’t need its records afterwards:
Post.joins(:user).where(users: { name: "Ann" }).pluck(:title)
# SELECT "posts"."title" FROM "posts"
# INNER JOIN "users" ON "users"."id" = "posts"."user_id"
# WHERE "users"."name" = ?
One query, and no user objects are built. It is an INNER JOIN, so posts without a matching user are left out. The Rails guide recommends joins for exactly this kind of conditional query, even though includes can also take conditions.
Where joins and includes bite #
joins on a has_many repeats the parent. Join users to posts and you get one row per post, so a user with two posts comes back twice:
User.joins(:posts).map(&:name) # => ["Ann", "Ann", "Bob"]
User.joins(:posts).distinct.map(&:name) # => ["Ann", "Bob"]
includes with a SQL string needs references. A hash condition tells Rails which table you mean. A string doesn’t, so Rails keeps the two separate queries and the database can’t find the column:
Post.includes(:user).where("users.name = ?", "Ann").to_a
# => ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: users.name
Post.includes(:user).where("users.name = ?", "Ann").references(:users).to_a
# one query with LEFT OUTER JOIN "users"
Join and load in one query #
You can chain both. joins turns the query into an INNER JOIN, so posts without a user drop out, and includes loads the users from that same query:
Post.joins(:user).includes(:user).each { |post| post.user.name }
# SELECT "posts"."id" AS t0_r0, ... "users"."name" AS t1_r1
# FROM "posts" INNER JOIN "users" ON "users"."id" = "posts"."user_id"
Every snippet here runs on SQLite with Rails 8.1, and the SQL shown is copied from the log. The rule of thumb: joins when you only filter by the association, includes when you are going to read it.




