The N+1 problem is the most common performance bug in database applications, and it hides well, because every single query is fast.
The pattern:
- 1 query fetches a list of N items (for example, 50 posts).
- Then the code runs 1 more query per item to fetch something related (the author of each post).
That is 1 + N queries, 51 round trips for one page.
Build the example
CREATE TABLE authors (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(30) NOT NULL);
CREATE TABLE posts (id INT AUTO_INCREMENT PRIMARY KEY, author_id INT NOT NULL, title VARCHAR(50) NOT NULL, published_at DATETIME NOT NULL, KEY idx_author (author_id));
INSERT INTO authors (name) VALUES ('Asha'), ('Ben'), ('Chitra'), ('Dev');
INSERT INTO posts (author_id, title, published_at) VALUES
(1, 'Intro to SQL', '2026-07-01 09:00:00'), (2, 'Joins explained', '2026-07-02 09:00:00'), (1, 'Indexes in depth', '2026-07-03 09:00:00'),
(3, 'Backups', '2026-07-04 09:00:00'), (4, 'Transactions', '2026-07-05 09:00:00'), (2, 'Window functions', '2026-07-06 09:00:00');
The N+1 code (PHP)
<?php
// 1 query: the list
$posts = $pdo->query('SELECT id, author_id, title FROM posts ORDER BY published_at DESC LIMIT 50')->fetchAll();
foreach ($posts as $post) {
// N queries: one per post
$stmt = $pdo->prepare('SELECT name FROM authors WHERE id = ?');
$stmt->execute([$post['author_id']]);
$author = $stmt->fetchColumn();
echo $post['title'], ' by ', $author, "\n";
}
With 50 posts, this runs 51 queries. Each takes 0.3 ms on the server, but each also has a network round trip of 0.5 to 2 ms (more on a managed database in another zone). The page spends most of its time waiting. At 500 posts it is 501 queries.
Fix 1: a JOIN
Ask the database for everything in one query:
SELECT p.id, p.title, a.name AS author
FROM posts p
JOIN authors a ON a.id = p.author_id
ORDER BY p.published_at DESC
LIMIT 50;
+----+------------------+--------+
| id | title | author |
+----+------------------+--------+
| 6 | Window functions | Ben |
| 5 | Transactions | Dev |
| 4 | Backups | Chitra |
| 3 | Indexes in depth | Asha |
| 2 | Joins explained | Ben |
| 1 | Intro to SQL | Asha |
+----+------------------+--------+
6 rows in set
One query, one round trip. This is the first choice when you need columns of the related row.
Fix 2: batch with IN (eager loading)
When the related data has its own structure (many-to-many, many rows per parent), run one extra query for all parents at once, then combine in code:
SELECT id, name FROM authors WHERE id IN (1, 2, 3, 4) ORDER BY id;
+----+--------+
| id | name |
+----+--------+
| 1 | Asha |
| 2 | Ben |
| 3 | Chitra |
| 4 | Dev |
+----+--------+
4 rows in set
In PHP:
<?php
$posts = $pdo->query('SELECT id, author_id, title FROM posts ORDER BY published_at DESC LIMIT 50')->fetchAll();
$ids = array_values(array_unique(array_column($posts, 'author_id')));
$marks = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT id, name FROM authors WHERE id IN ($marks)");
$stmt->execute($ids);
$authors = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); // [id => name]
foreach ($posts as $post) {
echo $post['title'], ' by ', $authors[$post['author_id']] ?? '?', "\n";
}
Two queries regardless of N. The placeholders are generated from the count of ids, and the values are bound as parameters, so it is safe (SQL Injection). This pattern is called eager loading in ORMs (Laravel with(), Doctrine joins, Rails includes).
Fix 3: one-to-many with JSON or GROUP_CONCAT
For “each author with their post titles”, one query can return the list per author:
SELECT a.id, a.name, COUNT(p.id) AS posts, GROUP_CONCAT(p.title ORDER BY p.published_at SEPARATOR ' | ') AS titles
FROM authors a
LEFT JOIN posts p ON p.author_id = a.id
GROUP BY a.id, a.name
ORDER BY a.id;
+----+--------+-------+------------------------------------+
| id | name | posts | titles |
+----+--------+-------+------------------------------------+
| 1 | Asha | 2 | Intro to SQL | Indexes in depth |
| 2 | Ben | 2 | Joins explained | Window functions |
| 3 | Chitra | 1 | Backups |
| 4 | Dev | 1 | Transactions |
+----+--------+-------+------------------------------------+
4 rows in set
How to find it
- Count the queries per request. Frameworks have a debug bar (Laravel Debugbar, Symfony profiler, Query Monitor for WordPress). 80 queries on one page is a signal.
- In the slow query log or
sys.statement_analysis, look for a very cheap query with a very highexec_count:
SELECT query, exec_count, avg_latency FROM sys.statement_analysis WHERE db = 'learn' ORDER BY exec_count DESC LIMIT 3;
+-------------------------------------------------------------------+------------+-------------+
| query | exec_count | avg_latency |
+-------------------------------------------------------------------+------------+-------------+
| SELECT ? | 10 | 119.40 us |
| EXPLAIN ANALYZE SELECT * FROM ... R BY `created_at` DESC LIMIT ? | 3 | 21.83 ms |
| CALL `sys` . `ps_truncate_all_tables` ( FALSE ) | 1 | 187.12 ms |
+-------------------------------------------------------------------+------------+-------------+
3 rows in set
- Log the SQL of one page in development and look for repetition.
Why it is so common
- ORMs hide it:
foreach ($posts as $post) { $post->author->name }looks innocent, and loads the author lazily on each access. - It is invisible on a small local database, where round trips take microseconds.
- Each query is fast, so no single query shows up as “slow”.
Fix it in an ORM
| ORM | Eager loading |
|---|---|
| Laravel Eloquent | Post::with('author')->get() |
| Doctrine (PHP) | JOIN FETCH in DQL, or fetch="EAGER" |
| Django | select_related('author') / prefetch_related('tags') |
| Rails | Post.includes(:author) |
| Hibernate | JOIN FETCH |
When N+1 is acceptable
When N is small and bounded (3 tabs on a page), or when you really need lazy loading for a rare branch, the cost is small. The problem is when N grows with the data.
A related trap: the query in a loop
The same pattern appears with inserts and updates: a foreach with one INSERT per row. Batch them into multi-row inserts and transactions (Inserting Multiple Rows).