Rails 8.1.3

ActiveRecord Joins and Association Loading

A SQL join can find an author without loading their books into Ruby.

Illustrative example: Maya, author ID 1, has two books, Rails Basics and Ruby Notes, through ordinary has_many :books and belongs_to :author associations.

Joining returns author columns

The database matches Maya to her books, but SELECT authors.* returns only the columns Rails uses to create her Author object.

Read the books twice, then switch to includes and try again.

authors = Author.where(id: 1).joins(:books).distinct.to_a

DATABASE ROWS

Author: Maya (id: 1)
Rails Basics author_id: 1
Ruby Notes author_id: 1

RUBY OBJECTS

Author: Maya (id: 1)

Book objects: not loaded

Modeled SELECTs: 1maya.books.loaded?: false
SELECT DISTINCT authors.*
FROM authors
INNER JOIN books ON books.author_id = authors.id
WHERE authors.id = 1;

Maya is loaded; her books association is unloaded.

Ruby and SQL for joins
authors = Author.where(id: 1).joins(:books).distinct.to_a
# => [#<Author id: 1, name: "Maya">]

INNER JOIN needs a matching book. Maya has two matches, so distinct prevents duplicate author results. The join alone does not populate her books association. Sources.

Reading the titles fetches the books

Iterating over Maya's unloaded books fetches their rows, creates Book objects, and keeps them in her association for another read.

BEFORE ITERATION

maya.books.loaded? == false

No loaded Book objects

AFTER ITERATION

maya.books.loaded? == true
Book: Rails Basics
Book: Ruby Notes
CollectionProxy and the extra SELECT

maya.books returns a CollectionProxy that manages access to the association; the proxy can exist before the books are loaded. Sources.

maya = authors.first
maya.books.loaded?
# => false

maya.books.map(&:title).sort
# Additional SQL:
# SELECT books.* FROM books WHERE books.author_id = 1
# => ["Rails Basics", "Ruby Notes"]

maya.books.loaded?
# => true
maya.books.first.class
# => Book

maya.books.map(&:title).sort
# Same titles; no additional SELECT

Includes prepares the books in advance

For this query, includes(:books) fetches the author and books with separate SELECTs before you read the titles.

TWO SELECTS

SELECT authors.*
SELECT books.*

RUBY OBJECTS READY

Author: Maya
Book: Rails Basics
Book: Ruby Notes
Ruby, SQL, and the left join qualification
authors = Author.where(id: 1).includes(:books).to_a
# SELECT authors.* FROM authors WHERE authors.id = 1
# SELECT books.* FROM books WHERE books.author_id IN (1)

maya = authors.first
maya.books.loaded?
# => true
maya.books.map(&:title).sort
# => ["Rails Basics", "Ruby Notes"]
# No additional SELECT for this iteration

includes does not mean left join: conditions that reference the books table can make it use a left outer join. The reference covers those conditions.

Choose for the screen you are building

When a screen displays book titles for many authors, includes(:books) batches the book loading instead of fetching each author's unloaded collection in the loop.

NAMES OF AUTHORS WITH BOOKS

Author.joins(:books).distinct

The books determine which authors qualify.

AUTHORS AND THEIR BOOK TITLES

Author.includes(:books)

Book objects are ready for iteration.

Example limits

This playground models one author and two books. It does not execute Ruby or SQL. The code and SQL are illustrative, not measured query logs; adapter formatting varies.

It excludes custom scopes, automatic eager loading, strict loading, query caching, and joins combined with preloading. It models iteration with map, not calculations such as count. Counting and strict loading have different rules.