The slow query log is the most direct instrument for measuring query performance in production. It captures every query that exceeds a time threshold, including execution time, rows examined, rows sent, lock time, and the full query text. What it captures is a running record of every time MySQL spent more than N seconds doing something. What most teams do with it is almost nothing.
Enabling the Slow Query Log
All slow query log settings can be changed at runtime with SET GLOBAL. You do not need a server restart or a maintenance window to enable logging.
-- Enable slow query logging immediately: SET GLOBAL slow_query_log = 'ON'; SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log'; -- Set the threshold (seconds): -- 1.0 = capture all queries over 1 second -- 0.1 = capture all queries over 100ms (for performance tuning) -- 0 = capture ALL queries (only for short diagnostic windows) SET GLOBAL long_query_time = 1; -- Also capture queries that do not use indexes (often the most important ones): SET GLOBAL log_queries_not_using_indexes = 'ON'; -- Verify settings took effect: SHOW VARIABLES LIKE 'slow%'; SHOW VARIABLES LIKE 'long_query_time'; SHOW VARIABLES LIKE 'log_queries_not_using_indexes';
Reading the Raw Log
The raw slow query log is a flat text file. Each entry contains the query and its statistics. The format is consistent but not easily parseable by eye at volume:
# Time: 2024-11-14T02:17:44.882943Z
# User@Host: appuser[appuser] @ web-01 [10.0.1.44]
# Query_time: 4.281734 Lock_time: 0.000182 Rows_sent: 1 Rows_examined: 8492341
SET timestamp=1731550664;
SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 1;
The critical fields: Query_time is total execution time. Rows_examined is how many rows MySQL read to produce the result. Rows_sent is how many rows were returned. When Rows_examined is 8 million and Rows_sent is 1, MySQL is doing a full table scan to find one row — the most common cause of slow queries.
Aggregating with mysqldumpslow
# -s t: sort by total time (sum of all executions) # -t 20: show top 20 queries # -a: show real numbers, not averages # Normalises queries by replacing literal values with N or S mysqldumpslow -s t -t 20 -a /var/log/mysql/slow.log # Output format: # Count: 1847 Time=0.18s (328s) Lock=0.00s (0s) Rows=1.0 (1847) # SELECT * FROM orders WHERE status = 'S' ORDER BY created_at LIMIT N # # Read as: ran 1847 times, averaged 0.18s, total 328 seconds consumed # The query normalised "pending" → S and 1 → N
# -s r: sort by average rows examined
# These are your worst missing-index candidates
mysqldumpslow -s r -t 10 /var/log/mysql/slow.logGoing Deeper with pt-query-digest
pt-query-digest from Percona Toolkit produces far richer analysis than mysqldumpslow. It fingerprints queries, groups variations, and shows full percentile distributions for each query pattern.
# Install: apt install percona-toolkit (Debian/Ubuntu) # yum install percona-toolkit (RHEL/CentOS) # Full analysis — writes a report sorted by total time: pt-query-digest /var/log/mysql/slow.log > /tmp/slow_report.txt # Most important output sections: # 1. "Overall" — total time, unique query patterns, total queries # 2. "Profile" — ranked list of queries by total time consumed # 3. Per-query detail — pct of total, concurrency, p95/p99 latency # Common patterns to look for: # Exec time: 4.28s avg, 29.1s max → high variance = parameter-sensitive plan # Rows examine: 8.49M avg → full table scan # Rows sent: 1 avg → one row returned from 8M read # Lock time: 12ms avg → lock contention adding latency
Using EXPLAIN on Slow Queries
-- Take the worst query from the log and EXPLAIN it: -- (replace literal values with real values for the EXPLAIN) EXPLAIN SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 1; -- Key columns to read: -- type: 'ALL' = full table scan (bad), 'ref' = index lookup (good) -- rows: estimated rows examined. Match against Rows_examined in log. -- Extra: 'Using filesort' = sort in memory/disk (expensive) -- 'Using index' = covering index (fast) -- 'Using where' = filter applied after index -- For full execution details (MySQL 8.0): EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 1; -- EXPLAIN ANALYZE actually runs the query and shows actual rows/loops
-- For: WHERE status = 'pending' ORDER BY created_at -- The correct index covers both the filter and the sort: CREATE INDEX idx_orders_status_created ON orders (status, created_at); -- Why this order matters: -- status first: allows index seek on status = 'pending' -- created_at second: rows already sorted within each status group -- Without this order: MySQL would seek on status, then filesort on created_at
Using performance_schema Instead of the Log
SELECT DIGEST_TEXT AS query_pattern, COUNT_STAR AS executions, ROUND(SUM_TIMER_WAIT/1e12, 3) AS total_sec, ROUND(AVG_TIMER_WAIT/1e12, 3) AS avg_sec, ROUND(MAX_TIMER_WAIT/1e12, 3) AS max_sec, SUM_ROWS_EXAMINED, SUM_ROWS_SENT, SUM_NO_INDEX_USED + SUM_NO_GOOD_INDEX_USED AS no_index_count FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 20; -- This is available without enabling the slow query log -- Persists across connections, reset with: -- TRUNCATE TABLE performance_schema.events_statements_summary_by_digest
The Correct Analysis Workflow
The workflow that finds the highest-impact problems fastest: enable the log with a 1-second threshold, let it run for 24 hours covering a full business cycle, run pt-query-digest sorted by total time, take the top 3 queries by total time, run EXPLAIN ANALYZE on each one, create the appropriate index or rewrite the query. Repeat weekly until the total time in the slow log drops to an acceptable baseline.
The mistake most teams make: they look at the slowest individual query, not the queries consuming the most total time. A 30-second query that runs once a day costs 30 seconds. A 2-second query that runs 10,000 times a day costs 5.5 hours. pt-query-digest's default sort — total time — finds the right problem automatically.