‹ Back to Blog

Ecto N+1 Queries: Detection and Prevention in Elixir

Elixir Performance

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.