Ecto does not lazy-load associations by default. This is great! If you access an association that has not been preloaded, you get an Ecto.Association.NotLoaded struct, not a database query. This means Elixir applications are less prone to accidental N+1 queries than Rails or Django.
But N+1 patterns still happen. They show up when you call Repo.preload/2 inside a loop, when you load associations one at a time in LiveView callbacks, or when Oban workers process records without batching. The explicit nature of Ecto preloading is a strength, but it also means you have to think about your data access patterns upfront. Which… is also a strength, really. Your DBA will thank you, even if your DBA is you.
This guide covers the N+1 patterns that can occur in real Elixir applications and how to solve them.
This is part of our N+1 query series. For a framework-agnostic overview, see Understanding N+1 Database Queries. For other frameworks, see our Rails N+1 Guide and Django N+1 Guide.
The N+1 Pattern in Ecto
The classic N+1 happens whenever you run a query in a loop. Maybe you mis-place the Repo.preload/2. If you’ve never done something like this, I’m proud of you:
# 1 query to load all posts
posts = Repo.all(Post)
# N queries, one per post
Enum.each(posts, fn post ->
post = Repo.preload(post, :author)
IO.puts("#{post.title} by #{post.author.name}")
end)
Each call to Repo.preload/2 inside the Enum.each executes a separate query. With 100 posts, that is 101 queries.
Fixing N+1 Queries in Ecto
Preload After the Query
The simplest fix is to preload the association on the entire list before iterating:
posts =
Post
|> Repo.all()
|> Repo.preload(:author)
Enum.each(posts, fn post ->
IO.puts("#{post.title} by #{post.author.name}")
end)
This executes exactly 2 queries: one for posts, one for all related authors. Ecto matches the authors to their posts in memory.
Inline Preload in the Query
You can declare preloads as part of the query itself:
posts =
from(p in Post,
preload: [:author]
)
|> Repo.all()
This is equivalent to the Repo.preload/2 approach but keeps the data loading intent in the query definition.
Join-Based Preload
When you need to filter or order by the associated table, use a join with preload:
posts =
from(p in Post,
join: a in assoc(p, :author),
where: a.active == true,
preload: [author: a]
)
|> Repo.all()
This uses a SQL JOIN instead of a separate query, which is useful when you need to filter on the association.
Comparison of Preload Strategies
| Strategy | Queries | Best For |
|---|---|---|
Repo.preload/2 after query |
2 (list + association) | Simple cases, pipeline style |
from ... preload: [...] |
2 (list + association) | Keeping intent in the query |
join + preload |
1 (single JOIN) | Filtering/ordering by association |
Nested Preloads
Ecto supports nested preloading for multi-level associations:
posts =
Post
|> Repo.all()
|> Repo.preload(author: :publisher)
# Or multiple associations
posts =
Post
|> Repo.all()
|> Repo.preload([:author, :comments, tags: :category])
N+1 Patterns in Phoenix LiveView
LiveView introduces unique N+1 patterns because data loading often happens in callbacks that fire per-event:
# N+1 in handle_params: loads posts then preloads one at a time
def handle_params(%{"id" => id}, _uri, socket) do
post = Repo.get!(Post, id)
post = Repo.preload(post, :comments) # Fine for a single record
# But if this were in a list view:
posts = Repo.all(Post)
posts = Enum.map(posts, &Repo.preload(&1, :comments)) # N+1!
{:noreply, assign(socket, posts: posts)}
end
The fix is to batch the preload:
def handle_params(_params, _uri, socket) do
posts =
Post
|> Repo.all()
|> Repo.preload(:comments)
{:noreply, assign(socket, posts: posts)}
end
In LiveView, be especially careful with handle_event callbacks that update list items individually. Each update can trigger a fresh query if the item is not already preloaded.
N+1 Patterns in Oban Workers
Background jobs with Oban can also contain N+1 patterns, of course. Like anything. With Ecto it feels like you have to go out of your way to make an N+1 like this, but strange things can happen, especially if LLMs are at the helm and the context window is getting wider, until query efficiency as a concern has left the building:
defmodule MyApp.Workers.DigestEmail do
use Oban.Worker
@impl Oban.Worker
def perform(%Oban.Job{args: %{"user_ids" => user_ids}}) do
# N+1: each user triggers a query for their posts
Enum.each(user_ids, fn user_id ->
user = Repo.get!(User, user_id) |> Repo.preload(:recent_posts)
MyApp.Mailer.send_digest(user)
end)
end
end
Fix by loading all users at once. This code is far more natural and elegant in addition to being more efficient:
def perform(%Oban.Job{args: %{"user_ids" => user_ids}}) do
users =
from(u in User, where: u.id in ^user_ids)
|> Repo.all()
|> Repo.preload(:recent_posts)
Enum.each(users, &MyApp.Mailer.send_digest/1)
end
Scout monitors Oban workers with the same N+1 detection that works in Phoenix controllers. If a worker has an N+1 pattern, it appears in your Scout dashboard with the exact code location.
Detecting N+1 Queries in Elixir
Ecto Telemetry
Ecto emits telemetry events for every query. You can attach a handler that counts queries per request and logs when the count is suspiciously high:
:telemetry.attach(
"log-query-count",
[:my_app, :repo, :query],
fn _name, _measurements, _metadata, _config ->
# Increment a counter, log if it exceeds a threshold
end,
nil
)
This is a DIY approach that requires custom code and does not identify which queries are repeated.
Scout APM
Scout’s Elixir agent monitors every Ecto query in every Phoenix request, LiveView event, and Oban job. When it detects repeated query patterns with varying parameters, it flags the N+1 automatically with:
- The exact code location causing the repeated queries
- The generated SQL
- The query count and total time impact
- The request or worker context
This works without custom telemetry handlers or code changes. Install the agent, configure your endpoint, and deploy.
Detection Tool Comparison
| Tool | Detects Automatically | Works in Production | Shows Code Location |
|---|---|---|---|
| Scout | Yes | Yes | Yes |
| Ecto telemetry (custom) | No (DIY) | Yes | No |
| Logger + query analysis | No (manual) | Yes | No |
Frequently Asked Questions
Does Ecto have the same N+1 problem as Rails?
Not exactly. Ecto does not lazy-load associations, so accessing an unpreloaded association raises a struct error instead of silently executing a query. This prevents many accidental N+1 patterns. But N+1 queries still happen when you call Repo.preload/2 inside loops, in LiveView callbacks, and in Oban workers.
What is the best way to preload associations in Ecto?
Use Repo.preload/2 on the full list after your initial query. This is the simplest pattern and executes exactly 2 queries regardless of list size. For queries that need to filter on the association, use a join with inline preload. The from ... preload syntax keeps the intent in the query definition and is equivalent to Repo.preload/2.
How does Scout detect N+1 queries in Elixir?
Scout’s Elixir agent monitors every Ecto query in every Phoenix request, LiveView event, and Oban job. When it sees repeated query patterns with varying parameters, it flags the N+1 with the code location, query count, and time impact. No custom telemetry, no code changes. See how Scout works or start a free trial with no credit card required. Our free tier is always available.
Can N+1 queries happen in Phoenix LiveView?
Yes. LiveView’s callback-driven architecture can lead to N+1 patterns when data loading happens per-event or per-item in a list. The fix is the same as anywhere in Ecto: batch your preloads on the full list before assigning to the socket. Be especially careful with handle_event callbacks that update individual list items.
Does Scout work with Oban?
Yes. Scout monitors Oban workers with the same depth as Phoenix controllers, including N+1 detection, transaction tracing, and error capture. If a worker has a performance issue, it shows up in your Scout dashboard alongside your web request data.
For application monitoring with errors, logs, and traces, Scout Monitoring provides the fastest insights without the bloat. See what we offer, or start a free trial with no credit card required.