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)RUBY OBJECTS
Author: Maya (id: 1)Book objects: not loaded
maya.books.loaded?: falseSELECT 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? == falseNo loaded Book objects
AFTER ITERATION
maya.books.loaded? == trueCollectionProxy 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: MayaRuby, 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).distinctThe 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.