Hey everyone
,
My lead dev @jplot and I ran into something that surprised us while profiling N+1s in a production app. We have a pretty classic grandparent pattern — Account → Report → Issue — and we noticed that issue.account (defined as has_one :account, through: :report) always hits the database, even when the entire object graph is already loaded via includes.
The weird part: issue.report.account (manual chaining) works perfectly fine — zero queries. Same for delegate :account, to: :report. It’s only the has_one :through path that ignores what’s already in memory.
We dug into it a bit and we’re fairly confident this is a bug (or at least a missing optimization) in how HasOneThroughAssociation resolves. We’ve tested on Rails 7.1 and 8.1.2, same behavior on both. Adding inverse_of everywhere doesn’t help either.
Reproduction
Here’s a self-contained script you can copy-paste and run directly:
# frozen_string_literal: true
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
gem "rails", "~> 8.0"
gem "sqlite3"
end
require "active_record"
require "minitest/autorun"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
$queries = []
ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
$queries << payload[:sql] unless payload[:name] == "SCHEMA"
end
ActiveRecord::Schema.define do
create_table :accounts, force: true
create_table :reports, force: true do |t|
t.integer :account_id
end
create_table :issues, force: true do |t|
t.integer :report_id
end
end
class Account < ActiveRecord::Base
has_many :reports, inverse_of: :account
end
class Report < ActiveRecord::Base
belongs_to :account, inverse_of: :reports
has_many :issues, inverse_of: :report
end
class Issue < ActiveRecord::Base
belongs_to :report, inverse_of: :issues
has_one :account, through: :report
delegate :account, to: :report, prefix: :delegated
end
class HasOneThroughTest < Minitest::Test
def setup
account = Account.create!
report = Report.create!(account: account)
Issue.create!(report: report)
end
# FAILS — has_one :through fires a query even though everything is loaded
def test_has_one_through_reuses_loaded_associations
accounts = Account.includes(reports: :issues).all.to_a
issue = accounts.first.reports.first.issues.first
# Both intermediate and target are already loaded:
assert issue.association(:report).loaded?, "report should be loaded"
assert issue.report.association(:account).loaded?, "report.account should be loaded"
$queries.clear
issue.account # fires SELECT despite everything being in memory!
assert_equal 0, $queries.length,
"has_one :through should reuse the loaded association, got: #{$queries.inspect}"
end
# PASSES — delegate just walks the Ruby objects
def test_delegate_reuses_loaded_associations
accounts = Account.includes(reports: :issues).all.to_a
issue = accounts.first.reports.first.issues.first
$queries.clear
issue.delegated_account
assert_equal 0, $queries.length
end
# PASSES — manual chaining works fine too
def test_manual_chaining_reuses_loaded_associations
accounts = Account.includes(reports: :issues).all.to_a
issue = accounts.first.reports.first.issues.first
$queries.clear
issue.report.account
assert_equal 0, $queries.length
end
end
What happens
| How you access it | Queries fired |
|---|---|
issue.account (has_one :through) |
1 — N+1! |
issue.delegated_account (delegate) |
0 |
issue.report.account (manual chain) |
0 |
So report is loaded, report.account is loaded, but has_one :through goes straight to the DB anyway:
SELECT "accounts".* FROM "accounts"
INNER JOIN "reports" ON "accounts"."id" = "reports"."account_id"
WHERE "reports"."id" = ? LIMIT ?
What I think is going on
It looks like HasOneThroughAssociation builds a scope and fires the query without ever checking if the intermediate association is already loaded and the target is reachable through it. The inverse_of mechanism — which works great for direct belongs_to/has_one — doesn’t kick in here.
Why I think this matters
This “grandparent shortcut” pattern is really common in deep hierarchies. You add has_one :through because it feels like the idiomatic Rails way to express the relationship — and you’d expect it to behave like issue.report.account under the hood. But instead it silently introduces N+1s that no amount of includes can fix.
The workaround is delegate, which works… but then you lose the ability to use that association in scopes, where clauses, joins, etc. So you end up choosing between query efficiency and query composability, which feels like a false tradeoff.
What we’d expect
When has_one :through is accessed and both the intermediate (report) and the target (report.account) are already loaded in memory, ActiveRecord should just return the in-memory object — the same way direct associations do with inverse_of.
Environment
- Reproduced on Rails 7.1.0 and 8.1.2
- Ruby 3.2+
- PostgreSQL (also reproduced with SQLite — behavior is adapter-independent)
Curious if others have run into this too! ![]()