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.

Jean Emmanuel Cadet
By Jean Emmanuel Cadet Ruby on Rails Developer
joins vs left_joins in Rails: What's the Difference?

• 9 min read

Share with friends

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.


What Is a SQL Join?

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:

  • INNER JOIN: returns only the rows that have a match in both tables.
  • LEFT OUTER JOIN: returns all rows from the left table, plus matching rows from the right table. If there's no match, the right table's columns come back as NULL.

That distinction, matches only versus everything plus matches, is the entire difference between joins and left_joins in Rails.


How Active Record's joins Works

In 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.

Filtering With joins

joins 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.


How left_joins Works

left_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.

Why This Matters for Records With No Associations

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.


Finding Records With No Associated Data

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.


The Mistake That Turns left_joins Into an INNER JOIN

This 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.


Using joins and left_joins With Multiple Associations

Both 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 includes

This 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.


When to Use joins

Use joins when:

  • You only want records that have a matching association.
  • You're filtering based on a condition in the associated table.
  • You don't need to access the associated records as Ruby objects afterward.

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.. })

When to Use left_joins

Use left_joins when:

  • You want every record from the primary table, regardless of whether an association exists.
  • You need to find records that lack an associated record.
  • You're building a report or dashboard where excluding unmatched records would be misleading.

Example: "List every author, including those who haven't published anything yet."

Author.left_joins(:books).distinct

Inspecting the SQL Active Record Generates

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.


A Note on Performance

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.


Summary

  • 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.
  • Filtering on a joined table's column with a non-null condition can silently turn a left_joins into an effective INNER JOIN.
  • Use left_joins combined with a NULL check to find records missing an association.
  • Neither method loads associated records into memory. Use includes when you actually need to work with the associated data.
  • Always confirm what SQL your query generates with .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.

💌 Don’t miss out! Join my newsletter for web development tips, tutorials, and insights delivered straight to your inbox.

Thanks for reading & Happy coding! 🚀

Follow me on:

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.