Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

MySQL Query Optimization for Slow Queries

By Kokil Thapa | Last reviewed: September 2026

A page that worked at launch can crawl once order volume grows. MySQL query optimization for slow queries is the disciplined process of finding statements that exceed your latency budget, reading their execution plans, and fixing root causes—usually missing indexes, bad joins, or ORM patterns that scan millions of rows. On production Laravel and WooCommerce systems I maintain, slow queries rarely announce themselves until cron jobs overlap peak traffic. This guide walks through the same workflow I use: measure first, explain second, change schema or SQL third, tune the server last. For broader context, see our guide on optimizing MySQL queries for high-traffic applications.

How do you find slow MySQL queries in production?

You cannot optimize what you do not measure. Start with data, not assumptions.

MySQL 9.7 and the widely deployed 8.4 LTS line both expose slow-query instrumentation through the slow query log and Performance Schema. On Ubuntu servers I administer, I enable the slow log with a threshold tied to real user experience—not the default ten seconds.

Enable the slow query log

Add these settings to my.cnf or a drop-in under /etc/mysql/mysql.conf.d/:

[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 0

A long_query_time of one second catches admin reports and checkout flows that feel broken. Tighten it to 0.5 during investigation windows. Set log_queries_not_using_indexes to 0 in production unless you enjoy log floods.

Reload MySQL after config changes:

sudo systemctl reload mysql

Use Performance Schema for live analysis

The slow log is historical. Performance Schema shows what is slow right now. This query surfaces the worst offenders by total wait time:

SELECT
  DIGEST_TEXT AS query_pattern,
  COUNT_STAR AS exec_count,
  ROUND(AVG_TIMER_WAIT / 1e12, 3) AS avg_sec,
  ROUND(SUM_TIMER_WAIT / 1e12, 2) AS total_sec
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 20;

Official reference: MySQL Performance Schema statement summary tables.

Slow Query Diagnosis WorkflowApp LayerLaravel / WPSlow Loglong_query_timeEXPLAINAnalyze planFixIndex / SQLCommon SignalsFull table scanFilesort + tempN+1 from ORMVerify fix with EXPLAIN ANALYZE before deploy
MySQL query optimization for slow queries follows a measure → explain → fix loop tied to application latency budgets.

Laravel teams should also enable query logging in staging and read Laravel N+1 query detection and fixes. A single controller action can fire fifty identical lookups. That pattern never appears as one slow row in the slow log.

Application-level APM and tools like Laravel Debugbar complement server logs. They show which Eloquent relationship triggered the scan. On a booking portal I built with Livewire, the culprit was an unbounded with() on a date-range filter—not the SQL text itself.

Parse slow log output with pt-query-digest

Percona Toolkit's pt-query-digest groups similar statements and ranks them by total impact:

pt-query-digest /var/log/mysql/slow.log > /tmp/digest.txt

Focus on queries with high Query_time sums, not isolated spikes. One slow admin export matters less than a checkout lookup running ten thousand times per hour.

How does EXPLAIN help with MySQL query optimization for slow queries?

EXPLAIN is the X-ray. It shows which indexes MySQL chose and where it gave up.

Run it on the exact SQL your application sends—including bound parameters in a realistic test dataset. Plans change with row counts.

Read the columns that matter

Prioritize these EXPLAIN output fields:

  • typeALL means full scan; aim for ref, range, or const.
  • key — which index was used; NULL is a red flag on large tables.
  • rows — estimated rows examined; compare before and after fixes.
  • Extra — watch for Using filesort, Using temporary, Using where.

On MySQL 8.4+ and 9.7, prefer EXPLAIN ANALYZE for actual timings:

EXPLAIN ANALYZE
SELECT o.id, o.total, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending'
  AND o.created_at >= '2026-01-01'
ORDER BY o.created_at DESC
LIMIT 50;

Official syntax reference: MySQL EXPLAIN statement documentation.

EXPLAIN: Good vs Bad Access PathsIndex Range Scantype: range | key: idx_status_daterows: ~500 examinedExtra: Using index conditionFast: seeks index, filters rowsFull Table Scantype: ALL | key: NULLrows: 2.4M examinedExtra: Using filesortSlow: reads every row on disk
EXPLAIN type and key columns reveal whether MySQL query optimization for slow queries needs an index or a rewrite.

Cross-check plans after schema changes. I've seen deployments where a new composite index helped one report but forced a worse plan on nightly batch jobs. Always EXPLAIN both paths.

For deep index theory, read our MySQL index design deep dive. For ORM-specific patterns, see Laravel Eloquent advanced query patterns for large datasets.

What indexes fix the most common slow MySQL query patterns?

Indexes are not free. Each one slows writes and consumes disk. Add them with intent.

Match indexes to your WHERE, JOIN, and ORDER BY columns—in that selectivity order. A composite index on (status, created_at) supports filtering by status and sorting by date within that subset.

Composite index column order

Put the most selective leading column first when equality filters precede ranges:

CREATE INDEX idx_orders_status_created
ON orders (status, created_at);

This index serves WHERE status = 'pending' ORDER BY created_at DESC. It does not help a query that filters only on created_at without status. That requires a separate index or a query rewrite.

Covering indexes for hot reads

A covering index includes all columns the SELECT needs. MySQL reads only the index tree—no table lookup:

CREATE INDEX idx_orders_list_cover
ON orders (status, created_at, id, total);

Use covering indexes sparingly on wide tables. They bloat quickly. On read-heavy catalog pages in WooCommerce 11.1 stores, a slim covering index on (post_status, menu_order, ID) often beats caching stale product lists.

Foreign keys and join columns

Every JOIN column on the driving side needs an index. Missing join indexes are the fastest way to turn a 50 ms query into a 30-second one:

CREATE INDEX idx_order_items_order_id ON order_items (order_id);

Our Laravel query optimization for 1M-row tables article walks through real Eloquent equivalents of these patterns.

Composite Index Column Orderidx_orders_status_created (status, created_at, id)1. statusequality filter2. created_atrange + sort3. idcovering PKServed by indexWHERE status = 'pending'ORDER BY created_at DESCNot servedWHERE created_at > '2026-01-01'(status not in predicate)
Leftmost prefix rules govern which filters benefit from a composite index during MySQL query optimization for slow queries.

Index maintenance checklist

  1. Run SHOW INDEX FROM table_name before adding duplicates.
  2. Drop unused indexes found via Performance Schema or sys.schema_unused_indexes.
  3. Rebuild stats after large imports: ANALYZE TABLE orders;
  4. Test write throughput on hot insert tables before adding wide composites.

SaaS workloads with tenant isolation often need leading tenant_id columns. Our MySQL optimization for SaaS indexing and partitioning covers multi-tenant edge cases.

When should you rewrite a query instead of adding another index?

Indexes have diminishing returns. Sometimes the SQL or schema shape is wrong.

Replace SELECT * with explicit columns

Wide rows force more I/O and prevent covering indexes from helping. Select only what the view or API response needs:

SELECT id, total, status, created_at
FROM orders
WHERE customer_id = 8842
ORDER BY created_at DESC
LIMIT 20;

Fix OR and function-wrapped columns

WHERE YEAR(created_at) = 2026 prevents index use on created_at. Rewrite to a range:

WHERE created_at >= '2026-01-01'
  AND created_at < '2027-01-01'

OR conditions across different columns often need UNION ALL instead of one mega-index:

SELECT id, total FROM orders WHERE status = 'pending'
UNION ALL
SELECT id, total FROM orders WHERE status = 'processing' AND priority = 1;

Eliminate N+1 and pagination offsets

Offset pagination degrades linearly. LIMIT 100000, 50 scans and discards 100,000 rows:

SELECT id, created_at, total
FROM orders
WHERE created_at < '2026-03-15 14:22:00'
ORDER BY created_at DESC
LIMIT 50;

Keyset pagination uses the last seen value as a cursor. It stays fast as tables grow. Pair it with Redis 8.10 caching for frequently accessed first pages—see database query caching strategies.

SymptomLikely causeFirst fixRewrite instead?
type: ALL on large tableMissing or wrong indexComposite index on filter columnsIf OR spans columns, use UNION
Using filesort on millions of rowsORDER BY not in indexAdd sort column to index tailKeyset pagination if offset is huge
Rows examined ≫ rows returnedLow selectivity or function on columnRewrite predicate to sargable formYes—functions block indexes
Many small identical queriesORM N+1Eager load or join in applicationYes—index cannot batch ORM loops
Sudden slowdown after migrationStale statistics or charset changeANALYZE TABLE; check collationsCheck join type changes in EXPLAIN

On the Quick And Easy Nepalese Grocery Laravel store, delivery-zone lookups slowed after catalog growth. Rewriting a subquery to a JOIN with a zone index cut admin load time more than adding a third redundant index.

How do you tune MySQL server settings for slow workloads?

Server tuning comes after query and index fixes. Tuning a bad query with more RAM only delays the pain.

Still, buffer pool and connection settings matter on busy hosts. I follow patterns from our MySQL performance tuning for web applications guide and validate on staging clones.

InnoDB buffer pool

Set innodb_buffer_pool_size to roughly 60–70% of dedicated DB RAM on a single-purpose server. On a 8 GB database VM, start near 5 GB:

innodb_buffer_pool_size = 5G
innodb_buffer_pool_instances = 4

Watch buffer pool hit rate:

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';

If Innodb_buffer_pool_reads stays high relative to requests, you need more RAM or fewer cold scans—not arbitrary query cache hacks.

Join and sort buffers

Raise these modestly when EXPLAIN shows large joins or sorts—but only after indexing:

join_buffer_size = 256K
sort_buffer_size = 512K
read_rnd_buffer_size = 256K

Oversized sort buffers per connection multiply memory use under concurrency. A site with 200 PHP-FPM workers can exhaust RAM quickly.

Connection and timeout hygiene

  • Set max_connections above peak pool size with headroom—not unlimited.
  • Use wait_timeout to drop idle connections from misconfigured apps.
  • Enable slow_query_log rotation so disks do not fill during incidents.
  • Schedule mysqlcheck and verify backups via MySQL binary logs for replication and backup.
Optimization Priority PyramidServer tuning (buffer pool)Caching layer (Redis)Indexes + schemaQuery rewrite + EXPLAINHighest impact at the top — start with SQL and plans
MySQL query optimization for slow queries delivers the biggest wins at the SQL and index layer before hardware or cache spending.

If you run MariaDB 12.3 instead of Oracle MySQL, most EXPLAIN and index advice transfers—but verify optimizer differences in our MariaDB vs MySQL comparison for 2026. Considering a move to PostgreSQL 18? Read PostgreSQL vs MySQL for production before optimizing a database you plan to leave.

Fresh installs benefit from a clean baseline—follow install MySQL on Ubuntu before layering production tuning. WordPress-heavy stacks should also audit plugin queries via WordPress database optimization for large sites.

Key Takeaways

  • Enable the slow query log and Performance Schema before changing schema—measure total wait time, not single spikes.
  • Run EXPLAIN ANALYZE on exact production SQL; fix type: ALL and Using filesort before raising buffer pool size.
  • Build composite indexes with equality columns first, then range and ORDER BY columns; drop unused indexes that slow writes.
  • Rewrite non-sargable predicates, OFFSET pagination, and ORM N+1 loops—indexes alone cannot fix application query shapes.
  • Validate every change on a staging clone with realistic row counts; plans flip when tables cross million-row thresholds.
  • Pair database work with app caching and professional testing and optimization services when slowdowns block revenue paths.

People Also Ask

What is a good long_query_time threshold?

One second is a practical production default for user-facing web apps. Drop to 0.2–0.5 seconds during active investigations. Batch ETL jobs may legitimately run longer—tag or route them to separate instances so they do not drown OLTP signals.

Does adding more indexes always speed up MySQL?

No. Each index slows INSERT, UPDATE, and DELETE operations and consumes disk. Duplicate or unused indexes confuse the optimizer and increase write amplification. Audit with Performance Schema and remove indexes that never appear in EXPLAIN output.

How do Laravel and WordPress developers find slow queries?

Laravel: enable query logging, use Debugbar in staging, and fix N+1 with eager loading. WordPress: Query Monitor plugin surfaces duplicate and slow queries per page load. Both still need MySQL EXPLAIN on the underlying SQL—not just application timers.

When should you upgrade hardware instead of optimizing queries?

Upgrade when buffer pool hit rate stays poor after index and rewrite work, disk I/O saturates on legitimate indexed reads, or replication lag persists despite clean SQL. Throwing RAM at full scans buys weeks, not years.

Ship faster queries with a clear optimization plan

MySQL query optimization for slow queries is repeatable engineering—not guesswork. Log slow statements, EXPLAIN the top offenders by total time, fix indexes and SQL shapes, then tune InnoDB and caching. That sequence has stabilized checkout flows, booking calendars, and legal-tech search on systems I operate under real Nepal and international traffic.

If your team lacks time to profile production safely, start with a focused audit through our speed optimization service or broader web development support. For JSON-heavy debugging of API payloads during query refactors, the JSON formatter tool on this site saves friction. Need hands-on help? Contact us with your slow query log excerpt and table sizes—we will map a fix path before anyone adds a random index.

Frequently Asked Questions

It is the process of finding SQL statements that exceed your latency budget, reading their execution plans, and fixing root causes—usually missing indexes, bad joins, or ORM patterns that scan millions of rows.

You cannot optimize what you do not measure. Enable the slow query log in my.cnf with slow_query_log = 1, set long_query_time to one second for user-facing apps, and reload MySQL with systemctl reload mysql. For live analysis on MySQL 9.7 or the widely deployed 8.4 LTS line, query Performance Schema events_statements_summary_by_digest ordered by total wait time. Parse historical logs with Percona Toolkit pt-query-digest, which groups similar statements and ranks by total impact—not isolated spikes. Laravel teams should also use staging query logging, Debugbar, and APM tools, because ORM N+1 loops never appear as one slow row in the server log.

One second is a practical default for user-facing web apps. Drop to 0.2–0.5 seconds during active investigations. Route long batch ETL jobs to separate instances so they do not drown OLTP signals.

EXPLAIN is the X-ray—it shows which indexes MySQL chose and where it gave up. Run it on the exact SQL your application sends, using realistic test data because plans change with row counts. Prioritize type (ALL means full scan; aim for ref, range, or const), key (NULL on large tables is a red flag), rows examined, and Extra (watch for Using filesort, Using temporary, Using where). On MySQL 8.4+ and 9.7, prefer EXPLAIN ANALYZE for actual timings. Cross-check plans after schema changes—I have seen a composite index help one report but force a worse plan on nightly batch jobs.

No. Each index slows INSERT, UPDATE, and DELETE operations and consumes disk. Duplicate or unused indexes confuse the optimizer and increase write amplification.

Match indexes to WHERE, JOIN, and ORDER BY columns in selectivity order. A composite index like (status, created_at) serves filtering by status and sorting by date within that subset, but not queries filtering only on created_at without status. Put equality columns first, then range and ORDER BY columns. Covering indexes include all SELECT columns so MySQL reads only the index tree—useful on read-heavy WooCommerce 11.1 catalog pages, but they bloat on wide tables. Every JOIN column on the driving side needs an index; missing join indexes can turn a 50 ms query into a 30-second one.

When indexes have diminishing returns or the SQL shape is wrong. Replace SELECT * with explicit columns to reduce I/O. Rewrite function-wrapped predicates like YEAR(created_at) to sargable ranges. Split OR conditions across different columns into UNION ALL instead of one mega-index. Fix ORM N+1 loops with eager loading—indexes cannot batch fifty identical lookups. Replace OFFSET pagination with keyset pagination using the last seen value as a cursor; LIMIT 100000, 50 scans and discards 100,000 rows. On the Quick And Easy Nepalese Grocery Laravel store, rewriting a subquery to a JOIN with a zone index cut admin load time more than adding a redundant index.

Laravel developers should enable query logging in staging, use Laravel Debugbar, and fix N+1 patterns with eager loading—watch for unbounded with() calls on date-range filters. WordPress developers should use the Query Monitor plugin to surface duplicate and slow queries per page load. Both approaches still require running MySQL EXPLAIN on the underlying SQL, not just application timers. A single controller action firing fifty identical lookups will never show as one slow row in the slow query log, so application-level tooling complements server instrumentation.

Upgrade when InnoDB buffer pool hit rate stays poor after index and rewrite work, disk I/O saturates on legitimate indexed reads, or replication lag persists despite clean SQL. Throwing RAM at full scans buys weeks, not years. Server tuning should come after query and index fixes—tuning a bad query with more RAM only delays the pain. If innodb_buffer_pool_reads stays high relative to requests after proper indexing, you need more RAM or fewer cold scans, not arbitrary cache hacks.

Set innodb_buffer_pool_size to roughly 60–70% of dedicated DB RAM on a single-purpose server—for an 8 GB database VM, start near 5 GB with innodb_buffer_pool_instances = 4. Monitor buffer pool hit rate with SHOW GLOBAL STATUS on Innodb_buffer_pool_read metrics. Raise join_buffer_size, sort_buffer_size, and read_rnd_buffer_size modestly only after indexing fixes, because oversized sort buffers per connection multiply memory use under concurrency—a site with 200 PHP-FPM workers can exhaust RAM quickly. Set max_connections above peak pool size with headroom, use wait_timeout to drop idle connections, and enable slow query log rotation so disks do not fill during incidents.

The slow query log is historical—it records statements exceeding your long_query_time threshold to a file like /var/log/mysql/slow.log for later analysis with pt-query-digest. Performance Schema shows what is slow right now by aggregating statement digests with execution counts, average wait times, and total wait times. Use the slow log to identify recurring offenders over days or weeks; use Performance Schema during active incidents to surface the worst offenders by total impact immediately. Both belong in a measure-first workflow before any schema or SQL changes reach production.

Focus on four columns. Type reveals access method—ALL on a large table means a full scan and usually signals a missing or wrong index; ref, range, and const are healthy targets. Key shows which index MySQL selected; NULL on a large table is a red flag. Rows estimates how many rows MySQL will examine—compare before and after fixes and watch when rows examined far exceeds rows returned. Extra flags problems like Using filesort on millions of rows, Using temporary for derived tables, and Using where for post-index filtering. Always EXPLAIN both the user-facing path and any batch jobs after adding indexes.

OFFSET pagination degrades linearly as tables grow because LIMIT 100000, 50 scans and discards 100,000 rows before returning fifty results. Keyset pagination uses the last seen value—such as created_at—as a cursor: filter WHERE created_at is less than the cursor, ORDER BY created_at DESC, LIMIT 50. This stays fast regardless of table size because MySQL uses an index to seek directly to the next page. Pair keyset pagination with Redis 8.10 caching for frequently accessed first pages. The approach requires a stable sort column and consistent ordering, but it eliminates one of the most common slow-query patterns on growing Laravel and WooCommerce order tables.

Run SHOW INDEX FROM table_name before adding duplicates. Drop unused indexes identified via Performance Schema or sys.schema_unused_indexes—indexes that never appear in EXPLAIN output are prime candidates. Rebuild statistics after large imports with ANALYZE TABLE on affected tables. Test write throughput on hot insert tables before adding wide composite or covering indexes, because each index slows INSERT, UPDATE, and DELETE operations. SaaS workloads with tenant isolation often need leading tenant_id columns in composite indexes. Leftmost prefix rules govern which filters benefit from a composite index, so verify every query pattern against the index definition before deploying to production.

Follow measure, explain, fix, tune—never skip steps. First enable the slow query log and Performance Schema, ranking offenders by total wait time rather than single spikes. Second run EXPLAIN or EXPLAIN ANALYZE on exact production SQL against realistic row counts. Third fix indexes and rewrite SQL shapes—composite indexes, covering indexes, sargable predicates, keyset pagination, and ORM N+1 elimination. Fourth tune InnoDB buffer pool and connection settings only after query-layer work is done. Validate every change on a staging clone before deploying; plans flip when tables cross million-row thresholds. If you run MariaDB 12.3 instead of Oracle MySQL, most index advice transfers but verify optimizer differences before assuming identical behavior.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: