Your API endpoint takes four seconds to load. The database CPU looks healthy.
Memory usage is fine. You add a read replica, and nothing changes.
You upgrade the instance, and it's still slow. Someone finally profiles the request and discovers the actual problem: one API call is generating 501 database queries.
This is the N+1 query problem, and it is one of the most expensive performance issues in production applications today. It does not look like a bug.
It does not throw errors. It hides behind clean-looking ORM code and grows silently as your user base grows.
Left unchecked, N+1 queries inflate latency, spike cloud costs, exhaust database connections, and drive infrastructure upgrades that never actually fix the problem, because the root cause was never capacity. It was efficiency.
This guide covers what the N+1 problem is, why it happens, how to find it in a running production system, and five approaches to eliminate it.
Prefer to skip the profiling and have an expert find it? Book a 30-min diagnostic →
What the N+1 Query Problem Actually Is
The N+1 query problem occurs when an application executes one query to retrieve a set of records, then executes an additional query for each record returned, instead of fetching all the data it needs in a single operation. The name comes directly from the arithmetic: 1 initial query plus N additional queries, one per row. For an endpoint that loads 100 users and their associated orders, a naive implementation runs 1 query to fetch the users, then 100 individual queries to fetch each user's orders.
That is 101 database round trips to accomplish what one well-written JOIN would do in one. The scale behaviour is what makes it genuinely dangerous. 100 users produces 101 queries.
1,000 users produces 1,001 queries. 10,000 users produces 10,001 queries. Query volume grows in direct proportion to data volume, so the worse the problem gets, the worse it gets.
The database is not doing more work because the business grew. It is doing more work because the application is asking inefficient questions.
-- What the database actually sees during an N+1 request
-- 1 query to fetch the parent records:
SELECT * FROM users; -- returns 100 rows
-- Then N queries — one per user row returned:
SELECT * FROM orders WHERE user_id = 1;
SELECT * FROM orders WHERE user_id = 2;
SELECT * FROM orders WHERE user_id = 3;
-- ... 97 more queries
-- Total: 101 queries to serve one API request.
-- At 1,000 users: 1,001 queries.
-- At 10,000 users: 10,001 queries.
-- Query count scales linearly with data — the problem worsens as you grow.Why N+1 Queries Are So Expensive
N+1 is typically described as a performance issue, but that undersells the damage.
Latency: every database query carries overhead: network round trip, query parsing, lock acquisition, result serialization. At 5ms per query, 101 queries add 505ms of database time before any application logic runs, and at 1,001 queries it is over 5 seconds before authentication, business logic, serialization, and response generation.
Database load: the database is processing hundreds of queries that deliver the same result as one. CPU rises, I/O increases, connection pool slots are consumed, and the buffer cache is churned. The database works substantially harder while accomplishing the same amount of useful work.
Cloud costs: rising database load triggers the natural response: upgrade the instance, add a read replica, increase storage. The bill grows; the performance barely changes. At 100 requests per second, each generating 50 unnecessary queries, the database is fielding 5,000 unnecessary queries every second, and scaling that with a larger instance just means the database handles 5,000 unnecessary queries faster. Still 5,000 unnecessary queries.
Scalability: N+1 queries scale linearly with data volume: an endpoint that generates 1,001 queries for 1,000 users will generate 10,001 queries for 10,000 users
What worked during development becomes a production bottleneck at scale, and the application appears to outgrow its infrastructure far earlier than it should.
Why N+1 Happens: ORMs and Lazy Loading
Developers rarely write N+1 queries deliberately. Most occur because of how ORMs handle data retrieval by default. ORMs (Django ORM, ActiveRecord in Rails, SQLAlchemy, Prisma, Entity Framework, Laravel Eloquent) abstract SQL behind object-oriented code, which makes development significantly faster but also makes N+1 patterns easy to create accidentally.
The most common trigger is lazy loading: the ORM defers fetching related records until the relationship is accessed in code. When you iterate over a queryset and access a related attribute on each row, the ORM fires a new database query for each iteration. The application code looks like a simple loop.
The database sees hundreds of individual requests. This is why N+1 issues frequently survive code reviews: the problematic pattern is invisible in the application code. You see a for loop iterating over objects.
You do not see 100 database queries firing behind each object access.
# Django: the innocent-looking code that causes N+1
# Step 1: fetch all users (1 query)
users = User.objects.all()
# Step 2: access related orders in a loop
for user in users:
print(user.orders.all())
# Each .orders access fires:
# SELECT * FROM orders WHERE user_id = <id>
# 100 users = 100 additional queries. Code looks clean. It isn't.
# ── The fix: one line change ──────────────────────────────────────────
# prefetch_related — separate optimized IN query, right for reverse FK / M2M
users = User.objects.prefetch_related('orders').all()
# Now: 2 queries total (not 101).
# Query 1: SELECT * FROM users
# Query 2: SELECT * FROM orders WHERE user_id IN (1, 2, 3, ..., 100)
# The ORM maps results back in Python — zero extra DB round trips.
# select_related — SQL JOIN at the DB level, right for FK / OneToOne
posts = Post.objects.select_related('author').all()
# 1 query total.Related: how to debug slow SQL queries, why your API is slow even when the database looks fine
101 Queries vs 1 Query: The Fix in SQL
The mechanics of the problem are most visible in raw SQL. The N+1 version runs one query to get the parent records, then fires an individual query per row for the related data. The fix is a single LEFT JOIN that retrieves both datasets in one database round trip: same result, a fraction of the work.
This is why fixing N+1 issues frequently produces performance improvements that feel disproportionate to the code change. Reducing 101 queries to 1 does not improve performance by 5%. At 5ms per query, it reduces total database time from 505ms to approximately 5ms, a 100x reduction on database time alone, before any other optimisation.
The fastest query is often the one you never execute. Eliminating unnecessary queries delivers more performance than tuning the queries themselves.
-- N+1 VERSION: 101 database round trips for 100 users
-- Query 1:
SELECT id, name, email FROM users;
-- Queries 2–101 (one per user, fired in a loop):
SELECT id, total, status, created_at FROM orders WHERE user_id = 1;
SELECT id, total, status, created_at FROM orders WHERE user_id = 2;
-- ... 98 more
-- ─────────────────────────────────────────────────────────────────────────
-- OPTIMIZED VERSION: 1 round trip
SELECT
u.id AS user_id,
u.name,
u.email,
o.id AS order_id,
o.total,
o.status,
o.created_at
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
-- Same result set. One round trip.
-- At 5ms per query: 101 queries = 505ms database time.
-- 1 query = 5ms database time.
-- That's a 100x reduction in DB time before any other optimization.Still Piecing This Together Yourself?
A senior engineer looks at your actual setup, not a generic checklist, and tells you exactly what's wrong and how to fix it.
Book a Discovery CallWhy Adding a Read Replica Doesn't Fix It
This is the most expensive misconception in database performance: that N+1 is a capacity problem. It is not. It is an efficiency problem.
A read replica does not reduce the number of queries an endpoint generates: it gives those queries another server to run on. If your endpoint generates 500 unnecessary queries, adding a read replica means the endpoint now generates 500 unnecessary queries distributed across two servers. The inefficiency is shared, not eliminated.
This is why teams sometimes spend months adding replicas, upgrading instances, and tuning storage while API performance barely improves. They are treating symptoms instead of causes. Five hundred unnecessary queries remain five hundred unnecessary queries regardless of how many database servers sit behind them.
No amount of hardware changes that. The bottleneck was never capacity. Infrastructure upgrades fail to produce the expected gains because the root cause, the query pattern, was never addressed.
How to Detect N+1 Queries in Production
Early detection is the difference between a quick fix and a systems-level incident. The most reliable signal across all stacks is pg_stat_statements: a PostgreSQL extension that tracks query frequency, cumulative execution time, and row counts. A query that appears thousands of times with low individual latency but high aggregate time is an N+1 candidate: the per-query cost is small, but at high call counts it dominates total database load.
For development-time detection by framework: Django has Django Debug Toolbar (displays every query executed during a request, with source line) and Silk (request profiling). Rails has Bullet, which detects N+1 patterns at runtime and fires warnings when lazy-loaded associations should be eager-loaded. Laravel has Telescope and Laravel Debugbar, both providing per-request query visibility.
In production, APM tools (New Relic, Datadog, AppSignal, Elastic APM) surface database-heavy endpoints and high-query-count request traces. An endpoint that consistently shows 200+ database calls in Datadog traces is an N+1 problem until proven otherwise.
-- pg_stat_statements: find N+1 candidates in production
-- Enable: add 'pg_stat_statements' to shared_preload_libraries (RDS parameter group)
-- Then: CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Surface high-frequency, low-latency queries — the N+1 signature:
SELECT
LEFT(query, 120) AS query_preview,
calls,
round(mean_exec_time::numeric, 2) AS avg_ms,
round((total_exec_time / 1000)::numeric, 1) AS total_sec,
rows / calls AS avg_rows
FROM pg_stat_statements
WHERE calls > 500 -- high call count is the N+1 signal
AND mean_exec_time < 20 -- fast individually, expensive in aggregate
ORDER BY calls DESC
LIMIT 20;
-- Red flags in the output:
-- "SELECT ... FROM orders WHERE user_id = $1" appearing 5,000+ times/hour
-- Total time dominated by high-frequency small queries, not a few slow ones
-- Query count growing proportionally with user count (not traffic count)
-- Reset after a code change to measure improvement:
SELECT pg_stat_statements_reset();Related: our database engineering service
Five Ways to Fix N+1 Queries
Once identified, N+1 problems are almost always fixable with one of five approaches:
Eager loading: tell the ORM to fetch related records upfront rather than on access. In Django: select_related() for FK and OneToOne (uses a SQL JOIN), prefetch_related() for reverse FK and ManyToMany (uses a batched IN query). In Rails: includes(). One line of ORM code can reduce query counts by 90% or more.
SQL JOINs: the framework-agnostic solution: retrieve parent and related records in one operation. They remain one of the most effective query optimisation techniques and require no ORM support.
Batch queries with IN: replace N individual lookups with one query using WHERE id IN (...): collect all required IDs first, then fetch all related records in a single round trip and map them in application memory
DataLoader for GraphQL APIs: each field resolver fires independently, producing N+1 at the resolver level. DataLoader batches all resolver calls for the same entity type within a single request cycle into one database query. Without it, GraphQL APIs generate N+1 at every nested field.
Query count budgets: the advanced practice that prevents regressions: define a maximum acceptable query count per endpoint and enforce it in integration tests, and treat budget breaches as test failures. This stops N+1 issues from re-entering production through code changes.
-- ── Eager loading (ORM solutions) ──────────────────────────────────────
-- Django (Python)
-- select_related → SQL JOIN, for FK / OneToOne
posts = Post.objects.select_related('author').all()
-- prefetch_related → batch IN query, for reverse FK / M2M
users = User.objects.prefetch_related('orders').all()
-- Rails (Ruby)
-- includes → JOIN or batch, Rails decides based on query shape
users = User.includes(:orders).all
-- ── Batch queries with IN (framework-agnostic) ───────────────────────────
-- Instead of N individual lookups:
-- SELECT * FROM orders WHERE user_id = 1;
-- SELECT * FROM orders WHERE user_id = 2; ...
-- Collect IDs, batch in one query:
SELECT * FROM orders
WHERE user_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
-- One round trip replaces N. PostgreSQL index-scans the IN list efficiently.
-- ── Query count budget (integration test pattern — Django example) ────────
-- from django.test.utils import CaptureQueriesContext
-- from django.db import connection
-- with CaptureQueriesContext(connection) as ctx:
-- response = client.get('/api/users/')
-- assert len(ctx.captured_queries) <= 5, (
-- f"N+1 regression: endpoint fired {len(ctx.captured_queries)} queries"
-- )
-- When a code change causes an endpoint to exceed its budget, the test fails
-- before the regression reaches production.Related: our database engineering service
Get a Straight Answer, Not a Sales Pitch
Tell us what you're running into. We'll tell you directly what's causing it and what it takes to fix, before you sign anything.
Book a Discovery CallWhen N+1 Becomes an Architectural Problem
N+1 starts as a code quality issue. Left unaddressed long enough, it becomes a systems-level problem. The warning signs that the threshold has been crossed: database CPU keeps rising in proportion to data volume even when application traffic has not changed substantially; read replicas accumulate, each delivering less benefit than the last; API latency worsens despite infrastructure upgrades; cloud database costs grow faster than business metrics; and most dangerously, nobody on the team can explain why the database is working as hard as it is.
At that point, the diagnosis is not obvious from looking at application code: it requires profiling at the query level, tracing request flows through pg_stat_statements and APM tooling, and mapping which endpoints are responsible for the highest aggregate database load. A structured review of query patterns and ORM configuration often recovers more performance than a year of infrastructure scaling, because the work being done was never useful work in the first place.
The N+1 query problem is one of the most common reasons APIs slow down as applications grow, and one of the most fixable. The pattern starts as an innocent ORM loop in development. Over time it multiplies into thousands of unnecessary database requests per second, driving latency up, database CPU up, and cloud bills up, while delivering no additional business value.
The typical response (more replicas, larger instances, additional caching) treats the symptom. Fixing the query pattern treats the cause. Before spending on more infrastructure, check whether the database is doing work it never needed to do.
pg_stat_statements will tell you inside five minutes whether you have an N+1 problem. Eager loading or a JOIN will fix it in an afternoon. Few optimisations deliver a better return on the time invested.
Frequently Asked Questions
What causes N+1 queries?
Most N+1 problems are caused by ORM lazy loading, where related data is fetched individually as code accesses each object's relationships in a loop, rather than being retrieved in a single batch upfront. The ORM fires one query per parent record instead of one query for all related records. The code looks clean; the database sees hundreds of individual requests.
How do I detect N+1 queries in production?
The most reliable method is pg_stat_statements, a PostgreSQL extension that tracks query frequency. Query for high call counts (calls > 500) with low individual latency (mean_exec_time < 20ms): that combination is the N+1 signature. In production, APM tools like Datadog and New Relic surface endpoints with high database query counts per request. During development, use Django Debug Toolbar, Rails Bullet, or Laravel Telescope for per-request query visibility.
How do I fix N+1 queries in Django?
Use select_related() for ForeignKey and OneToOne relationships (adds a SQL JOIN) and prefetch_related() for reverse FK and ManyToMany relationships (runs a separate batched IN query). Either replaces N queries with 1 or 2 queries. For raw SQL-level fixes, use a LEFT JOIN or WHERE id IN (...) batch query.
How do I fix N+1 queries in Rails?
Use User.includes(:orders); Rails chooses between a JOIN and a batch IN query based on query complexity. Add the Bullet gem to your development environment; it actively detects N+1 patterns at runtime and fires warnings before they reach production. For complex cases, use joins() or raw SQL with WHERE id IN (...).
Why is my API still slow after adding a read replica?
Because N+1 is an efficiency problem, not a capacity problem. A read replica does not reduce how many queries an endpoint generates; it gives those queries another server to run on. If an endpoint fires 500 unnecessary queries, adding a replica means 500 unnecessary queries now run across two servers. The query count and inefficiency remain unchanged. Fix the query pattern first; then evaluate whether additional capacity is actually needed.
Can GraphQL cause N+1 problems?
Yes, and GraphQL is particularly prone to them. Each field resolver fires independently, which means fetching a list of users with nested order data triggers one resolver call per user: a textbook N+1 pattern at the resolver level. DataLoader is the standard fix: it batches all resolver calls for the same entity type within a single request cycle into one database query, reducing N resolver queries to 1 per entity type per request.
Get a Straight Answer on Your Setup
Tell us what you're running into. We read every message personally and reply within 24 hours with times for a free call.


