The N+1 Query Problem: How to Detect and Fix It
TL;DR
N+1 queries happen when you fetch records in a loop - 1 query becomes 1000. Detect with query logging and APM tools. Fix with eager loading, joins, or batching. 100x performance improvement is common.
Our homepage loaded in 15 seconds. I checked the logs: 2,847 database queries for a single page load. We had N+1 queries everywhere.
After fixing them, the same page loaded in 180ms with just 4 queries. Response time improved by 98%. Our database CPU dropped from 80% to 12%.
The N+1 query problem is the most common performance issue I see in production. It's invisible until it kills your site, and every ORM makes it easy to write. Here's how to find and fix it.
What Is the N+1 Query Problem?
The N+1 query problem happens when you fetch N records, then make 1 additional query for each record to fetch related data. Instead of 1 query, you make N+1 queries.
// Fetch 100 users (1 query)
const users = await User.findAll();
// Loop through users and fetch their posts
for (const user of users) {
const posts = await Post.findAll({ where: { userId: user.id } });
// This runs 100 more queries - one per user!
user.posts = posts;
}
// Total queries: 1 + 100 = 101 queries
// This is the N+1 problem
What actually happens:
-- Query 1: Get all users
SELECT * FROM users;
-- Query 2: Get posts for user 1
SELECT * FROM posts WHERE user_id = 1;
-- Query 3: Get posts for user 2
SELECT * FROM posts WHERE user_id = 2;
-- Query 4: Get posts for user 3
SELECT * FROM posts WHERE user_id = 3;
-- ... repeats 100 times
With 100 users, you make 101 queries. With 1,000 users, you make 1,001 queries. This scales horribly.
Why ORMs Make This Easy to Write
ORMs hide the SQL, making N+1 queries invisible:
// Looks innocent - how many queries does this make?
const users = await User.findAll();
for (const user of users) {
console.log(user.name);
console.log(user.posts.length); // Hidden query here!
}
You don't see the query, so you don't realize you're making hundreds of them.
Every ORM has this problem:
- Sequelize (Node.js)
- ActiveRecord (Rails)
- Django ORM (Python)
- Entity Framework (.NET)
- Hibernate (Java)
- Prisma (Node.js)
- TypeORM (Node.js)
Real-World N+1 Query Example
Here's actual code that brought down a production site:
// Express.js API endpoint
app.get('/api/dashboard', async (req, res) => {
// Get current user
const user = await User.findByPk(req.userId); // Query 1
// Get user's projects
const projects = await Project.findAll({
where: { userId: user.id }
}); // Query 2
// For each project, get tasks
for (const project of projects) {
project.tasks = await Task.findAll({
where: { projectId: project.id }
}); // Queries 3-52 (50 projects)
// For each task, get comments
for (const task of project.tasks) {
task.comments = await Comment.findAll({
where: { taskId: task.id }
}); // Queries 53-552 (500 tasks)
// For each comment, get author
for (const comment of task.comments) {
comment.author = await User.findByPk(comment.userId);
} // Queries 553-2552 (2000 comments)
}
}
res.json({ user, projects });
});
// Total queries: 1 + 1 + 50 + 500 + 2000 = 2,552 queries
// Response time: 15 seconds
This is not an exaggeration. I've seen worse in production.
How to Detect N+1 Queries
1. Query Logging
Enable query logging in your ORM:
// Sequelize
const sequelize = new Sequelize('database', 'user', 'password', {
logging: console.log // Log all queries
});
// Now you see every query
// SELECT * FROM users;
// SELECT * FROM posts WHERE user_id = 1;
// SELECT * FROM posts WHERE user_id = 2;
// SELECT * FROM posts WHERE user_id = 3;
// ... uh oh
# Django - settings.py
LOGGING = {
'version': 1,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django.db.backends': {
'handlers': ['console'],
'level': 'DEBUG',
},
},
}
# Rails - config/environments/development.rb
config.log_level = :debug
# Or use bullet gem
gem 'bullet'
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.console = true
end
If you see the same query pattern repeating with different IDs, you have N+1.
2. Count Queries
Add middleware to count queries per request:
// Express.js middleware
app.use((req, res, next) => {
let queryCount = 0;
const originalQuery = sequelize.query;
sequelize.query = function(...args) {
queryCount++;
return originalQuery.apply(this, args);
};
res.on('finish', () => {
if (queryCount > 10) {
console.warn(`⚠️ ${req.path} made ${queryCount} queries`);
}
});
next();
});
# Django middleware
class QueryCountMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
from django.db import connection
queries_before = len(connection.queries)
response = self.get_response(request)
queries_after = len(connection.queries)
query_count = queries_after - queries_before
if query_count > 10:
print(f"⚠️ {request.path} made {query_count} queries")
return response
3. APM Tools
Use Application Performance Monitoring:
New Relic:
- Shows N+1 queries automatically
- Groups identical queries
- Highlights slow endpoints
Datadog APM:
- Traces database queries
- Shows query count per request
- Alerts on high query volume
Sentry Performance:
- Detects N+1 automatically
- Shows before/after query counts
- Suggests fixes
4. Django Debug Toolbar
# Install
pip install django-debug-toolbar
# Shows queries for each page
# Highlights duplicate queries
# Shows execution time
5. Rails Bullet Gem
# Gemfile
gem 'bullet', group: 'development'
# Detects N+1 queries automatically
# Suggests eager loading
# Shows unused eager loaded associations
How to Fix N+1 Queries
Solution 1: Eager Loading (Best for Most Cases)
Fetch all related data in advance:
// Sequelize - BAD (N+1)
const users = await User.findAll();
for (const user of users) {
user.posts = await Post.findAll({ where: { userId: user.id } });
}
// 1 + N queries
// Sequelize - GOOD (Eager loading)
const users = await User.findAll({
include: [{ model: Post }]
});
// 1 query with JOIN
Generated SQL:
SELECT users.*, posts.*
FROM users
LEFT JOIN posts ON users.id = posts.user_id;
Django ORM:
# BAD (N+1)
users = User.objects.all()
for user in users:
posts = user.post_set.all() # N queries
# GOOD (Eager loading)
users = User.objects.prefetch_related('post_set')
# 2 queries: users, then all posts
Rails ActiveRecord:
# BAD (N+1)
users = User.all
users.each do |user|
puts user.posts.count # N queries
end
# GOOD (Eager loading)
users = User.includes(:posts)
users.each do |user|
puts user.posts.count # 0 additional queries
end
Prisma:
// BAD (N+1)
const users = await prisma.user.findMany();
for (const user of users) {
user.posts = await prisma.post.findMany({
where: { userId: user.id }
});
}
// GOOD (Eager loading)
const users = await prisma.user.findMany({
include: { posts: true }
});
Solution 2: Manual JOIN
Write the JOIN yourself:
// Sequelize with raw SQL
const results = await sequelize.query(`
SELECT
users.*,
JSON_AGG(posts.*) as posts
FROM users
LEFT JOIN posts ON users.id = posts.user_id
GROUP BY users.id
`, { type: QueryTypes.SELECT });
# Django ORM with select_related (for ForeignKey)
posts = Post.objects.select_related('author').all()
for post in posts:
print(post.author.name) # No additional query
Solution 3: Batch Loading (DataLoader Pattern)
Load related records in batches:
const DataLoader = require('dataloader');
// Create a batch loading function
const postLoader = new DataLoader(async (userIds) => {
const posts = await Post.findAll({
where: { userId: userIds }
});
// Group posts by user ID
const postsByUserId = {};
for (const post of posts) {
if (!postsByUserId[post.userId]) {
postsByUserId[post.userId] = [];
}
postsByUserId[post.userId].push(post);
}
// Return posts in same order as userIds
return userIds.map(id => postsByUserId[id] || []);
});
// Usage
const users = await User.findAll();
for (const user of users) {
user.posts = await postLoader.load(user.id);
// DataLoader batches these into 1 query
}
// Queries executed:
// SELECT * FROM users;
// SELECT * FROM posts WHERE user_id IN (1, 2, 3, ..., 100);
// Total: 2 queries instead of 101
Solution 4: Aggregation Queries
Sometimes you don't need the full data:
// BAD - Fetch all posts just to count them
const users = await User.findAll();
for (const user of users) {
const posts = await Post.findAll({ where: { userId: user.id } });
user.postCount = posts.length;
}
// GOOD - Aggregate in database
const users = await User.findAll({
attributes: {
include: [
[
sequelize.fn('COUNT', sequelize.col('posts.id')),
'postCount'
]
]
},
include: [{
model: Post,
attributes: []
}],
group: ['User.id']
});
Generated SQL:
SELECT users.*, COUNT(posts.id) as postCount
FROM users
LEFT JOIN posts ON users.id = posts.user_id
GROUP BY users.id;
Nested N+1 Queries
The worst kind - N+1 inside N+1:
// VERY BAD - Nested N+1
const users = await User.findAll(); // 1 query
for (const user of users) { // N queries
user.posts = await Post.findAll({ where: { userId: user.id } });
for (const post of user.posts) { // N*M queries
post.comments = await Comment.findAll({ where: { postId: post.id } });
}
}
// With 10 users and 5 posts each:
// 1 + 10 + 50 = 61 queries
Fix with nested eager loading:
// Sequelize
const users = await User.findAll({
include: [{
model: Post,
include: [{ model: Comment }]
}]
});
// 1 query with nested JOINs
// Or 3 separate queries (sometimes faster)
const users = await User.findAll({
include: [{
model: Post,
include: [{ model: Comment }],
separate: true // Uses 3 queries instead of 1 JOIN
}]
});
# Django
users = User.objects.prefetch_related(
'post_set__comment_set' # Double underscore for nested
)
# 3 queries: users, posts, comments
# Rails
users = User.includes(posts: :comments)
# 3 queries: users, posts, comments
Performance Comparison
I benchmarked N+1 vs eager loading:
// Test: 100 users, each with 10 posts
// N+1 Queries
console.time('N+1');
const users = await User.findAll();
for (const user of users) {
user.posts = await Post.findAll({ where: { userId: user.id } });
}
console.timeEnd('N+1');
// N+1: 3,847ms
// Queries: 101
// Eager Loading
console.time('Eager');
const users = await User.findAll({
include: [{ model: Post }]
});
console.timeEnd('Eager');
// Eager: 38ms
// Queries: 1
// Improvement: 101x faster, 100 fewer queries
Real production impact:
- Homepage load time: 15s → 180ms (83x faster)
- Database CPU: 80% → 12%
- Queries per request: 2,847 → 4
- Monthly database costs: $800 → $150
When N+1 Is Acceptable
Sometimes N+1 queries are fine:
1. Small N
// Only loading 3 users - N+1 is fine
const recentUsers = await User.findAll({ limit: 3 });
for (const user of recentUsers) {
user.posts = await Post.findAll({ where: { userId: user.id } });
}
// 4 queries - not a problem
2. Rarely Accessed Data
// Most users don't have premium data
const users = await User.findAll();
for (const user of users) {
if (user.isPremium) {
user.premiumData = await PremiumData.findOne({
where: { userId: user.id }
});
// Only 2% of users - lazy load is fine
}
}
3. Conditional Loading
// Only load posts if user has any
const users = await User.findAll({
attributes: {
include: [[sequelize.fn('COUNT', sequelize.col('posts.id')), 'postCount']]
},
include: [{ model: Post, attributes: [] }],
group: ['User.id']
});
for (const user of users) {
if (user.postCount > 0) {
user.posts = await Post.findAll({ where: { userId: user.id } });
}
}
4. Pagination
// Only showing 10 users per page
const users = await User.findAll({ limit: 10, offset: page * 10 });
// Even with N+1, it's only 11 queries total
GraphQL and N+1 Queries
GraphQL makes N+1 worse:
query {
users {
id
name
posts { # N+1 here
id
title
comments { # N+1 inside N+1
id
text
author { # N+1 inside N+1 inside N+1
name
}
}
}
}
}
Solution: DataLoader
const DataLoader = require('dataloader');
// Create loaders
const postLoader = new DataLoader(async (userIds) => {
const posts = await Post.findAll({ where: { userId: userIds } });
// Group and return
});
const commentLoader = new DataLoader(async (postIds) => {
const comments = await Comment.findAll({ where: { postId: postIds } });
// Group and return
});
// GraphQL resolvers
const resolvers = {
User: {
posts: (user, args, { postLoader }) => {
return postLoader.load(user.id);
}
},
Post: {
comments: (post, args, { commentLoader }) => {
return commentLoader.load(post.id);
}
}
};
DataLoader batches and caches queries within a single request.
ORM-Specific Solutions
Sequelize (Node.js)
// Single level eager loading
User.findAll({
include: [Post]
});
// Multiple associations
User.findAll({
include: [Post, Comment, Profile]
});
// Nested eager loading
User.findAll({
include: [{
model: Post,
include: [Comment]
}]
});
// Separate queries (sometimes faster than JOIN)
User.findAll({
include: [{
model: Post,
separate: true
}]
});
// Conditional include
User.findAll({
include: [{
model: Post,
where: { published: true },
required: false // LEFT JOIN instead of INNER JOIN
}]
});
Django ORM (Python)
# select_related for ForeignKey (uses JOIN)
posts = Post.objects.select_related('author', 'category').all()
# prefetch_related for reverse ForeignKey and ManyToMany (uses separate queries)
users = User.objects.prefetch_related('post_set', 'comments').all()
# Nested prefetch
users = User.objects.prefetch_related(
'post_set__comment_set__author'
).all()
# Custom prefetch
from django.db.models import Prefetch
users = User.objects.prefetch_related(
Prefetch('post_set', queryset=Post.objects.filter(published=True))
).all()
Rails ActiveRecord (Ruby)
# includes (uses JOIN or separate queries)
User.includes(:posts)
# preload (always uses separate queries)
User.preload(:posts)
# eager_load (always uses JOIN)
User.eager_load(:posts)
# Nested includes
User.includes(posts: [:comments, :tags])
# Conditional includes
User.includes(:posts).where(posts: { published: true }).references(:posts)
Prisma (Node.js)
// Include related data
const users = await prisma.user.findMany({
include: {
posts: true
}
});
// Nested include
const users = await prisma.user.findMany({
include: {
posts: {
include: {
comments: true
}
}
}
});
// Select specific fields
const users = await prisma.user.findMany({
include: {
posts: {
select: {
id: true,
title: true
}
}
}
});
TypeORM (Node.js)
// relations option
const users = await userRepository.find({
relations: ['posts', 'profile']
});
// Nested relations
const users = await userRepository.find({
relations: ['posts', 'posts.comments']
});
// Query builder with join
const users = await userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.leftJoinAndSelect('post.comments', 'comment')
.getMany();
Automated Detection
Add this to your test suite:
// Jest test
describe('N+1 Detection', () => {
it('should not have N+1 queries on dashboard', async () => {
let queryCount = 0;
// Hook into Sequelize queries
sequelize.addHook('beforeQuery', () => {
queryCount++;
});
// Make request
await request(app).get('/api/dashboard');
// Assert reasonable query count
expect(queryCount).toBeLessThan(10);
});
});
# Django test
from django.test import TestCase
from django.test.utils import override_settings
from django.db import connection
class N1TestCase(TestCase):
def test_dashboard_queries(self):
with self.assertNumQueries(5): # Expect exactly 5 queries
response = self.client.get('/dashboard/')
self.assertEqual(response.status_code, 200)
Monitoring in Production
// Track query count per endpoint
app.use((req, res, next) => {
const startQueries = sequelize.queryCount || 0;
res.on('finish', () => {
const queryCount = (sequelize.queryCount || 0) - startQueries;
// Log to metrics system
metrics.histogram('db.queries.per_request', queryCount, {
endpoint: req.path,
method: req.method
});
// Alert on high query count
if (queryCount > 50) {
logger.warn('High query count', {
endpoint: req.path,
queries: queryCount,
requestId: req.id
});
}
});
next();
});
Common Mistakes
Mistake 1: Over-eager Loading
// BAD - Loading too much
const users = await User.findAll({
include: [
{ model: Post, include: [Comment, Tag, Category] },
{ model: Profile, include: [Settings] },
{ model: Subscription, include: [Plan, Payment] }
]
});
// Huge JOIN with tons of duplicate data
Only load what you need.
Mistake 2: Forgetting Nested Relations
// BAD - Fixed one level but not nested
const users = await User.findAll({
include: [Post]
});
for (const user of users) {
for (const post of user.posts) {
post.comments = await Comment.findAll({ where: { postId: post.id } });
// Still N+1 at nested level!
}
}
Mistake 3: Ignoring Pagination
// BAD - Eager loading everything
const users = await User.findAll({
include: [{ model: Post, include: [Comment] }]
});
// Loads 1000 users * 10 posts * 20 comments = 200,000 rows
// GOOD - Paginate and limit
const users = await User.findAll({
limit: 20,
offset: page * 20,
include: [{
model: Post,
limit: 5, // Only recent 5 posts
separate: true
}]
});
Mistake 4: Not Using Indexes
// N+1 queries are slow even with eager loading if indexes are missing
// Add indexes on foreign keys
// Migration
await queryInterface.addIndex('posts', ['user_id']);
await queryInterface.addIndex('comments', ['post_id']);
My N+1 Prevention Checklist
Before deploying:
- [ ] Query logging enabled in development
- [ ] APM tool configured (New Relic, Datadog, etc.)
- [ ] Tests assert query counts
- [ ] Foreign key indexes exist
- [ ] Eager loading used for associations
- [ ] Nested N+1 queries checked
- [ ] GraphQL uses DataLoader
- [ ] Monitoring alerts on high query counts
The Bottom Line
N+1 queries are the #1 database performance killer. They're invisible, they scale horribly, and every ORM makes them easy to write.
Detect them: Enable query logging, count queries per request, use APM tools.
Fix them: Use eager loading, joins, or batch loading. One query is better than N queries.
Prevent them: Add tests that assert query counts. Monitor query counts in production.
The homepage that took 15 seconds went to 180ms by fixing N+1 queries. Database costs dropped by 80%. Users stopped complaining.
Check your codebase today. Count the queries on your busiest endpoints. Fix the N+1 queries killing your performance.