MySQL

For writing queries against a schema you already have. Leads with the two questions that cost the most time: why is this slow, and why did the join return more rows than I expected.

What this page covers

  • Why it is slow

    Reading EXPLAIN, and the four things it tells you that matter.

  • Indexes

    Which column to index, why order matters in a composite, and when an index is ignored.

  • Joins

    The four types, and the row multiplication that catches everyone.

  • Grouping

    GROUP BY, HAVING against WHERE, and window functions.

  • Everyday admin

    Sizes, running queries, and killing the one holding a lock.

  • Dump and restore

    mysqldump options that matter, including the two that avoid a locked site.

Why is this query slow

EXPLAIN SELECT * FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > '2026-01-01';

-- MySQL 8: the real execution plan, with actual times and row counts
EXPLAIN ANALYZE SELECT ...;

Four columns carry nearly all the signal:

  • typeALL means a full table scan. ref or range means an index is being used. Going from ALL to ref is usually the whole fix.
  • rows — how many MySQL expects to examine. Compare it with how many you expect back. A gap of several orders of magnitude is the problem.
  • key — which index was chosen, or NULL for none.
  • ExtraUsing filesort and Using temporary both mean work on disk that an index could have avoided.

Indexes

SHOW INDEX FROM orders;

CREATE INDEX idx_orders_customer_created ON orders (customer_id, created_at);

-- Enforce uniqueness and get an index in the same move
CREATE UNIQUE INDEX idx_users_email ON users (email);

DROP INDEX idx_old_thing ON orders;

Column order in a composite index is not cosmetic. An index on (customer_id, created_at) serves a query filtering on customer_id, or on both — but not one filtering only on created_at. Leftmost prefix.

Three things stop an index being used, and all three look innocent:

WHERE YEAR(created_at) = 2026        -- function on the column: no index
WHERE created_at >= '2026-01-01'     -- rewrite as a range: index used

WHERE customer_id = '42'             -- string against an int column
WHERE customer_id = 42               -- match the type

WHERE name LIKE '%smith'             -- leading wildcard: no index
WHERE name LIKE 'smith%'             -- index used

Joins, and the row count surprise

-- Only rows with a match on both sides
SELECT ... FROM a INNER JOIN b ON b.a_id = a.id

-- Every row of a, with nulls where b has no match
SELECT ... FROM a LEFT JOIN b ON b.a_id = a.id

-- Rows of a with NO match in b - the anti-join
SELECT a.* FROM a LEFT JOIN b ON b.a_id = a.id WHERE b.id IS NULL

-- Join on a condition rather than a foreign key
SELECT ... FROM a JOIN b ON b.a_id = a.id AND b.status = 'active'

A join multiplies rows. One order with three line items returns three rows, and SUM(orders.total) across that join triples the money. If a total is suddenly wrong by an exact multiple, this is why. Aggregate in a subquery, or use SUM(DISTINCT ...) only when you understand what it discards.

ON and WHERE differ on a LEFT JOIN. A condition in WHERE filters after the join and turns it back into an inner join; the same condition in ON keeps the unmatched rows.

Grouping and windows

SELECT customer_id, COUNT(*) AS orders, SUM(total) AS spend
FROM orders
WHERE created_at > '2026-01-01'     -- filters BEFORE grouping
GROUP BY customer_id
HAVING spend > 500                  -- filters AFTER grouping
ORDER BY spend DESC;

-- Window functions (MySQL 8): aggregate without collapsing rows
SELECT
  customer_id,
  total,
  SUM(total) OVER (PARTITION BY customer_id) AS customer_total,
  ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at) AS n
FROM orders;

WHERE before grouping, HAVING after. Putting a plain column filter in HAVING works and scans far more rows than it needed to.

Everyday admin

-- What is running right now
SHOW FULL PROCESSLIST;
KILL 1234;              -- the id from the first column

-- Table sizes, largest first
SELECT table_name,
       ROUND((data_length + index_length) / 1024 / 1024, 1) AS mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY (data_length + index_length) DESC;

-- What is holding a lock (MySQL 8)
SELECT * FROM performance_schema.data_locks;

SHOW CREATE TABLE orders;    -- the real definition, including indexes

Dump and restore

# --single-transaction avoids locking the tables on InnoDB, so the site
# stays up while the dump runs. Without it, a large dump is an outage.
mysqldump --single-transaction --quick --routines --triggers \
  -u user -p dbname > dump.sql

# Schema only, or data only
mysqldump --no-data -u user -p dbname > schema.sql
mysqldump --no-create-info -u user -p dbname > data.sql

mysql -u user -p dbname < dump.sql

--single-transaction and --quick are the two that matter on anything live: the first keeps the site serving, the second streams rows instead of buffering the whole table into memory.