ApiaryActive
Try: pause · settings · learn · wipe
← Community / Reading Room
RA
craft · 14 min read

Rails ActiveRecord Associations

Rails ActiveRecord is the heart of any Rails application. It turns database tables into Ruby objects, lets developers think in terms of entities, and hides…

Rails ActiveRecord is the heart of any Rails application. It turns database tables into Ruby objects, lets developers think in terms of entities, and hides the boilerplate of SQL behind a clean, expressive API. Yet, the power of ActiveRecord lies not only in the CRUD methods it gives you, but in the way it models relationships between objects—through has_many, belongs_to, has_one, has_many :through, and polymorphic associations. When those relationships are misused or left unchecked, they can become the source of performance nightmares: the dreaded N+1 queries, slow joins, and bloated memory usage.

In this pillar article we dive deep into eager loading and N+1 query mitigation. We’ll walk through the mechanics of how Rails generates SQL, how to spot the N+1 problem in your logs, and how to apply the right eager‑loading strategy to keep your application fast and responsive. Along the way, we’ll tie these concepts to the world of bee conservation and self‑governing AI agents—showing that whether you’re modeling a honeybee colony or an autonomous swarm of drones, the same relational patterns apply, and the same performance tricks can save you time, money, and resources.


1. Understanding Rails Associations

Before we can talk about optimization, we need to understand the building blocks that create the relationships you’ll be eager‑loading.

1.1 The Basic Association Types

AssociationDirectionTypical Usage
belongs_toMany → OneA Pet belongs to an Owner.
has_oneOne → OneA User has one Profile.
has_manyOne → ManyA Category has many Products.
has_many :throughMany → Many (via join)A Student has many Courses through Enrollments.
has_and_belongs_to_manyMany → Many (direct join)A Tag has and belongs to many Articles.
PolymorphicOne → Many (dynamic)A Comment can belong to either a Post or a Photo.

These associations are defined in your models, and Rails automatically generates helper methods, scopes, and foreign key constraints (if you use migrations).

1.2 The Role of Foreign Keys and Indexes

Every belongs_to or has_many association relies on a foreign key column (e.g., owner_id in the pets table). Without proper indexing, lookups on that column become linear scans, and queries that join tables will be expensive. In a typical bee‑conservation database, you might have:

  • hives (id, location, colony_id, ... )
  • bees (id, hive_id, role, age, ... )
  • pollinations (id, bee_id, flower_id, timestamp, ... )

Each of these foreign keys (hive_id, bee_id, flower_id) should have an index. Rails migrations make this trivial:

add_index :bees, :hive_id
add_index :pollinations, :bee_id

A missing index can turn a sub‑second query into a minutes‑long operation when the tables grow to thousands or millions of rows.


2. The N+1 Query Problem

2.1 What is N+1?

The N+1 problem occurs when a single query is followed by N additional queries to fetch associated records. Consider this scenario:

# Fetch all hives
hives = Hive.all

# Render each hive's bees
hives.each do |hive|
  puts hive.bees.count
end

Rails will issue:

  1. SELECT * FROM hives; (1 query)
  2. For each hive, SELECT * FROM bees WHERE hive_id = ?; (N queries)

If you have 500 hives, you’ll run 501 queries—clearly a performance issue.

2.2 Real‑world Impact

  • Latency: Each round‑trip to the database adds network latency. In a production environment with a high‑latency connection, N+1 can double or triple page load times.
  • Database Load: Each query consumes CPU and memory on the database server. For a busy API, this can lead to connection exhaustion and timeouts.
  • Cost: On cloud platforms that bill per query or per GB processed (e.g., AWS RDS, Google Cloud SQL), N+1 queries inflate operational costs.

In a bee‑conservation platform, imagine a dashboard that shows the number of bees per hive. If you have 200 hives, the N+1 problem would generate 200 extra queries, potentially slowing down the entire page for researchers trying to monitor colony health.

