Introduction
When a screen or API is slow, do not decide at the outset that you need to “eliminate N+1.” First separate where the load is coming from.
This article divides Rails/ActiveRecord retrieval design into three different problems:
- N+1 queries, where the number of SQL statements and round trips increases
- Database-side processing becoming heavier because of JOINs
- Rails-side object creation and application memory becoming heavier because too much data is loaded
The scope here is retrieval on the Rails/ActiveRecord side. Database-side optimizations such as indexes, table definitions, database-server settings, replicas, sharding, and execution-plan improvements specific to a database engine are outside this article. SQL logs and EXPLAIN are used as diagnostic tools to confirm what SQL ActiveRecord issued.
1. What to Look at First When Something Is Slow
画面・APIが遅い
└─ DB処理、SQL回数、Rails側メモリ、オブジェクト生成のどこかに負荷がある
Before going into the details of the cause, check these measurements.
| Measurement | What it tells you |
|---|---|
| SQL count | N+1 queries and unnecessary round trips |
| SQL time | Database and JOIN cost |
| Returned rows and columns | JOIN-driven row growth and the size of the result the database processes and returns |
| ActiveRecord object count and application memory | The cost of loading a large amount of data or eager loading |
| End-to-end processing time | The actual latency |
The amount of data transferred from the database to Rails may also increase. However, the main focus here is not the network transfer itself. The primary measurements are the database work required to produce JOIN results, the work Rails does to create and retain objects, and the number of SQL round trips.
2. Separate the Causes
2.1 N+1: The Number of SQL Statements and Round Trips Increases
If you access an association one record at a time inside a loop after loading the parent records, the association query may be repeated for every parent.
job_applications = JobApplication
.where(status: "active")
.limit(100)
job_applications.each do |job_application|
puts job_application.job.title
end
In this code, loading job_applications takes one query, while loading job_application.job may issue up to one query per record. This is the typical N+1 pattern.
The problem with N+1 is not that every individual SQL statement must be expensive. The problem is that the number of SQL statements and database round trips grows with the number of parent records.
2.2 JOINs: Consolidating SQL Can Make Database-Side Processing Heavier
Joining an association can avoid N+1 by loading it in one SQL statement. However, joining a one-to-many association can repeat the parent data once for every associated record and increase the number of result rows.
Database-side processing tends to become heavier when several of these conditions overlap:
- A one-to-many association is joined
- Multiple associations are joined at the same time
- The parent scope is not narrowed before the JOIN
- Unnecessary JOINs remain
- Sorting or filtering after the JOIN is expensive
Therefore, “did this become one SQL statement?” is not enough to judge an improvement. Even when the query count falls, check whether the JOINs and result rows processed by that one statement have become too large.
2.3 Large Loads: Rails Object Creation and Application Memory Become Heavier
Using preload or a similar method may avoid both N+1 queries and JOIN-driven row growth. At the same time, Rails keeps the loaded parents and associations as ActiveRecord objects.
- There are many parent records
- Each parent has many associated records
- Unnecessary columns are loaded into ActiveRecord objects
- Ruby
selectormapturns all records into an array
In this situation, avoiding database JOINs may move the cost to Rails. The number of objects, arrays, garbage-collection work, and memory usage may all increase.
3. Choose a Solution
3.1 preload: Eager Load Associations Without a JOIN
job_applications = JobApplication
.where(status: "active")
.preload(:job)
preload loads the parent and association with separate SQL statements so that accessing the association in the loop does not trigger another query. It is a candidate when you want to avoid JOIN-driven row amplification.
However, both the parents and the associations are loaded into Rails memory. When the association cardinality is large, application memory may become the new bottleneck even though N+1 has been reduced.
3.2 eager_load and joins: Apply Association Conditions in the Database
When you need to apply conditions on an associated table in the database, consider eager_load or joins.
job_applications = JobApplication
.eager_load(:job)
.where(jobs: { published: true })
eager_load also eager loads the association objects. In contrast, joins is mainly for using the associated table in conditions or filtering; it does not eager load the association objects.
job_applications = JobApplication
.joins(:job)
.where(jobs: { published: true })
If you access job_application.job after joins, another query may be issued. Database-side filtering through a JOIN and eager loading association objects are separate concerns.
3.3 includes: Convenient, but Check the SQL Shape
includes is a common entry point for eager loading, but the way conditions are written can make it behave like preload or like a JOIN.
JobApplication.includes(:job)
Do not assume that “writing includes means no JOIN” or that “writing includes is always safe.” Inspect the SQL that was actually issued and decide whether the database work and result-row growth caused by a JOIN are acceptable.
3.4 Narrow the Scope with where First
JobApplication
.where(status: "active")
.where(created_at: period)
.preload(:job)
Narrowing the time range or status before loading associations can reduce both the parent set processed by the database and the set retained by Rails. It may help with N+1, JOIN cost, and application memory at the same time.
However, even a narrow parent set can have a large number of associated records. Check where the conditions are applied and how many records are actually involved.
3.5 select and pluck: Reduce Unnecessary Columns and Object Creation
JobApplication
.where(status: "active")
.pluck(:id, :job_id)
Fetch only the columns you need, and use pluck when you do not need ActiveRecord objects at all. This can reduce unnecessary columns and object creation.
However, select and pluck do not automatically reduce the number of SQL statements or eliminate JOINs. If you omit an attribute that later processing needs, you may create another query or an implementation problem.
3.6 Pagination: Limit the Amount Handled at Once
Limit the number of parent records loaded for a list screen and keep eager loading within the page. This limits the number of objects Rails retains in one request.
The total number of requests and screen interactions may increase, so evaluate memory, latency, and UX together.
3.7 Split Retrieval on the Front End
Another option is to separate information needed for the initial display from heavier information that can be shown later.
- Shorten the initial response wait
- Split the load concentrated in one request
- Fetch heavy associations when they are needed
This does not necessarily reduce total SQL or total request volume. Treat it as a supporting way to spread initial-display load, not as a replacement for database optimization.
4. How Solutions Map to Problems
The symbols mean: ◎ is a primary target, ○ may help depending on the conditions, and △ is a supporting effect with side effects or limits.
| Solution | N+1 | Database load from JOINs | Large loads and Rails memory | Notes |
|---|---|---|---|---|
preload | ◎ | ◎ | △ | Avoids JOINs, but loads associations with another query into Rails memory |
eager_load | ◎ | △ | △ | Resolves N+1, but may add database work and result-row growth through JOINs |
includes | ◎ | ○ | △ | May behave like preload or a JOIN depending on the conditions |
joins | △ | ○ | ○ | For filtering; it does not eager load association objects |
Narrow first with where, time range, or status | ○ | ◎ | ◎ | Reduces the sets processed by the database and retained by Rails |
select and pluck | △ | △ | ◎ | Reduces columns and objects per query, not necessarily SQL count |
| Pagination | ○ | ○ | ◎ | Limits rows per request; total request count may increase |
| Split retrieval on the front end | ○ | ○ | ○ | Spreads load across requests; does not necessarily reduce total database work |
5. Separate the Two Load Axes
When comparing retrieval strategies, distinguish “how many SQL statements are issued” from “how much one SQL statement makes the database process.”
| Axis | Typical problem | Main solutions | Main measurements |
|---|---|---|---|
| SQL count and round trips | Association SQL repeats inside a loop as N+1 | preload, eager_load, includes, and aggregation when appropriate | SQL count, round-trip time, total SQL time |
| Database work and result size per query | JOINs multiply rows, or the database processes and returns unnecessary rows and columns | A preceding where, select, pluck, pagination, or preload | Database time, row count, column count, Rails object count, and memory |
Reducing SQL count by resolving N+1 does not guarantee that an eager_load JOIN will be cheap. Conversely, reducing columns and objects with select or pluck does not reduce SQL count by itself.
6. Trade-offs That Can Appear After a Fix
A solution does not make the problem disappear magically. It may move the load to another place.
| Problem to solve | Solution | What improves | What may happen next |
|---|---|---|---|
| N+1 | preload | SQL count and association round trips | Loading many associations in a separate query increases Rails memory |
| N+1 | eager_load | SQL count and association eager loading | JOIN processing, row growth, and duplicated result data |
| N+1 and filtering by an association | includes | Eager loading appropriate to the conditions | The SQL shape may change to a JOIN |
| Database load from JOINs | Split into preload | JOIN processing and row amplification | SQL count and Rails-side memory may increase |
| Large loads and Rails memory | select or pluck | Object creation and retained data | Later processing may lack required attributes |
| Large loads and Rails memory | Narrow first with where or pagination | Rows and memory per request | Conditions become distributed, or extra pagination requests are added |
| Initial-request load | Split retrieval on the front end | Initial response and per-request retrieval volume | Total request count, SQL volume, and state management may increase |
7. A Flowchart for Choosing a Retrieval Strategy
Finally, here is a decision process to use after reviewing the problems and solutions.
flowchart TD
A[目的を定義する<br/>表示・件数・更新・存在判定] --> B[データ量を確認する<br/>親件数・関連件数・カーディナリティ]
B --> C{関連条件をDBで<br/>適用する必要があるか}
C -->|はい| D[joins / eager_load / サブクエリ / 集計SQL]
C -->|いいえ| E{関連オブジェクトを<br/>Rubyで使うか}
E -->|はい| F[preload / 明示的な関連取得]
E -->|いいえ| G[pluck / select / exists / count]
D --> H{取得量・行増加・メモリを<br/>許容できるか}
F --> H
G --> H
H -->|大きい| I[期間・ページング・集計・専用クエリ]
H -->|許容| J[SQLログ・EXPLAIN・時間・メモリで検証]
I --> J
J --> K[必要なら初期表示と重い情報をAjax分離]
This flow is not intended to eliminate N+1 at any cost. It is a procedure for choosing which load to reduce after checking the purpose, data volume, whether conditions must be handled in the database, and whether Rails needs the association objects.
Summary
- N+1 is a problem where the number of SQL statements and round trips increases.
preloadcan eager load associations without a JOIN, but watch application memory.eager_loadmakes it easier to apply association conditions in the database, but watch database work and row growth caused by JOINs.select,pluck, a precedingwhere, and pagination reduce the amount the database and Rails handle at once.- Splitting retrieval on the front end spreads initial-display load; it is not database optimization by itself.
- The goal is not to minimize SQL statements at all costs. Choose a retrieval strategy based on the screen requirements and data volume, and measure database work, SQL round trips, and Rails memory.
References
- Active Record Querying — Rails Guides
- Used to confirm
preload,eager_load,includes,joins,pluck, andEXPLAINbehavior.
- Used to confirm
- Active Record Associations — Rails Guides
- Used to confirm association and eager-loading basics.
- Heroku: N+1 Queries or Memory Problems
- Used as a reference for the trade-off where resolving N+1 loads many associations into memory.
- Saeloun: Fixing N+1 Queries in Active Record
- Used to compare
preload,eager_load, JOIN result growth, andpluck.
- Used to compare
- Martin Fowler: Data Fetching Patterns in Single-Page Applications
- Used as a supporting reference for splitting front-end data fetching.