Joins Vs Left_joins In Rails: What's The Difference?
Learn the difference between Rails joins and left_joins, how each generates SQL, and how to avoid common query mistakes.
• 9 min read
• 9 min read
Learn the difference between Rails joins and left_joins, how each generates SQL, and how to avoid common query mistakes.
• 9 min read
• 9 min read
When you're querying data across associated models in Rails, you'll eventually need to combine data from multiple tables in a single query. Active Record gives you two closely related tools for this: joins and left_joins. They look similar; they're often used interchangeably by mistake, and that mistake can quietly return the wrong data.
This article breaks down exactly what SQL joins are, how joins and left_joins work under the hood, and how to choose the right one for your query.
Before touching Active Record, it helps to understand what a SQL join actually does.
A join combines rows from two or more tables based on a related column, usually a foreign key. If you have a posts table and a comments table, and each comment belongs to a post, a join lets you pull post data and comment data together in one query instead of running separate lookups.
There are several types of joins, but the two you'll use constantly in Rails are:
NULL.That distinction, matches only versus everything plus matches, is the entire difference between joins and left_joins in Rails.
joins WorksIn Rails, joins generates a SQL INNER JOIN. It's used when you want to filter or query based on data in an associated table, but you only care about records that actually have that association.
Consider two models:
class Author < ApplicationRecord
has_many :books
end
class Book < ApplicationRecord
belongs_to :author
end
To find all authors who have at least one book:
Author.joins(:books)
This generates SQL similar to:
SELECT authors.* FROM authors
INNER JOIN books ON books.author_id = authors.id
Because it's an INNER JOIN, any author with zero books is excluded entirely from the results. If an author has three books, that author will actually appear three times in the raw SQL result set, once per matching book row, unless you add .distinct.
joinsjoins becomes powerful when combined with where conditions on the joined table:
Author.joins(:books).where(books: { published: true })
This returns only authors who have at least one published book. Authors with no books, or authors whose books are all unpublished, are excluded.
You can also use table-qualified string conditions:
Author.joins(:books).where("books.published = ?", true)
Both approaches produce equivalent SQL. The hash syntax is generally preferred in modern Rails because it's less error-prone and easier to read.
left_joins Worksleft_joins (aliased as left_outer_joins) generates a SQL LEFT OUTER JOIN. It includes every record from the primary table, whether or not a matching associated record exists.
Using the same models:
Author.left_joins(:books)
This generates SQL similar to:
SELECT authors.* FROM authors
LEFT OUTER JOIN books ON books.author_id = authors.id
Every author is included in this result, even authors with no books at all. For authors without books, the columns from the books table simply come back as NULL in the underlying SQL, though Active Record won't load those non-existent book records as objects.
This is where joins and left_joins genuinely diverge in behavior, not just in the SQL keyword they produce.
# Excludes authors with no books
Author.joins(:books)
# Includes authors with no books
Author.left_joins(:books)
If your goal is a report that includes every author regardless of publishing history, joins will silently drop the ones with no books. That's a common source of subtly wrong data in dashboards and admin reports.
left_joins is the standard way to find records that lack an association. You combine it with a WHERE clause checking for NULL on the joined table's primary key:
Author.left_joins(:books).where(books: { id: nil })
This generates:
SELECT authors.* FROM authors
LEFT OUTER JOIN books ON books.author_id = authors.id
WHERE books.id IS NULL
Only rows where no matching book exists will have a NULL value for books.id, since the join didn't find a match. This pattern, left join plus a NULL check, is one of the most common ways to answer "which records don't have an associated X" in SQL generally, not just in Rails.
left_joins Into an INNER JOINThis is one of the most common bugs developers run into, and it's easy to miss because the code doesn't throw an error; it just returns the wrong records.
Author.left_joins(:books).where(books: { published: true })
At first glance, this looks like it should return every author, along with information about whether they have a published book. It does not. Because SQL evaluates WHERE conditions after the join, any author with no books at all has NULL for books.published, and NULL = true is never true in SQL. Those authors get filtered out.
The practical effect is that the LEFT OUTER JOIN in the generated SQL becomes functionally identical to an INNER JOIN once you filter on a non-null condition from the joined table.
If your intent is "include every author, but also tell me who has a published book," you need to move the condition into the join itself, or restructure the query. If your intent is genuinely "authors with at least one published book," you probably wanted joins in the first place, since left_joins with a strict condition like this doesn't buy you anything.
A more explicit way to express "no published books" is:
Author.left_joins(:books).where(books: { id: nil }).or(
Author.left_joins(:books).where.not(books: { published: true })
)
But in most real applications, it's clearer and more maintainable to write a dedicated scope that expresses the actual business rule rather than chaining conditional logic like this.
joins and left_joins With Multiple AssociationsBoth methods accept multiple associations, and you can nest them for associations of associations.
class Book < ApplicationRecord
belongs_to :author
has_many :reviews
end
To join across both books and reviews:
Author.joins(books: :reviews)
This produces two INNER JOINs, one from authors to books, and one from books to reviews. If any author has books but none of those books have reviews, that author is excluded.
The left_joins equivalent:
Author.left_joins(books: :reviews)
Here, every author appears, even those with no books, and every book appears, even those with no reviews. The join type applies consistently down the association chain.
You can also join multiple separate associations at once:
Author.joins(:books, :publisher)
This performs an INNER JOIN against both books and publisher in the same query.
joins and left_joins Are Not Substitutes for includesThis is a common point of confusion for developers newer to Active Record. joins and left_joins exist to build SQL join clauses so you can filter or sort based on associated tables. They do not load the associated records into memory as usable Ruby objects.
authors = Author.joins(:books)
authors.each do |author|
author.books.each { |book| puts book.title } # triggers a query per author
end
Even though the SQL joined the books table, accessing author.books on each result still fires a separate query, because joins doesn't populate the association cache. This is the classic setup for an N+1 query problem.
If you actually need to work with the associated records in Ruby, use includes, which either eager loads with a separate query or, when appropriate, generates a LEFT OUTER JOIN and populates the association cache in one pass:
Author.includes(:books).where(books: { published: true }).references(:books)
The rule of thumb: reach for joins or left_joins when you're filtering or ordering by an associated table, and reach for includes when you're going to actually read data off that association afterward. For a deeper look at when each strategy fits, see this guide on optimizing Active Record queries in Rails.
joinsUse joins when:
Example: "Find all authors who have published at least one book this year."
Author.joins(:books).where(books: { published_at: Date.current.beginning_of_year.. })
left_joinsUse left_joins when:
Example: "List every author, including those who haven't published anything yet."
Author.left_joins(:books).distinct
You don't have to guess what SQL your Active Record query produces. Call .to_sql on any relation to see the exact query:
puts Author.left_joins(:books).where(books: { id: nil }).to_sql
This prints the full SQL string without executing the query, which is useful for debugging joins that aren't returning what you expect.
You can also turn on query logging in the Rails console:
ActiveRecord::Base.logger = Logger.new(STDOUT)
Every query executed afterward prints to your console, including the exact JOIN clauses, WHERE conditions, and bind parameters Active Record used. This is often the fastest way to catch the "left join silently became an inner join" mistake described earlier.
Joins and left joins are generally efficient when the joined columns are indexed, particularly foreign key columns like author_id. Without proper indexing, joins across large tables can become slow regardless of which join type you use.
Whether joins or left_joins is "faster" depends heavily on your schema, indexes, and database engine. Neither method is inherently faster than the other in general. If you're seeing slow queries, look at your indexing strategy and query plans before assuming the join type itself is the bottleneck. If you're running Rails on SQLite, indexing and query planning behave a little differently than on PostgreSQL, which is covered in this guide to optimizing SQLite for Rails 8 production. And if you're still deciding which database fits your project, this comparison of SQLite and PostgreSQL for Rails walks through the tradeoffs.
joins generates an INNER JOIN and only returns records with a matching association.left_joins generates a LEFT OUTER JOIN and returns every record, matched or not.left_joins into an effective INNER JOIN.left_joins combined with a NULL check to find records missing an association.includes when you actually need to work with the associated data..to_sql when the results look off.Understanding this distinction will save you from a specific class of bug that doesn't crash your application; it just quietly returns fewer rows than you expected.