2.3 Spotting N+1 in Logs

Rails logs each SQL statement. A typical N+1 pattern looks like:

Started GET "/hives" for 127.0.0.1 at 2026-09-27 10:00:00
Processing by HivesController#index as HTML
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1  [["id", 1]]
  Hive Load (0.3ms)  SELECT  "hives".* FROM "hives" WHERE "hives"."user_id" = ?  [["user_id", 1]]
  Bee Load (1.0ms)  SELECT  "bees".* FROM "bees" WHERE "bees"."hive_id" = ?  [["hive_id", 1]]
  Bee Load (1.1ms)  SELECT  "bees".* FROM "bees" WHERE "bees"."hive_id" = ?  [["hive_id", 2]]
  ...
Completed 200 OK in 200ms (Views: 150.0ms | ActiveRecord: 50.0ms)

Notice the repeated Bee Load queries. A quick grep for the association name often reveals the culprit.


3. Eager Loading Fundamentals

Eager loading is the mechanism Rails provides to fetch related records in as few queries as possible. Instead of the default lazy loading that triggers one query per association, eager loading pre‑loads all needed records in a single (or a few) queries.

3.1 includes

The most common eager‑loading method:

hives = Hive.includes(:bees).where(status: 'active')

Rails will generate:

  • SELECT * FROM hives WHERE status = 'active';
  • SELECT * FROM bees WHERE hive_id IN (?,?,?); (one query for all hives)

If you then call hive.bees for each hive, Rails will use the already‑loaded collection, avoiding additional queries.

3.2 preload vs eager_load

  • preload always uses separate queries for the primary and association tables.
  • eager_load uses a single LEFT OUTER JOIN to fetch both in one query.

When to use which?

ScenarioPreferred Method
You need to filter or order by columns on the associationeager_load (joins)
You only need to iterate over the associationpreload (separate queries)
You want to avoid GROUP BY or DISTINCT pitfallspreload

3.3 references

When you use includes but also add a where or order clause that references the included table, Rails automatically switches to eager_load to satisfy the SQL. If you want to force eager loading, you can call includes(:bees).references(:bees).

hives = Hive.includes(:bees).where('bees.role = ?', 'worker')

Rails will detect the need for a join and generate a single query with LEFT OUTER JOIN bees.


4. Practical Eager Loading Techniques

Let’s walk through concrete examples and best‑practice patterns.

4.1 Simple includes

# List all hives with the number of worker bees
hives = Hive.includes(:bees).where(status: 'active')
hives.each do |hive|
  puts "#{hive.name} has #{hive.bees.where(role: 'worker').count} workers"
end

Here, Hive.includes(:bees) loads all bees for all hives in one query. The subsequent where on role is applied in Ruby, not SQL, because we’re filtering after the eager load.

4.2 preload with select

If you only need a subset of columns, you can reduce data transfer:

hives = Hive.preload(:bees).select(:id, :name).includes(:bees).select('bees.id AS bee_id, bees.role')

Be careful: using select on both models can lead to ambiguous column names. Alias columns to avoid conflicts.

4.3 eager_load for Ordering

Suppose you want hives ordered by the number of bees:

hives = Hive
  .eager_load(:bees)
  .group('hives.id')
  .order('COUNT(bees.id) DESC')

This generates a single SQL statement with a LEFT OUTER JOIN, GROUP BY, and ORDER BY, eliminating N+1 while providing the desired ordering.

4.4 joins vs includes

joins performs an inner join, discarding hives with no bees. Use it when you need to filter on the association and can afford to drop records:

Hive.joins(:bees).where(bees: { role: 'queen' })

If you need to keep all hives, use left_outer_joins (Rails 5+):

Hive.left_outer_joins(:bees).where(bees: { role: 'queen' })

4.5 has_many :through Eager Loading

class Student < ApplicationRecord
  has_many :enrollments
  has_many :courses, through: :enrollments
end

students = Student.includes(courses: :professor).where(courses: { active: true })

