Hand-drawn bar chart: Where MySQL's day actually goes. Orders lookup 40ms × 52k/day (~35 min/day); Cart recalc 8ms × 180k/day (~24 min/day); Report export 1.8s × 300/day (~9 min/day); User dashboard 120ms × 3.2k/day (~6 min/day); Nightly reconcile 14s × 12/day (~3 min/day).

How to find and fix MySQL performance bottlenecks in production

A top-down triage runbook for backend software engineers: prove it is the database, rank offenders by total time, read the EXPLAIN ANALYZE plan, fix the query pattern and the index, and only then touch my.cnf.

The triage runbook for MySQL performance bottlenecks#

This guide is written for the backend software engineer or tech lead who owns a slow route in production. Your day starts with a high p95 on one endpoint, not with a config file. Most guides on MySQL performance bottlenecks open at the bottom of the stack, on innodb_buffer_pool_size and a list of server variables. Therefore they answer a question you have not reached yet.

Instead, treat this as a triage runbook. First you diagnose, then you fix, and the order matters because each step rules out a whole class of cause. The flow below is the whole method on one page. Read it top to bottom, and notice that my.cnf sits at the very end.

The five-step MySQL performance triage flowStart at a slow route with a high p95. Prove the database is dominant, rank offenders by total time, read the plan, fix the query pattern or index or schema, and reach my.cnf only if the p95 is still out of budget.

Each branch below is one step. Moreover, the steps run cheapest-diagnosis first. You only move down when the current step confirms the database is genuinely at fault. Because the method is subtractive, you avoid the common trap of tuning a config knob that was never the problem.

Step 1: Prove it is the database (not the app, N+1, or the network)#

Before you optimize a single query, prove that MySQL is where the time goes. A slow route has many suspects. The router and middleware, the ORM hydrating objects, the network, and the SQL itself all add latency. Therefore attributing the p95 across those layers is the first move, and it is the layer most guides skip entirely.

The waterfall below splits one route's p95 into proportional spans. Toggle between a healthy trace and the slow trace. Watch the SQL-execution span balloon until it dominates the request. That domination is your evidence that the database, not the app, owns the cost.

Request-latency waterfall: healthy trace versus slow trace

p95 latency for this route1,400 ms

Slow trace (p95): where the 1,400 ms goes
LayerTimeShare of p95
Router + middleware20 ms1.4%
Connection acquisition12 ms0.9%
Eloquent hydration48 ms3.4%
SQL executionhotspot1,320 ms94%
Toggle the trace to re-attribute the route's p95 across its layers. In the slow trace the SQL-execution span dominates, which is the proof that the bottleneck is the database. The table is the accessible source of truth; figures are illustrative.

When the SQL span dominates like that, you have earned the right to blame MySQL. However, if the router or hydration span were the tall one, your fix would live in the application, not the database. First prove it, then fix it.

Is the cost the ORM hydration or the SQL itself?#

A slow Eloquent call has two costs bundled together. There is the query MySQL runs, and there is the work of turning rows into model objects. These are different problems with different fixes. Therefore measure them separately before you assume the database is slow.

app/Providers/AppServiceProvider.php · php
// Separate SQL execution time from Eloquent hydration time.
// DB::listen reports the pure query duration for every statement.
DB::listen(function ($query) {
    Log::info('sql', [
        'ms'  => $query->time,   // execution time only, no hydration
        'sql' => $query->sql,
    ]);
});

// Wrap the call to see the object-building cost on top of the query.
$start   = microtime(true);
$orders  = Order::where('customer_id', 42)->get();  // query + hydrate
$totalMs = (microtime(true) - $start) * 1000;

// If $totalMs is far larger than the summed $query->time values,
// the cost is hydration, not the database.

Because DB::listen reports pure execution time, the gap between it and the wall clock is your hydration cost. When that gap is large, the query is fine and the fix is to select fewer columns or hydrate fewer models. In contrast, when the query time itself is large, you move on to Step 2.

Rule out the network and the connection pool first#

Step 2: Rank offenders by TOTAL time, not the single slowest#

Here is the counterintuitive number that reorders every triage. The slowest single query is rarely your biggest MySQL performance bottleneck. A query that runs in 1.8 seconds but fires three times a day costs you about five seconds daily. A query that runs in 40 milliseconds but fires 50,000 times a day costs you over half an hour. Therefore rank by total time, not by the worst single run.

The slow-once query versus the fast-often query~370x more total time

~5 sec/day

1.8 s query, run 3x per day

Real cost

~33 min/day

40 ms query, run 50,000x per day

The fast query nobody flags is the real cost. Ranking by total time, not by the single slowest run, is what moves the frequent 40 ms query to the top of your list. These figures are illustrative, order-of-magnitude values that show the ratio, not a published benchmark.

The slow-once query versus the fast-often query (total MySQL time per day (illustrative))
Optiontotal MySQL time per day (illustrative)
1.8 s query, run 3x per day~5 sec/day
40 ms query, run 50,000x per day~33 min/day

Because the fast-often query hides in plain sight, you need MySQL's own accounting to surface it. Two tools give you that ranking directly. First, the performance schema and the sys schema. Second, pt-query-digest over the slow query log.

