Part · Chapter

Indexes and query performance

An index is the difference between a query that answers in a millisecond and one that reads every row on disk.

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_size and shared_buffers both 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.

Illustration · Both
-- 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.

Root Branch Leaf Secondary index idx_orders_customer customer_id 1 · 2 3 1 → 10, 11 2 → 12 3 → 14 Clustered index PRIMARY (id) = the table id 10 · 11 · 12 13 · 14 10 · 1 · 2026-01-05 · paid · 65.00 13, 14 (full rows) second lookup: fetch the whole row by primary key 10
Secondary index entries hold primary key values; the second descent fetches the row.

The kinds of index you will meet

Most tables need only B-tree indexes on the columns queries filter, join and sort by.

KindWhat it is forMySQL 8.4 / 9.7 / 26.xPostgreSQL 17 / 18
B-treeEquality, ranges, prefix LIKE, ORDER BYYes (the only InnoDB type)Yes (default)
UniqueNo duplicates, and a normal index tooYesYes
CompositeSeveral columns; leftmost prefix appliesYesYes (18 adds skip scan)
CoveringAnswered from the index aloneYes (Using index)Yes (INCLUDE, Index Only Scan)
DescendingORDER BY ... DESC, mixed directionsYes (since 8.0)Yes (plus NULLS FIRST/LAST)
FunctionalIndex on LOWER(name)Yes (8.0.13+)Yes
InvisibleMaintained, ignored; test before droppingYes (8.0+)No
PartialOnly rows matching a WHERENoYes
Full-textWords inside text columnsYes (FULLTEXT)Yes (tsvector + GIN)
SpatialPoints, polygons, distanceYes (SPATIAL)Yes (PostGIS + GiST)
HashEquality only, no rangesPartial MEMORY and NDB onlyYes (USING hash)
GINjsonb, arrays, text, trigramsNo (multi-valued indexes for JSON arrays)Yes
GiST / SP-GiSTGeometry, ranges, nearest neighborNoYes
BRINHuge tables in insertion orderNoYes
Build without blocking writesAdd an index while the app runsYes (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.

Illustration · Both
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.

Illustration · MySQL
EXPLAIN SELECT id, order_date, total
FROM orders
WHERE customer_id = 1
ORDER BY order_date;
plantabletypekeyrowsExtra
before the indexordersALLNULL2980412Using where; Using filesort
after the indexordersrefidx_orders_cust_date2NULL

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.

Illustration · PostgreSQL
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.

SymptomWhat is happeningWhat to do
No index on the filtered or joined columntype ALL or Seq Scan with a big Rows Removed by FilterAdd a B-tree index; on PostgreSQL index foreign keys yourself
WHERE YEAR(order_date) = 2026The function runs per row, so the sorted index is uselessRewrite as a date range, or add an expression index
WHERE phone = 5551234 on a VARCHARMySQL converts the column per row; PostgreSQL usually refusesMatch the types: quote the literal, or fix the column
LIKE '%mouse'A B-tree is sorted from the first characterFull-text index, or pg_trgm with GIN
LIMIT 20 OFFSET 1000000A million rows are read and thrown awayKeyset paging: WHERE id > :last ORDER BY id LIMIT 20
Too many indexesEvery write maintains each copy; the cache fills with unread pagesDrop 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.

Illustration · Both
-- 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 = ON and long_query_time = 1 logs every statement over one second.
MySQL sys schema
Installed by default over the Performance Schema. sys.statement_analysis ranks statements by total latency, and sys.schema_unused_indexes lists indexes nobody used.
PostgreSQL pg_stat_statements
Add it to shared_preload_libraries, restart the service, then CREATE 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)

my.ini
[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.

Go deeper

Quick check

1. orders has an index on (customer_id, order_date). Which WHERE clause can use it?

The index is sorted by customer_id first, so a query must constrain that leading column. Option a skips it, option b wraps the column in a function, and option d uses a column that is not in the index at all. Reread "Composite indexes and the leftmost-prefix rule".

2. In InnoDB, what does an entry in a secondary index point at?

InnoDB stores the primary key columns in every secondary index entry and uses them for a second descent into the clustered index. That is also why a wide primary key makes every index bigger. PostgreSQL is the engine that points at a physical location. Reread "Where the rows actually live".

3. In MySQL's EXPLAIN grid, type = ALL means…

ALL is the full table scan. On a large table it is the first thing to investigate, usually together with a NULL in the key column. Reread "MySQL EXPLAIN".

4. Which statement about EXPLAIN ANALYZE is true?

Both engines run the statement to collect actual timings and row counts. PostgreSQL's manual recommends BEGIN, EXPLAIN ANALYZE, ROLLBACK for data-changing statements, and MySQL (8.0.18+) runs multi-table UPDATE and DELETE under EXPLAIN ANALYZE as well. Reread the "Careful" callout in "Reading a plan".

5. orders.status has three values and 90 percent of rows are 'paid'. Someone adds an index on status and runs WHERE status = 'paid'. What happens, most likely?

A condition that matches most of the table has low selectivity, and a full scan is cheaper than millions of random row fetches. The same index could help for a rare value such as 'cancelled'. Reread "Selectivity, cardinality and statistics".