Skip to content

MySQL Tutorial Query Performance and Optimization

The N+1 Query Problem

Lesson 212 of 308 3 min read By Deepak Paswan Updated Oct 5, 2026

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. 1 query fetches a list of N items (for example, 50 posts).
  2. 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

query.sql
SQL
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)

index.php
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:

query.sql
SQL
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;
Output
OUTPUT
+----+------------------+--------+
| 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:

query.sql
SQL
SELECT id, name FROM authors WHERE id IN (1, 2, 3, 4) ORDER BY id;
Output
OUTPUT
+----+--------+
| id | name   |
+----+--------+
|  1 | Asha   |
|  2 | Ben    |
|  3 | Chitra |
|  4 | Dev    |
+----+--------+
4 rows in set

In PHP:

index.php
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:

query.sql
SQL
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;
Output
OUTPUT
+----+--------+-------+------------------------------------+
| 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

  1. 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.
  2. In the slow query log or sys.statement_analysis, look for a very cheap query with a very high exec_count:
query.sql
SQL
SELECT query, exec_count, avg_latency FROM sys.statement_analysis WHERE db = 'learn' ORDER BY exec_count DESC LIMIT 3;
Output
OUTPUT
+-------------------------------------------------------------------+------------+-------------+
| 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
  1. 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

ORMEager loading
Laravel EloquentPost::with('author')->get()
Doctrine (PHP)JOIN FETCH in DQL, or fetch="EAGER"
Djangoselect_related('author') / prefetch_related('tags')
RailsPost.includes(:author)
HibernateJOIN 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.

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).

lightbulb
Count the round trips
A page that runs 5 queries of 20 ms is fine. A page that runs 150 queries of 1 ms each is slower and gets worse as the data grows. Count queries, not only milliseconds.