Programming Advanced 13 min

How to Find and Fix Slow MySQL Queries with EXPLAIN

A query that takes 20ms in development can take 8 seconds in production once the table grows to a million rows. The gap between the two is almost always a missing index or an avoidable full table scan — problems that are invisible until you use EXPLAIN.

This guide walks through the complete workflow: finding the slow queries in the first place, reading EXPLAIN output without guessing, adding the right index, and catching the N+1 problem at the application layer before it ever reaches the database.

Step-by-step

  1. 1

    Identify the Slow Query First

    You cannot fix what you cannot find. Three places to look, in order of precision:

    • MySQL slow query log — the most reliable source. Enable it and set a threshold; MySQL writes every query that exceeds it.
    • Laravel Telescope / Debugbar — shows all queries for a request, their duration, and the call stack that triggered them. Invaluable in development.
    • DB::listen() in AppServiceProvider — log queries to a file in any environment, no extra package required.
    sql
    -- Enable slow query log (add to my.cnf or run at runtime)
    SET GLOBAL slow_query_log = 'ON';
    SET GLOBAL long_query_time = 1;          -- log queries over 1 second
    SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
    
    -- View the log
    -- tail -f /var/log/mysql/slow.log
    
    -- Or use mysqldumpslow to summarize it
    -- mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
  2. 2

    Listen to Queries in Laravel

    For development, register a query listener in AppServiceProvider::boot(). This logs every query with its bindings and execution time — no external tools needed. Remove or gate it with an APP_DEBUG check before deploying.

    php
    <?php
    
    // app/Providers/AppServiceProvider.php
    use Illuminate\Support\Facades\DB;
    use Illuminate\Support\Facades\Log;
    
    public function boot(): void
    {
        if (config('app.debug')) {
            DB::listen(function ($query) {
                if ($query->time > 100) { // only log queries over 100ms
                    Log::channel('daily')->warning('Slow query', [
                        'sql'      => $query->sql,
                        'bindings' => $query->bindings,
                        'time_ms'  => $query->time,
                    ]);
                }
            });
        }
    }
  3. 3

    Run EXPLAIN on the Slow Query

    Prepend EXPLAIN to the SELECT and read the output. The columns that matter most:

    • type — the join/access type. Worst to best: ALL (full scan) → indexrangerefeq_refconst. You want ref or better.
    • key — the index actually used. NULL means no index was used.
    • rows — the estimated number of rows MySQL will examine. Multiply this across all rows in the output to get the total work.
    • Extra — watch for Using filesort and Using temporary — both are expensive.
    sql
    EXPLAIN SELECT
        o.id,
        o.created_at,
        u.email
    FROM orders o
    JOIN users u ON u.id = o.user_id
    WHERE o.status = 'pending'
    ORDER BY o.created_at DESC
    LIMIT 20;
    
    -- Example bad output:
    -- +----+------+--------+------+------+------+-------------+
    -- | id | type | key    | ref  | rows | filt | Extra       |
    -- +----+------+--------+------+------+------+-------------+
    -- |  1 | ALL  | NULL   | NULL | 980k | 0.50 | Using where |
    -- +----+------+--------+------+------+------+-------------+
    -- type=ALL + key=NULL = full table scan on 980,000 rows
  4. 4

    Add the Missing Index

    The fix for a full table scan on a WHERE/ORDER BY is almost always an index. For the query above, a composite index on (status, created_at) covers both the filter and the sort in a single index scan. Column order matters: put the equality column first (status), then the range/sort column (created_at).

    sql
    -- Add a composite index that covers the WHERE and ORDER BY
    ALTER TABLE orders
        ADD INDEX idx_orders_status_created (status, created_at);
    
    -- Run EXPLAIN again to verify
    EXPLAIN SELECT o.id, o.created_at, u.email
    FROM orders o
    JOIN users u ON u.id = o.user_id
    WHERE o.status = 'pending'
    ORDER BY o.created_at DESC
    LIMIT 20;
    
    -- Expected improved output:
    -- +----+------+---------------------------+-------+------+
    -- | id | type | key                       | rows  | Extra|
    -- +----+------+---------------------------+-------+------+
    -- |  1 | ref  | idx_orders_status_created |  4200 |      |
    -- +----+------+---------------------------+-------+------+
  5. 5

    Stop Wrapping Indexed Columns in Functions

    Functions applied to a column in a WHERE clause prevent index usage entirely. MySQL cannot use an index on created_at if the condition is DATE(created_at) = '2024-01-15' — it has to compute DATE() for every single row.

    The fix is always to rewrite the condition as a range against the raw column value.

    sql
    -- Bad: function wraps the indexed column → full scan
    SELECT * FROM orders
    WHERE DATE(created_at) = '2024-01-15';
    
    -- Bad: same problem with YEAR/MONTH
    SELECT * FROM orders
    WHERE YEAR(created_at) = 2024 AND MONTH(created_at) = 1;
    
    -- Good: range query — MySQL can use the index
    SELECT * FROM orders
    WHERE created_at >= '2024-01-15 00:00:00'
      AND created_at <  '2024-01-16 00:00:00';
    
    -- Good: LOWER() on a column kills the index too
    -- Bad:  WHERE LOWER(email) = 'user@example.com'
    -- Good: store emails already lowercased, or use a case-insensitive collation
  6. 6

    Use EXPLAIN ANALYZE for Actual Timings

    MySQL 8.0+ supports EXPLAIN ANALYZE, which actually executes the query and reports real row counts and timings alongside the estimates. This is the most accurate tool for diagnosing slow queries — the estimates in plain EXPLAIN are sometimes off by orders of magnitude on skewed data.

    sql
    -- MySQL 8.0+ only — actually runs the query
    EXPLAIN ANALYZE
    SELECT o.id, u.email
    FROM orders o
    JOIN users u ON u.id = o.user_id
    WHERE o.status = 'pending'
    ORDER BY o.created_at DESC
    LIMIT 20;
    
    -- Output shows:
    -- -> Limit: 20 row(s)  (actual time=0.318..0.319 rows=20 loops=1)
    --   -> Index lookup on o using idx_orders_status_created
    --      (status='pending')  (cost=1820 rows=4200)
    --      (actual time=0.089..0.304 rows=20 loops=1)
  7. 7

    Fix N+1 Queries at the Application Layer

    The N+1 problem is a query-per-row pattern: you fetch 50 posts, then the loop fires 50 separate queries to get each post's author. It does not show up in a single slow query — it shows up as 51 fast queries that collectively take 2 seconds.

    In Eloquent the fix is with(). In plain SQL the fix is a JOIN. The rule: never query inside a loop.

    php
    <?php
    
    // Bad: N+1 — fires one query per post
    $posts = Post::all();
    foreach ($posts as $post) {
        echo $post->author->name; // SELECT * FROM users WHERE id = ? × N
    }
    
    // Good: eager load — 2 queries total regardless of N
    $posts = Post::with('author')->get();
    foreach ($posts as $post) {
        echo $post->author->name;
    }
    
    // Also good: nested eager loading
    $posts = Post::with(['author', 'comments.author'])->paginate(20);
    
    // Laravel Debugbar or Telescope will flag N+1s visually —
    // enable them in development and look for repeated identical queries.
  8. 8

    Benchmark with Realistic Data

    A query that is fast on 1,000 rows is not necessarily fast on 1,000,000. Always benchmark with realistic data volume before declaring a fix done. Use EXPLAIN before and after, capture actual query time with EXPLAIN ANALYZE or a timer, and keep both results so you can show the improvement.

    sql
    -- Benchmark: compare before and after
    -- 1. Record baseline
    SET @start = NOW(6);
    SELECT COUNT(*) FROM orders WHERE status = 'pending' ORDER BY created_at DESC;
    SELECT TIMESTAMPDIFF(MICROSECOND, @start, NOW(6)) / 1000 AS ms;
    
    -- 2. Add the index
    ALTER TABLE orders ADD INDEX idx_orders_status_created (status, created_at);
    
    -- 3. Run again and compare
    SET @start = NOW(6);
    SELECT COUNT(*) FROM orders WHERE status = 'pending' ORDER BY created_at DESC;
    SELECT TIMESTAMPDIFF(MICROSECOND, @start, NOW(6)) / 1000 AS ms;
    
    -- Also check SHOW STATUS LIKE 'Handler_read%' before and after
    -- to see how many rows were actually read by the storage engine.

Tips & gotchas

  • <code>EXPLAIN</code> shows the query plan before execution; <code>EXPLAIN ANALYZE</code> (MySQL 8+) shows actual execution — always prefer <code>ANALYZE</code> when you need the ground truth.
  • The <code>rows</code> column in EXPLAIN is an estimate. On tables with skewed data distributions it can be wildly inaccurate — run <code>ANALYZE TABLE orders</code> to refresh the statistics first.
  • Adding an index speeds reads but slows writes (INSERT/UPDATE/DELETE). On high-write tables, every index has a cost — only keep the ones that are actually used.
  • Use <code>SHOW INDEX FROM table_name</code> to see all existing indexes and their cardinality before adding a duplicate.
  • The MySQL <code>performance_schema</code> and <code>sys</code> schema contain views like <code>sys.statements_with_full_table_scans</code> that surface slow patterns across all traffic, not just your current session.

Wrapping up

Slow queries are always diagnosable — EXPLAIN tells you exactly what the optimizer is doing and why. Start by finding the query, understand the access type, add the minimum index that covers the WHERE and ORDER BY, then verify with real data. The N+1 problem is the other half of database performance; fix it at the Eloquent layer before it multiplies.

#MySQL #Performance #EXPLAIN
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.