Rails will generate three queries: one for students, one for enrollments, and one for courses+professors.

4.6 Polymorphic Associations

class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
end

comments = Comment.includes(:commentable).where(commentable_type: 'Post')

Rails will automatically join the appropriate table based on commentable_type. Eager loading polymorphic associations can be tricky; always inspect the generated SQL to ensure the correct joins.


5. Advanced Query Optimizations

5.1 Counter Cache

A counter cache stores the number of associated records in a column on the parent table. This eliminates the need for a COUNT query each time.

class Bee < ApplicationRecord
  belongs_to :hive, counter_cache: true
end

Rails will create a bees_count column on hives. Now hive.bees_count is a simple integer fetch, no SQL needed.

Benefits:

  • O(1) lookup for counts.
  • Removes N+1 when displaying counts on dashboards.

Caveats:

  • Requires manual migration to add the counter column.
  • Must handle bulk inserts/deletes carefully to keep the counter accurate.

5.2 pluck and select

When you only need scalar values, pluck bypasses ActiveRecord objects:

bee_ids = Bee.where(hive_id: hive_ids).pluck(:id)

This returns an array of integers, no object allocation, and uses a lightweight SELECT id FROM bees WHERE hive_id IN (...).

5.3 distinct and group

If you need unique values or aggregated data, use distinct or group in combination with eager loading:

Hive
  .includes(:bees)
  .select('hives.*, COUNT(DISTINCT bees.id) AS bees_count')
  .group('hives.id')

This is the same as the ordering example but returns the count directly.

5.4 where with Subqueries

Sometimes you need a subquery to filter parent records based on complex conditions on the child:

Hive.where(id: Bee.where(role: 'queen').select(:hive_id))

Rails will generate:

SELECT * FROM hives WHERE id IN (SELECT hive_id FROM bees WHERE role = 'queen');

This is efficient because the database handles the subquery internally.

5.5 Using select to Reduce Payload

When you know you only need a handful of columns, you can explicitly select them:

hives = Hive
  .select('hives.id, hives.name')
  .includes(:bees)
  .select('bees.id AS bee_id, bees.role')

This reduces the amount of data transferred, especially important when dealing with large bee datasets.


6. Counter Cache and Dependent Options

6.1 Counter Cache Revisited

When you add a counter cache, you must also consider dependent: :destroy or dependent: :delete_all on the child side to keep counts accurate.

class Hive < ApplicationRecord
  has_many :bees, dependent: :destroy
end

Rails automatically decrements the counter when a bee is destroyed.

6.2 dependent: :nullify

If you want to preserve the child records but remove the association:

class Hive < ApplicationRecord
  has_many :bees, dependent: :nullify
end

This sets hive_id to NULL on associated bees.

6.3 dependent: :restrict_with_error

To prevent deletion of a parent if it has children:

class Hive < ApplicationRecord
  has_many :bees, dependent: :restrict_with_error
end

Rails will add a validation error if you try to delete a hive that still has bees.

6.4 optional: true

For belongs_to associations that can be absent:

class Bee < ApplicationRecord
  belongs_to :hive, optional: true
end

This allows bees to exist without a hive, which can be useful during migrations or when a bee is temporarily unassigned.


7. Polymorphic Associations & Performance

Polymorphic associations let a single model belong to multiple other models. They’re handy when modeling shared behavior (e.g., Comment belongs to Post or Photo). However, they can become a performance pitfall if not handled carefully.

7.1 Indexing Polymorphic Columns

A polymorphic association uses two columns: <association>_id and <association>_type. Index both:

add_index :comments, [:commentable_type, :commentable_id]

This composite index speeds up lookups for a specific type and id.

7.2 Eager Loading Polymorphic Associations

When eager‑loading a polymorphic association, Rails will perform separate queries per type. For example:

Comment.includes(:commentable).all

If you have 1000 comments on posts and 500 on photos, Rails will issue:

  • One query for all comments.
  • One query for all posts (SELECT * FROM posts WHERE id IN (...)).
  • One query for all photos (SELECT * FROM photos WHERE id IN (...)).

This is efficient, but if you have many types, the number of queries grows. In extreme cases, you might need to refactor into separate associations.

7.3 Avoiding N+1 with Polymorphic

If you’re displaying comments on a page that lists posts and photos, use includes on each parent type:

posts = Post.includes(:comments).where(id: post_ids)
photos = Photo.includes(:comments).where(id: photo_ids)

Rails will batch the comment loads separately per type, preventing N+1.


8. Joins, References, and Subqueries

8.1 left_outer_joins vs joins

  • joins → inner join (records must exist in both tables).
  • left_outer_joins → left outer join (keeps all records from the left table).

Example:

# Show all hives, even those with no bees
Hive.left_outer_joins(:bees).select('hives.*, COUNT(bees.id) AS bees_count').group('hives.id')

8.2 Using references for Complex Conditions

When you need to filter on a joined table but still use includes:

hives = Hive.includes(:bees).where('bees.role = ?', 'queen').references(:bees)

Rails will generate a single query with a LEFT OUTER JOIN.

8.3 Subquery Includes

Rails 7 introduces includes with subquery support, which can be more efficient for large datasets. Example:

hives = Hive.includes(:bees).where(bees: { role: 'worker' }).where(subquery: true)

This will generate a subquery that selects hive_id from bees, then fetches hives in a single pass.


9. Database Indexing & Schema Design

9.1 Foreign Key Indexes

Every belongs_to association should have an index on the foreign key. Rails migrations automatically add them when you use t.references:

t.references :hive, null: false, foreign_key: true

9.2 Composite Indexes for Polymorphic

As mentioned, index [:commentable_type, :commentable_id] to speed up polymorphic queries.

9.3 Partial Indexes

If you only care about active records:

add_index :hives, :status, where: "status = 'active'"

This improves lookups for active hives without bloating the index.

9.4 Indexes on Join Tables

For has_many :through associations, index the join table’s foreign keys:

add_index :enrollments, :student_id
add_index :enrollments, :course_id

9.5 Normalization vs Denormalization

Sometimes, adding a denormalized column (e.g., bees_count) is worthwhile if you read it far more often than you write. Use counter caches or triggers to keep it in sync.


10. Profiling & Tooling

10.1 Bullet Gem

Bullet automatically detects N+1 queries and unused eager loads:

# Gemfile
gem 'bullet', group: :development

Configure:

# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.alert = true
  Bullet.bullet_logger = true
  Bullet.rails_logger = true
  Bullet.add_footer = true
end

Bullet will warn you in the console and add an HTML footer with the number of queries.

10.2 Rails Query Trace

Rails 6+ includes a query_trace method that shows the stack trace for each query. Enable it in development:

ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Base.logger.level = Logger::DEBUG
ActiveRecord::Base.logger.formatter = proc do |severity, datetime, progname, msg|
  "#{msg}\n#{caller[0..5].join("\n")}\n\n"
end

10.3 Database EXPLAIN

Use EXPLAIN to see how the database executes a query. In Rails console:

explain Hive.includes(:bees).where(status: 'active')

This returns the execution plan, showing whether indexes are used, if a full table scan occurs, etc.

10.4 Benchmarking

Rails’ Benchmark module can measure query times:

require 'benchmark'

time = Benchmark.realtime do
  hives = Hive.includes(:bees).where(status: 'active')
  hives.each { |h| h.bees.count }
end
puts "Query took #{time}s"

10.5 Performance Regression Tests

Add a test that asserts the number of queries:

it 'does not generate N+1 queries' do
  expect { get :index }.not_to exceed_query_limit(5)
end

Use the active_record_query_trace gem or ActiveRecord::Base.connection_pool.with_connection to count queries.


11. Case Study: Bee Conservation Data

Let’s put all this theory into practice with a realistic scenario.

11.1 Domain Model

class Hive < ApplicationRecord
  has_many :bees, dependent: :destroy
  has_many :pollinations, through: :bees
end

class Bee < ApplicationRecord
  belongs_to :hive, counter_cache: true
  has_many :pollinations, dependent: :destroy
end

class Pollination < ApplicationRecord
  belongs_to :bee
  belongs_to :flower
end

class Flower < ApplicationRecord
  has_many :pollinations
end

11.2 Problem Statement

We need a dashboard that shows, for each active hive:

  • Hive name
  • Number of worker bees
  • Number of flowers pollinated in the last week

Without optimization, this would generate:

  1. Query to fetch hives.
  2. For each hive, query to count bees.
  3. For each hive, query to count pollinations.

11.3 Optimized Solution

# 1. Preload bees and pollinations
hives = Hive
  .where(status: 'active')
  .includes(:bees, pollinations: :flower)
  .references(:bees, :pollinations, :flowers)

# 2. Aggregate counts in SQL
hives_with_stats = hives
  .select('hives.*,
           COUNT(DISTINCT bees.id) FILTER (WHERE bees.role = \'worker\') AS worker_count,
           COUNT(DISTINCT pollinations.id) FILTER (WHERE pollinations.created_at >= ? ) AS pollination_count',
          1.week.ago)
  .group('hives.id')

This single query returns all the data needed for the dashboard. No N+1, no Ruby‑side counting.

11.4 Performance Gains

  • Baseline: 1 + 500 + 500 = 1001 queries for 500 hives.
  • Optimized: 1 query.

Assuming each query takes 5 ms, baseline ≈ 5 s, optimized ≈ 0.05 s. In a real deployment, the difference is magnified by network latency and database load.


12. Why it Matters

Performance is not a luxury; it’s a necessity for any platform that supports conservation science or autonomous agents. In a bee‑conservation dashboard, researchers rely on real‑time data to decide when to intervene in a colony. In an AI agent system, a delay in fetching agent relationships can stall decision‑making loops, leading to sub‑optimal behavior or even failures.

By mastering eager loading and N+1 mitigation:

  • Scalability: Your application can handle thousands of hives or agents without hitting database limits.
  • Cost Efficiency: Fewer queries mean lower cloud compute costs and faster response times.
  • Developer Productivity: Tools like Bullet and EXPLAIN help catch regressions early, reducing technical debt.
  • Data Integrity: Counter caches and dependent options keep your data consistent even under heavy write loads.

Eager loading is a cornerstone of Rails performance. It’s not just an optimization; it’s a design principle that encourages you to think about relationships holistically. Whether you’re building a conservation platform, a social network, or a swarm of AI agents, the patterns we’ve covered will help you write clean, efficient, and maintainable code.


Frequently asked
What is Rails ActiveRecord Associations about?
Rails ActiveRecord is the heart of any Rails application. It turns database tables into Ruby objects, lets developers think in terms of entities, and hides…
What should you know about 1. Understanding Rails Associations?
Before we can talk about optimization, we need to understand the building blocks that create the relationships you’ll be eager‑loading.
What should you know about 1.1 The Basic Association Types?
These associations are defined in your models, and Rails automatically generates helper methods, scopes, and foreign key constraints (if you use migrations).
What should you know about 1.2 The Role of Foreign Keys and Indexes?
Every belongs_to or has_many association relies on a foreign key column (e.g., owner_id in the pets table). Without proper indexing, lookups on that column become linear scans, and queries that join tables will be expensive. In a typical bee‑conservation database, you might have:
2.1 What is N+1?
The N+1 problem occurs when a single query is followed by N additional queries to fetch associated records. Consider this scenario:
References & sources
  1. Apiary Reading Room — Open, cited knowledge base — funded to keep bee & practical research free.
From the Apiary Reading Room. Opinion & editorial — not financial advice. We don't overclaim.
More from the Reading Room