MySQL Query Optimization Techniques
The orders page loaded fine when the table had a few thousand rows. A year later it has a few million, the page takes several seconds, and the database server's CPU is pinned every time someone opens the admin dashboard. Nothing in the application code changed. The queries just stopped scaling.
Most of the time the fix is a missing index, a query that accidentally defeats the index it has, or a pagination pattern that gets slower with every page. This post covers how to read EXPLAIN, how to design indexes that MySQL will actually use, JOIN and WHERE patterns, sorting and pagination, the server settings that matter, and a couple of heavier tools (partitioning, generated columns) for when the basics aren't enough.
In most apps a small number of queries cause most of the database load. Turn on the slow query log (or use Performance Schema, shown later) and work on the worst offenders first. Tuning queries at random usually means spending time on things that were never the problem.
Understanding Query Execution with EXPLAIN
You can't fix a query until you know what MySQL is doing with it. EXPLAIN shows the plan the optimizer picked: which indexes it uses, the order it joins tables in, and roughly how many rows it expects to read.
-- Basic EXPLAIN
EXPLAIN SELECT * FROM orders
WHERE customer_id = 42
AND status = 'pending'
ORDER BY created_at DESC;
-- EXPLAIN FORMAT=JSON gives more detail
EXPLAIN FORMAT=JSON SELECT * FROM orders
WHERE customer_id = 42;
-- EXPLAIN ANALYZE actually runs the query and shows real statistics
-- (MySQL 8.0.18+)
EXPLAIN ANALYZE SELECT * FROM orders
WHERE customer_id = 42;
Reading EXPLAIN Output
One warning about EXPLAIN ANALYZE: unlike plain EXPLAIN, it really executes the query. If the query takes 40 seconds, so does the explain, so be careful running it against a busy production primary.
These are the columns I look at first:
| Column | What It Tells You | What to Watch For |
|---|---|---|
type |
Join/access type: how MySQL reads the table | Avoid ALL (full table scan); aim for ref, eq_ref, or const |
key |
Which index MySQL chose to use | NULL means no index was used, so it probably needs one |
rows |
Estimated number of rows MySQL will examine | High row count relative to actual result set = inefficient query |
Extra |
Additional info about the operation | Using filesort, Using temporary indicate costly operations |
possible_keys |
Indexes MySQL considered | If NULL, no existing index applies. Add one. |
The access type hierarchy from best to worst: system → const → eq_ref → ref → range → index → ALL. A type: ALL means MySQL is reading every row in the table. On a small lookup table that's fine; on anything that grows, it's usually the first thing to fix.
MySQL's query execution pipeline. EXPLAIN shows what the optimizer decided.
Indexing: The Foundation of Query Performance
If a query is slow, the odds are good that the answer is an index. Without one, MySQL has to read the whole table to find matching rows, the same way you'd flip through every page of a book instead of checking the index at the back. That's cheap at 10,000 rows and painful at 10 million.
Index Types and When to Use Them
-- Single-column index: good for simple equality lookups
CREATE INDEX idx_customer_id ON orders(customer_id);
-- Composite index: covers multiple WHERE conditions
-- Column order matters! Put most selective column first,
-- or put equality columns before range columns
CREATE INDEX idx_customer_status ON orders(customer_id, status);
-- Covering index: includes all columns needed by the query
-- MySQL can satisfy the query entirely from the index (no table lookup)
CREATE INDEX idx_covering ON orders(customer_id, status, created_at, total);
-- Prefix index for large VARCHAR columns
CREATE INDEX idx_email_prefix ON users(email(20));
-- Unique index: prevents duplicates AND speeds up lookups
CREATE UNIQUE INDEX idx_unique_email ON users(email);
-- Check existing indexes
SHOW INDEX FROM orders;
-- Check if a query uses an index efficiently
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending';
Composite Index Column Ordering
Column order in a composite index matters a lot. MySQL can only use a composite index starting from the leftmost column, which is usually called the "leftmost prefix rule." An index on (customer_id, status, created_at) can be used for:
- Queries filtering on
customer_idalone - Queries filtering on
customer_id+status - Queries filtering on all three columns
- But NOT for queries filtering on
statusalone orcreated_atalone
Indexes slow down INSERT, UPDATE, and DELETE operations because MySQL must maintain them. So don't index every column. Focus on the ones that show up in WHERE, JOIN ON, and ORDER BY. Also, wrapping a column in a function (e.g., WHERE YEAR(created_at) = 2026) stops MySQL from using its index. Rewrite it as a range condition instead.
Fixing Function-Based Index Killing
-- BAD: Function call prevents index usage
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';
SELECT * FROM products WHERE price + 10 > 100;
-- GOOD: Rewrite to allow index usage
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
-- For case-insensitive email: use a case-insensitive collation on the column
-- ALTER TABLE users MODIFY email VARCHAR(255) COLLATE utf8mb4_unicode_ci;
-- Then a simple equality works with the index:
SELECT * FROM users WHERE email = 'user@example.com';
SELECT * FROM products WHERE price > 90; -- functionally equivalent, uses index
JOIN Optimization
MySQL mostly runs joins as nested loops: for each row in the driving table, it looks up matching rows in the joined table. With an index on the join column, each lookup is cheap. Without one, every lookup is a scan, and the cost grows roughly as O(n×m).
Always Index Join Columns
-- Ensure foreign key columns are indexed
-- The orders.customer_id column should have an index
-- MySQL automatically creates one for declared FOREIGN KEYs
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_order_items_order ON order_items(order_id);
-- GOOD JOIN: both join columns are indexed
SELECT
c.name,
COUNT(o.id) AS order_count,
SUM(o.total) AS total_spent
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id -- indexed on both sides
WHERE o.status = 'completed'
AND o.created_at >= '2026-01-01'
GROUP BY c.id, c.name
ORDER BY total_spent DESC
LIMIT 10;
-- Avoid joining on expressions or functions
-- BAD:
SELECT * FROM orders o
JOIN order_archive oa ON DATE(o.created_at) = DATE(oa.created_at);
-- GOOD: Compare raw column values
SELECT * FROM orders o
JOIN order_archive oa ON o.created_at = oa.created_at;
Subquery vs JOIN: Choosing Wisely
In MySQL 5.x, correlated subqueries were notoriously slow. In MySQL 8.0 the optimizer is smarter, but JOINs are often still faster for large datasets because they give the optimizer more flexibility in choosing the execution order.
-- SLOW: Correlated subquery runs once per row in customers
SELECT c.name
FROM customers c
WHERE (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) > 5;
-- FAST: JOIN + GROUP BY runs once
SELECT c.name
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name
HAVING COUNT(o.id) > 5;
-- For NOT IN / NOT EXISTS, use LEFT JOIN ... IS NULL
-- (often faster than NOT IN with large sets)
-- SLOW:
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
-- FAST:
SELECT c.*
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
Writing Efficient WHERE Clauses
Small differences in how a WHERE clause is written decide whether MySQL can use an index at all. These four come up constantly in code review.
Use equality before range conditions in composite indexes
In a composite index (status, created_at), filter on status = 'active' first, then use a range on created_at. Putting the range column first wastes index efficiency.
Avoid leading wildcards in LIKE
WHERE name LIKE 'John%' can use an index. WHERE name LIKE '%John%' can't, so MySQL falls back to a full scan. For full-text search needs, use MySQL's FULLTEXT indexes instead.
Use IN instead of OR for multiple values
WHERE status IN ('pending', 'processing') is typically more efficient than WHERE status = 'pending' OR status = 'processing' and is cleaner to read.
Avoid implicit type conversions
If user_id is an INT, querying WHERE user_id = '42' (string) forces MySQL to cast every row, preventing index use. Always match the data type of your literals to the column type.
-- Good: Uses index on (status, created_at)
SELECT * FROM orders
WHERE status = 'pending' -- equality first
AND created_at >= '2026-01-01' -- range second
AND created_at < '2026-04-01';
-- Good: Prefix LIKE uses index
SELECT * FROM users WHERE email LIKE 'mayur%';
-- Bad: Leading wildcard can't use index
-- SELECT * FROM users WHERE email LIKE '%@gmail.com';
-- Good: IN clause
SELECT * FROM products WHERE category_id IN (1, 3, 7, 12);
-- Bad: implicit type conversion (user_id is INT)
-- SELECT * FROM users WHERE user_id = '42';
-- Good: matched types
SELECT * FROM users WHERE user_id = 42;
-- Good: EXISTS is often faster than IN with large subqueries
SELECT * FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id
AND o.status = 'completed'
);
ORDER BY, GROUP BY, and LIMIT Optimization
When MySQL can't read rows in the order you asked for, it has to collect them and sort them itself. That shows up in EXPLAIN as Using filesort (and sometimes Using temporary). If an index already stores rows in the right order, MySQL can skip the sort and just read them off the index.
Eliminating Filesort with Indexes
-- With this index, ORDER BY can be served from the index
CREATE INDEX idx_customer_created ON orders(customer_id, created_at DESC);
-- MySQL can now serve this query entirely from the index
-- (no filesort, no temporary table)
SELECT id, total, created_at
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 20;
-- For pagination: use keyset pagination instead of OFFSET for large pages
-- BAD: OFFSET 10000 makes MySQL scan and discard 10000 rows
SELECT * FROM orders ORDER BY id DESC LIMIT 20 OFFSET 10000;
-- GOOD: Keyset pagination - cursor-based, fast regardless of page depth
SELECT * FROM orders
WHERE id < 98765 -- last seen id from previous page
ORDER BY id DESC
LIMIT 20;
Optimizing GROUP BY Aggregations
-- Add index that covers GROUP BY column and aggregate target
CREATE INDEX idx_status_total ON orders(status, total);
-- Count query: can now be answered from the index alone
SELECT status, COUNT(*) AS cnt, SUM(total) AS revenue
FROM orders
GROUP BY status;
-- Use HAVING to filter groups, not WHERE (WHERE filters rows before grouping)
SELECT customer_id, COUNT(*) AS order_count
FROM orders
WHERE created_at >= '2026-01-01' -- filter rows first (uses index)
GROUP BY customer_id
HAVING COUNT(*) >= 3; -- filter groups after aggregation
-- For dashboard summary stats, consider summary tables
-- updated periodically instead of running heavy aggregations on demand
CREATE TABLE order_daily_summary (
date DATE PRIMARY KEY,
order_count INT NOT NULL DEFAULT 0,
revenue DECIMAL(12,2) NOT NULL DEFAULT 0.00
);
A covering index contains every column the query needs, so MySQL can answer it from the index alone without going back to the table rows. In EXPLAIN you'll see Using index in the Extra column. For hot read queries this can be a big win, but each extra column makes the index bigger and writes slower, so save it for queries that run constantly.
MySQL Configuration Tuning
Good queries can still be slow on a badly configured server. The defaults are conservative (a 128M buffer pool on a machine with 16GB of RAM is common), and these are the variables I'd check first on a typical web app.
| Variable | Default | Recommended Starting Point | Purpose |
|---|---|---|---|
innodb_buffer_pool_size |
128M | 70-80% of total RAM (dedicated DB server) | Caches data and indexes in memory. The most important setting. |
innodb_io_capacity |
200 | 1000-2000 (SSD) | Tells InnoDB how fast your disk is |
query_cache_type |
0 (off) | 0 (keep off) | MySQL 8.0 removed the query cache. Cache in the application instead. |
max_connections |
151 | Match your connection pool size | Max simultaneous connections |
slow_query_log |
OFF | ON (in production) | Logs queries slower than long_query_time |
long_query_time |
10 | 1 or 0.5 | Threshold (seconds) for slow query logging |
[mysqld]
# Set to 70-80% of server RAM (example: 8GB server)
innodb_buffer_pool_size = 6G
# For SSD storage
innodb_io_capacity = 2000
innodb_io_capacity_max = 4000
# Enable slow query logging to find problems
slow_query_log = ON
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = ON
# Avoid too many temp tables being written to disk
tmp_table_size = 64M
max_heap_table_size = 64M
# Connection settings
max_connections = 200
wait_timeout = 300
interactive_timeout = 300
Be careful with log_queries_not_using_indexes. It also logs fast queries on tiny tables that legitimately scan, and on a busy server it can bury the slow log in noise. If you turn it on, set log_throttle_queries_not_using_indexes to cap how many of those entries get written per minute.
Finding Slow Queries
-- Find the top slow queries from Performance Schema
SELECT
digest_text,
count_star AS executions,
ROUND(avg_timer_wait / 1e9, 2) AS avg_ms,
ROUND(sum_timer_wait / 1e9, 2) AS total_ms,
sum_rows_examined AS rows_examined,
sum_rows_sent AS rows_sent
FROM performance_schema.events_statements_summary_by_digest
ORDER BY sum_timer_wait DESC
LIMIT 10;
-- Check tables with no primary key (very slow for joins/updates)
SELECT table_schema, table_name
FROM information_schema.tables t
WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys')
AND NOT EXISTS (
SELECT 1 FROM information_schema.statistics s
WHERE s.table_schema = t.table_schema
AND s.table_name = t.table_name
AND s.index_name = 'PRIMARY'
);
-- Use pt-query-digest (Percona Toolkit) to analyze slow query log
-- pt-query-digest /var/log/mysql/slow.log | head -100
Advanced Techniques
Partitioning for Large Tables
Once a time-series table gets very large, partitioning lets you split it into physical chunks by a key like date. If the WHERE clause includes that key, MySQL only reads the matching partitions (partition pruning). The bigger practical win is often cleanup: dropping a year of old data becomes a quick metadata operation instead of a huge DELETE.
-- Partition a large events table by year
CREATE TABLE events (
id BIGINT AUTO_INCREMENT,
user_id INT NOT NULL,
event_type VARCHAR(50) NOT NULL,
created_at DATETIME NOT NULL,
payload JSON,
PRIMARY KEY (id, created_at) -- partition key must be in PK
)
PARTITION BY RANGE (YEAR(created_at)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION p2026 VALUES LESS THAN (2027),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
-- Query with partition key: MySQL only scans p2026
SELECT * FROM events
WHERE created_at >= '2026-01-01'
AND created_at < '2027-01-01'
AND event_type = 'purchase';
-- Check partition pruning with EXPLAIN PARTITIONS
EXPLAIN PARTITIONS SELECT * FROM events
WHERE created_at >= '2026-01-01';
-- Drop old partition (instant, no row-by-row DELETE)
ALTER TABLE events DROP PARTITION p2023;
Version note: EXPLAIN PARTITIONS is MySQL 5.x syntax. MySQL 8.0 removed the keyword and throws a syntax error if you use it. On 8.0, plain EXPLAIN already includes a partitions column. Also keep in mind that partitioning doesn't help queries that don't filter on the partition key; those now have to check every partition.
Using Generated Columns for Computed Indexes
-- Problem: You need to filter by year of created_at frequently
-- but YEAR(created_at) in WHERE prevents index use
-- Solution: Virtual generated column + index
ALTER TABLE orders
ADD COLUMN created_year SMALLINT AS (YEAR(created_at)) VIRTUAL,
ADD INDEX idx_created_year (created_year);
-- Now this query uses the index!
SELECT * FROM orders WHERE created_year = 2026;
-- JSON field indexing with generated columns
ALTER TABLE products
ADD COLUMN brand VARCHAR(100) AS (JSON_UNQUOTE(metadata->>'$.brand')) STORED,
ADD INDEX idx_brand (brand);
SELECT * FROM products WHERE brand = 'Sony';
MySQL Optimization Checklist
| Area | Checklist Item | Priority |
|---|---|---|
| Indexing | All foreign keys are indexed | Critical |
| Indexing | Composite indexes ordered with equality before range columns | High |
| Queries | No functions wrapping indexed columns in WHERE clauses | High |
| Queries | LIKE patterns don't start with a wildcard | High |
| Pagination | Large-offset pagination replaced with keyset/cursor pagination | Medium |
| Config | innodb_buffer_pool_size set to 70-80% of RAM | Critical |
| Monitoring | Slow query log enabled and regularly reviewed | High |
| Schema | All tables have a primary key | Critical |
| Schema | Column types are the smallest that fit the data | Medium |
| Joins | JOIN columns are indexed on both tables | High |
When to Reach for Which Fix (and When Not To)
Not every technique here is worth it for every app. This is roughly how I'd decide:
Trade-offs Worth Knowing
- Indexes: Almost always the first move, but not on write-heavy tables where the column is rarely queried. Every index costs disk and slows inserts. If you've got five overlapping indexes that start with the same column, drop the redundant ones (
sys.schema_redundant_indexeswill list them). - Covering indexes: Good for one or two very hot queries. Bad as a general habit, because wide indexes get expensive to maintain.
- Keyset pagination: Use it for infinite scroll, APIs, and batch jobs that walk a whole table. It doesn't support "jump to page 47", so for small admin tables plain
OFFSETis simpler and fine. - Summary tables: Worth it when dashboards run the same heavy aggregation over and over. You now own a sync job and some staleness, so don't bother if the raw query is already fast enough.
- Partitioning: I'd only consider it for very large, time-based tables where you regularly drop old data. For most tables a good index does the job with much less complexity, and the primary key restrictions can be awkward.
- Config tuning: Check the buffer pool size early, since the default is tiny. The 70-80% guideline assumes MySQL has the server to itself; if PHP, Node, or Redis share the box, go lower.
Whatever you change, check it with EXPLAIN (or EXPLAIN ANALYZE on a replica) before and after. The optimizer sometimes ignores a new index because its statistics are stale; running ANALYZE TABLE on the table refreshes them and is worth trying before you assume the index is useless.