Ask MySQL's own books: performance_schema and the sys schema#

MySQL already tracks every statement digest and its summed latency. Therefore you do not need to guess. Query events_statements_summary_by_digest ordered by total wait, and the top rows are your real offenders.

rank-by-total-latency.sql · sql
-- Rank statements by TOTAL latency, not the single slowest run.
SELECT
  DIGEST_TEXT                       AS query,
  COUNT_STAR                        AS calls,
  ROUND(SUM_TIMER_WAIT / 1e12, 1)   AS total_seconds,
  ROUND(AVG_TIMER_WAIT / 1e9,  1)   AS avg_ms
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC        -- total time is the number that matters
LIMIT 10;

-- The sys schema wraps the same data in a readable view.
SELECT * FROM sys.statement_analysis
ORDER BY total_latency DESC
LIMIT 10;

The SUM_TIMER_WAIT column is the one that matters, because it is total time across every call. Meanwhile the average column tells you the per-run cost. Read them together and the frequent-fast query rises to the top where it belongs.

pt-query-digest on the slow query log#

When you want a shareable report, Percona's pt-query-digest aggregates the slow query log and ranks by total time consumed. First set long_query_time to zero for a capture window. Then digest the log.

capture-and-digest.sh · bash
# Turn the slow log on and capture everything for a window.
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0;   # log all statements, then digest

# Aggregate the log and rank queries by total time consumed.
pt-query-digest /var/log/mysql/slow.log > digest.txt

# Read the top of the "Profile" section. Rank 1 is the query eating
# the most TOTAL time across the window, which is rarely the
# slowest single run.

The report's profile section ranks queries by their share of total response time. Consequently rank one is almost never the slowest single statement. It is the query whose frequency times its per-run cost adds up to the most wall-clock time, which is exactly the offender Step 2 exists to find. Percona documents the report format in the pt-query-digest reference.

The real ranking of your top offenders#

Put five representative queries side by side by their total daily time and the priority order changes shape. The chart below ranks by aggregate time, not by per-run duration. Notice where the 14-second nightly job lands.

Show data table
Top five queries by total MySQL time per day (illustrative)
Item Total time per day
Orders lookup (40 ms x 52k) 2,080 sec/day
Cart recalc (8 ms x 180k) 1,440 sec/day
Report export (1.8 s x 300) 540 sec/day
User dashboard (120 ms x 3.2k) 384 sec/day
Nightly reconcile (14 s x 12) 168 sec/day

The 40 ms orders lookup, dismissed as fast, is the number-one cost. The 14-second nightly job, the one that feels slow, ranks last. Ranking by total time is what surfaces the query worth fixing first.

Figure Top five queries by total MySQL time per day (illustrative) Worked example in the shape a pt-query-digest report returns. Modelled, not measured.

Step 3: Read the EXPLAIN ANALYZE plan properly#

Once you have the offending query, run EXPLAIN ANALYZE and read the plan node by node. This is the step most software engineers rush. However, the plan tells you exactly why the query is slow, and it tells you whether your fix worked. Therefore learn to read it properly.

The inspector below shows a real-shaped plan for one query. Toggle between the pre-index and post-index plan. Then step through each node to see its estimated rows, its actual rows, and its actual time. Watch the full table scan become an index range scan and the filesort disappear.

EXPLAIN ANALYZE plan reader: before and after the index
  1. Limit: 20 row(s)

    est rows
    20
    actual rows
    20
    loops
    1
    actual time
    982 ms

    The LIMIT only trims the final 20 rows. Everything expensive already ran underneath it, so the cheap-looking top of the plan hides the cost.

  2. Sort: orders.created_at DESC (filesort)

    est rows
    1
    actual rows
    3,842
    loops
    1
    actual time
    980 ms

    A filesort over 3,842 matched rows. No index provides this order, so MySQL sorts on the fly. The estimate of 1 row is wildly off, a classic bad-plan tell.

  3. Filter: (orders.status = 'shipped')

    est rows
    120,000
    actual rows
    3,842
    loops
    1
    actual time
    900 ms

    The status filter runs after rows are read, not to avoid reading them. Estimate and actual are far apart, so the optimizer is guessing.

  4. Table scan on ordershotspot

    est rows
    1,200,000
    actual rows
    1,204,318
    loops
    1
    actual time
    870 ms

    A full table scan. Every one of 1.2 million rows is read because no index covers customer_id. This is where the 1.3 seconds goes.

Before index, node 1 of 4: Limit: 20 row(s)

Toggle the plan and step node by node. Each node shows estimated versus actual rows, loops, and actual time. The before plan hides a full table scan and a filesort; the after plan resolves both. Figures are illustrative.

Estimated rows versus actual rows: the tell#

The single most useful signal in a plan is the gap between estimated and actual rows. When the optimizer estimates one row and the node returns thousands, its whole plan is built on a bad guess. Therefore a wide estimate-to-actual gap points you straight at the missing statistics or the missing index. In practice, a healthy plan shows the two numbers close together, as the after-index node does above.

Step 4: Fix the query pattern, the index, the schema#

Now you fix the thing you proved is at fault. Most MySQL performance bottlenecks resolve at one of three layers. First the ORM query pattern. Second a missing or wrong index. Third the schema shape itself. Work them in that order, because a pattern fix is cheaper than an index and an index is cheaper than a migration.

The N+1 query: one route, thousands of round trips#

The most common ORM pattern bug is the N+1 query. Your code loads a list, then touches a relation inside a loop, and the ORM fires one extra query per row. At a few rows nobody notices. At a few thousand rows the route falls over. Compare the trap and the fix below.

lazy-load.php · php
// One query for the orders...
$orders = Order::where('status', 'shipped')->get();

foreach ($orders as $order) {
    // ...then ONE more query per order for its customer.
    echo $order->customer->name;   // lazy load: 1 + N queries
}

// 1 orders query plus N customer queries.
// At 3,842 orders that is 3,843 round trips to MySQL.

Because eager loading batches the relation into a single WHERE id IN (...) query, the N round trips collapse to one. This is the same shape of bug that appears when structuring a WordPress backend at scale, where meta-query joins fan out over one fat table. In both cases the fix is to stop asking the database the same question thousands of times.

Composite and covering indexes: column order is everything#

When the pattern is fine but the query still scans the table, you need the right index. A composite index serves equality, then range, then order, in the column sequence you define. Therefore column order is not cosmetic. The diagram below walks how one composite index answers a real query.

Anatomy of a composite and covering indexA composite index on (customer_id, status, created_at) seeks by equality on the first two columns, provides the ORDER BY from the third, and becomes a covering index when it also holds every selected column, removing the row lookup entirely.

Notice the covering-index branch. When the index holds every column the query selects, MySQL answers from the index alone and never touches the table rows. Consequently a well-shaped covering index turns a slow scan into a fast seek. The order of columns must match how the query filters and sorts, or the index sits unused.

Before and after the index#

Here is the payoff: the same query, measured before and after the composite index lands. The plan you read in Step 3 predicted exactly this result.

One query, before and after the composite index~650x faster

~1,300 ms

Before the index (full table scan + filesort)

After

~2 ms

After the composite index (range scan)

The index removes the full table scan and the filesort you saw in the plan. These are illustrative figures for the plan shown above, not a published benchmark; the point is the order-of-magnitude change an index buys when the plan told you it would.

One query, before and after the composite index (query execution time (illustrative))
Optionquery execution time (illustrative)
Before the index (full table scan + filesort)~1,300 ms
After the composite index (range scan)~2 ms

Source: MySQL EXPLAIN and EXPLAIN ANALYZE output (reference)

Step 5: Only now tune my.cnf and the hardware#

If, and only if, the query patterns and indexes are sound and the p95 is still out of budget, you reach for the server config. By this point you have ruled out the causes that config cannot fix. Therefore the knobs below can finally help, because the workload underneath them is already lean. The table lists the handful that matter and the risk of cargo-culting each one.

The my.cnf knobs worth touching, and how each one backfires
KnobWhat it controlsSensible starting pointWhen it actually helpsRisk if you cargo-cult it
innodb_buffer_pool_sizeHow much data and index MySQL caches in RAM50 to 70 percent of a dedicated server's RAMThe working set does not fit in memory and disk reads dominateSet too high, the OS swaps and everything slows down
innodb_log_file_sizeRedo log capacity for writesLarge enough for several minutes of write trafficWrite-heavy workloads stalling on frequent checkpointsOver-large logs slow crash recovery
max_connectionsConcurrent client connections allowedMatch your pool size, not a big round numberConnections are being refused under real loadSet huge, each idle connection still costs memory
tmp_table_sizeIn-memory temporary table ceilingRaise only after you see on-disk temp tablesGROUP BY or DISTINCT is spilling to diskLarge values times many connections exhaust RAM
query_cache_typeLegacy result cacheLeave it off; it is removed in MySQL 8.0Almost never, since it no longer exists in 8.0Enabling it on old versions serializes writes

Because these knobs shape how the server runs a workload, they cannot rescue a workload that is doing too much work. In short, tuning my.cnf before Step 4 is optimizing the wrong layer. The reference values live in the MySQL server system variables documentation, and every server deserves values fitted to its own RAM and workload.

When this triage runbook is the wrong tool#

Where to go next#

Diagnosing MySQL performance bottlenecks is one skill in keeping a backend fast under load. If you are shaping a busy backend, the same top-down instinct applies to structuring a WordPress backend at scale and to choosing a rendering strategy on Cloudflare Workers. For the broader discipline, see how we approach performance and Core Web Vitals. When you want a second set of hands on a slow production database, you can hire Laravel developers from our team.

Talk to us about a performance audit

Margi Varmora

Senior Software Engineer, Atyantik Technologies

Margi Varmora is a Senior Software Engineer at Atyantik Technologies, a software product studio building web platforms and integrated systems since 2015. She works on the parts of a system that have to stay fast under load: the domain model, the database, and the background jobs that do the heavy work out of the request path.

More from Margi VarmoraPerformance and Core Web VitalsHire Laravel developers

Keep reading