The short version
- An index is a sorted shortcut. A shallow B-tree replaces reading every row.
- In InnoDB the primary key is the table. Every secondary index entry carries it, so keep it short.
- Composite indexes read left to right. Constrain the leading column first.
- EXPLAIN shows the plan. Compare the estimated row count with the actual one.
- Slow queries repeat a few patterns. A function on the column, a type mismatch, a leading wildcard, a big OFFSET.
- Give the engine memory.
innodb_buffer_pool_sizeandshared_buffersboth default to 128 MB.
What an index is
An index is a separate structure sorted by one or more columns, where each entry holds the value and a pointer to its row. It is the phone book sorted by last name instead of by signup order. InnoDB and PostgreSQL both build ordinary indexes as a B-tree that stays three or four levels deep over millions of rows, so a lookup reads a few pages and also serves ranges, prefix LIKE, ORDER BY and joins.
-- The most common index a DBA adds: the column a WHERE or JOIN filters on
CREATE INDEX idx_orders_customer ON orders (customer_id);
-- InnoDB already created one for the foreign key (fk_orders_customer).
-- PostgreSQL does not index foreign key columns for you.
Remember
A sorted copy of some columns plus a pointer to the row, kept in a shallow tree. It turns "read everything" into "read a few pages".
Where the rows actually live
MySQL In InnoDB the primary key index is the table: its leaf pages hold the whole rows in primary key order, the clustered index. Every other index is a secondary index whose pointer is the primary key value, so a lookup costs two descents and that key sits in every entry. A 36-character UUID therefore bloats every index; prefer a short, increasing BIGINT.
PostgreSQL PostgreSQL stores the table as a heap, with rows wherever there is free space. Every index, including the primary key's, is a separate B-tree pointing at a physical location. No index is special, so a UUID key hurts far less.
The kinds of index you will meet
Most tables need only B-tree indexes on the columns queries filter, join and sort by.
| Kind | What it is for | MySQL 8.4 / 9.7 / 26.x | PostgreSQL 17 / 18 |
|---|---|---|---|
| B-tree | Equality, ranges, prefix LIKE, ORDER BY | Yes (the only InnoDB type) | Yes (default) |
| Unique | No duplicates, and a normal index too | Yes | Yes |
| Composite | Several columns; leftmost prefix applies | Yes | Yes (18 adds skip scan) |
| Covering | Answered from the index alone | Yes (Using index) | Yes (INCLUDE, Index Only Scan) |
| Descending | ORDER BY ... DESC, mixed directions | Yes (since 8.0) | Yes (plus NULLS FIRST/LAST) |
| Functional | Index on LOWER(name) | Yes (8.0.13+) | Yes |
| Invisible | Maintained, ignored; test before dropping | Yes (8.0+) | No |
| Partial | Only rows matching a WHERE | No | Yes |
| Full-text | Words inside text columns | Yes (FULLTEXT) | Yes (tsvector + GIN) |
| Spatial | Points, polygons, distance | Yes (SPATIAL) | Yes (PostGIS + GiST) |
| Hash | Equality only, no ranges | Partial MEMORY and NDB only | Yes (USING hash) |
| GIN | jsonb, arrays, text, trigrams | No (multi-valued indexes for JSON arrays) | Yes |
| GiST / SP-GiST | Geometry, ranges, nearest neighbor | No | Yes |
| BRIN | Huge tables in insertion order | No | Yes |
| Build without blocking writes | Add an index while the app runs | Yes (ALGORITHM=INPLACE, LOCK=NONE) | Yes (CREATE INDEX CONCURRENTLY) |
Composite indexes and the leftmost-prefix rule
An index on (customer_id, order_date) is sorted by customer, and within each customer by date.
CREATE INDEX idx_orders_cust_date ON orders (customer_id, order_date);
-- uses the index: WHERE customer_id = 1
-- uses the index: WHERE customer_id = 1 AND order_date >= '2026-02-01'
-- uses the index: WHERE customer_id = 1 ORDER BY order_date
-- cannot use it: WHERE order_date >= '2026-02-01' (leading column missing)
The rule: a query must constrain the leftmost column, then the next, without skipping. An index on (a, b) therefore serves queries on a alone, and equality columns go first with the range column last. PostgreSQL 18 added skip scan for a skipped low-cardinality leading column, but do not design for it.
Selectivity, cardinality and statistics
Cardinality is the number of distinct values in a column; selectivity is the share of rows a condition matches. The optimizer decides per query, and it ignores an index on status when 90 percent of orders are 'paid', because a scan beats millions of random row fetches. It judges from stored statistics, so plans that broke overnight usually want ANALYZE first.
Reading a plan
EXPLAIN asks the optimizer what it would do without doing it. EXPLAIN ANALYZE runs the statement and prints real row counts and timings next to the estimates. An estimate of 300,000 rows that turns out to be 2 is a statistics problem; a scan of three million rows to return 2 is an index problem. The plans below assume three million orders.
MySQL EXPLAIN
MySQL Three fields carry the meaning: type is how the table is read (ALL is a full table scan, ref an index lookup), key is the index chosen (NULL means none), and Extra holds the warnings such as Using filesort. 8.4 prints the TRADITIONAL grid; 9.5 and later default to TREE.
EXPLAIN SELECT id, order_date, total
FROM orders
WHERE customer_id = 1
ORDER BY order_date;
| plan | table | type | key | rows | Extra |
|---|---|---|---|---|---|
| before the index | orders | ALL | NULL | 2980412 | Using where; Using filesort |
| after the index | orders | ref | idx_orders_cust_date | 2 | NULL |
The filesort is gone too: the index returns each customer's orders in date order.
PostgreSQL EXPLAIN
PostgreSQL PostgreSQL prints one tree. Recognize Seq Scan, Index Scan and Index Only Scan, and read Rows Removed by Filter as wasted work. EXPLAIN (ANALYZE, BUFFERS) adds actual time, rows and pages from cache (shared hit) or disk (read); 18 includes BUFFERS with ANALYZE.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, order_date, total FROM orders WHERE customer_id = 1 ORDER BY order_date;
-- before the index
Sort (cost=63012.10..63757.20 rows=298041 width=17) (actual time=1650.32..1650.33 rows=2 loops=1)
-> Seq Scan on orders (cost=0.00..58233.00 rows=298041 width=17) (actual time=0.04..1642.11 rows=2 loops=1)
Filter: (customer_id = 1)
Rows Removed by Filter: 2999998
Buffers: shared hit=2112 read=20734
-- after CREATE INDEX idx_orders_cust_date ON orders (customer_id, order_date)
Index Scan using idx_orders_cust_date on orders (cost=0.43..12.47 rows=2 width=17) (actual time=0.03..0.04 rows=2 loops=1)
Index Cond: (customer_id = 1)
Buffers: shared hit=5
Same fix: 22,846 pages and a sort before, five cached pages after.
Careful
EXPLAIN ANALYZE executes the statement, so on an UPDATE it changes data. Wrap it: BEGIN; EXPLAIN ANALYZE ...; ROLLBACK; on PostgreSQL, START TRANSACTION on MySQL.
Ask the AI
"MySQL 8.4, InnoDB. Here is my query and its EXPLAIN ANALYZE output: (paste). Which step is expensive, and which single index would fix it?" The same prompt works for PostgreSQL 18.
The usual suspects
Nine out of ten slow queries are one of these patterns, and the pattern is most of the fix.
| Symptom | What is happening | What to do |
|---|---|---|
| No index on the filtered or joined column | type ALL or Seq Scan with a big Rows Removed by Filter | Add a B-tree index; on PostgreSQL index foreign keys yourself |
WHERE YEAR(order_date) = 2026 | The function runs per row, so the sorted index is useless | Rewrite as a date range, or add an expression index |
WHERE phone = 5551234 on a VARCHAR | MySQL converts the column per row; PostgreSQL usually refuses | Match the types: quote the literal, or fix the column |
LIKE '%mouse' | A B-tree is sorted from the first character | Full-text index, or pg_trgm with GIN |
LIMIT 20 OFFSET 1000000 | A million rows are read and thrown away | Keyset paging: WHERE id > :last ORDER BY id LIMIT 20 |
| Too many indexes | Every write maintains each copy; the cache fills with unread pages | Drop unused ones after a fair sample; on MySQL make them invisible first |
The wildcard one is worth seeing on real rows. Both queries below find Gary Moore's Still Got the Blues in the sample tracks table, and only the first one can use an index on title. A B-tree is sorted by first character, so an anchored pattern is a seek and a leading % is a scan of every row.
-- Seek: the engine jumps to the S entries and stops
SELECT title FROM tracks WHERE title LIKE 'Still%';
-- Scan: the sort order is no help, so every row is examined
SELECT title FROM tracks WHERE title LIKE '%Blues%';
On fourteen rows nobody notices. On fourteen million, the second query is the ticket you get paged about.
Finding the slow queries, and giving the engine memory
When the complaint is "the app is slow", first find which statements deserve attention. Chapter 16 makes this a weekly routine.
- MySQL Slow query log
- Off by default.
SET PERSIST slow_query_log = ONandlong_query_time = 1logs every statement over one second. - MySQL sys schema
- Installed by default over the Performance Schema.
sys.statement_analysisranks statements by total latency, andsys.schema_unused_indexeslists indexes nobody used. - PostgreSQL pg_stat_statements
- Add it to
shared_preload_libraries, restart the service, thenCREATE EXTENSION. It lists calls, mean time and rows per statement.
An index only pays off when its pages are in memory. MySQL InnoDB caches table and index pages in the buffer pool, raised online with SET PERSIST. PostgreSQL shared_buffers plays the same role, needs a restart, and rarely helps far above its starting point because the operating system caches files too.
innodb_buffer_pool_size default
128 MB
shared_buffers default
128 MB
Dedicated MySQL server
50 to 75% of RAM (manual: up to 80%)
PostgreSQL starting point
25% of RAM
long_query_time default
10 s (set 1)
[mysqld]
# 16 GB machine dedicated to MySQL: give InnoDB 12 GB
innodb_buffer_pool_size=12G
slow_query_log=ON
long_query_time=1
For you as a DBA
"The app is slow" has a routine: find the top offenders in sys.statement_analysis or pg_stat_statements, EXPLAIN ANALYZE the worst one against the suspects table, then check the buffer pool against RAM. Change one thing at a time.