Database Optimization

Database Indexing: Boost Your Query Performance

Mayur Dabhi
Mayur Dabhi
February 20, 2026
18 min read

The orders page loaded fine when the table had a few thousand rows. Now it has a million, the page takes seconds, and the slow query log keeps showing the same innocent-looking SELECT ... WHERE customer_id = ?. Nothing in the code changed. The data grew, and the query never had an index to lean on.

This post covers how MySQL indexes actually work (B-Trees), the index types you'll use, how to read EXPLAIN, composite and covering indexes, what indexes cost you, and the mistakes that quietly stop MySQL from using an index you already have.

Why this is worth learning

On a large table, the gap between a full scan and an index lookup isn't a few percent. It's often the difference between seconds and milliseconds, because the database reads a handful of pages instead of the whole table. Few other changes give you that much for one line of SQL.

What an Index Actually Does

Think of the index at the back of a textbook. You don't read every page to find "replication"; you look it up and jump to page 212. A database index works the same way. It's a separate, sorted data structure that lets the database find rows without reading every row in the table.

Without one, MySQL does a full table scan and checks every row against your WHERE clause. With one, it goes straight to the matching rows.

Without Index

Seconds

Full table scan on 1M rows

With Index

Milliseconds

Index lookup + row fetch

How Indexes Work: Inside a B-Tree

Most MySQL indexes (every regular InnoDB index) are B-Trees, or more precisely B+Trees. Once you can picture the tree, most indexing rules stop feeling arbitrary.

B-Tree Structure Explained

The tree has a root, some internal levels, and leaf nodes. Each node holds sorted keys and pointers to child nodes; the leaves point at the rows. The tree stays balanced, so every lookup walks the same number of levels no matter which value you ask for.

B-Tree Index Structure 50 | 100 Root Node 10 | 25 | 40 60 | 75 | 90 110 | 125 | 150 5,8 12,18,22 28,35,38 42,45,48 52,55,58 62,68,72 78,82,88 92,95,98 Leaf nodes point to actual data rows Row Data Row Data Row Data Lookup: WHERE id = 72 1. Check root: 72 > 50, < 100 2. Go middle: 72 > 60, < 75 3. Find in leaf: 72 ✓

B-Tree structure showing how index lookups traverse from root to leaf nodes

Why B-Trees Are So Fast

Lookups are logarithmic. In a table with 1 million rows, a B-Tree can find any row in about 20 comparisons (log₂ 1,000,000 ≈ 20). A full table scan may need a million. In practice it's even better than that number suggests: each InnoDB page holds hundreds of keys, so a million-row index is usually only three or four levels deep, which means three or four page reads.

B-Tree Lookup Complexity

O(log n): as the table grows, lookup cost grows logarithmically, not linearly.

  • 1,000 rows: ~10 comparisons
  • 1,000,000 rows: ~20 comparisons
  • 1,000,000,000 rows: ~30 comparisons

Types of Database Indexes

You'll use the first four constantly. Full-text and spatial indexes are for specific jobs.

Primary Key Index

Automatically created on the primary key column. Unique and clustered (in InnoDB).

PRIMARY KEY (id)

Unique Index

Rejects duplicate values in the indexed column(s). The usual pick for email or username.

UNIQUE INDEX (email)

Regular (Secondary) Index

A plain B-Tree index on a column you filter or join on. This is most of what you'll create.

INDEX (status)

Composite Index

One index over several columns. Column order matters because of the leftmost prefix rule.

INDEX (country, city, zip)

Full-Text Index

Built for word searches in text with MATCH ... AGAINST, including natural language mode.

FULLTEXT INDEX (content)

Spatial Index

For geometry columns, such as "find stores inside this area" queries.

SPATIAL INDEX (location)

Creating Indexes in MySQL

You can declare indexes in CREATE TABLE or add them later. Both are shown below.

Basic Index Creation

SQL
-- Create index when creating table
CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    email VARCHAR(255) UNIQUE,           -- Unique index created automatically
    username VARCHAR(100),
    status ENUM('active', 'inactive', 'banned'),
    country VARCHAR(50),
    city VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_status (status),           -- Single column index
    INDEX idx_location (country, city),  -- Composite index
    INDEX idx_created (created_at)       -- Index for date range queries
);

-- Add index to existing table
CREATE INDEX idx_username ON users (username);

-- Add unique index
CREATE UNIQUE INDEX idx_email ON users (email);

-- Add composite index
CREATE INDEX idx_status_country ON users (status, country);

-- Drop an index
DROP INDEX idx_username ON users;

-- Show indexes on a table
SHOW INDEX FROM users;
Adding an index to a busy production table

On InnoDB in MySQL 5.6+ and 8.0, adding a secondary index is an online operation, so reads and writes keep going. The catch is the metadata lock. If a long transaction or a slow report is holding the table open, your ALTER/CREATE INDEX sits in Waiting for table metadata lock in SHOW PROCESSLIST, and every new query on that table queues up behind it. Check for long running transactions first, and set a short lock_wait_timeout in the session so the DDL gives up instead of stalling the app. Also notice that the example above creates idx_email on a column already declared UNIQUE; MySQL will happily keep both, so you'd pay for a duplicate index.

Analyzing Query Performance with EXPLAIN

EXPLAIN shows how MySQL plans to run a query and which index, if any, it picked. I run it before and after adding any index.

SQL - Using EXPLAIN
-- Basic EXPLAIN
EXPLAIN SELECT * FROM users WHERE email = 'user@example.com';

-- EXPLAIN with FORMAT=JSON for detailed info
EXPLAIN FORMAT=JSON SELECT * FROM users WHERE status = 'active' AND country = 'US';

-- EXPLAIN ANALYZE (MySQL 8.0+) - Actually runs the query
EXPLAIN ANALYZE SELECT * FROM users WHERE created_at > '2024-01-01';

Understanding EXPLAIN Output

Column Meaning What to Look For
type Join type / access method const, ref, range = Good
ALL = Full table scan
possible_keys Indexes that could be used Should list relevant indexes
key Index actually used Should not be NULL for indexed queries
rows Estimated rows to examine Lower is better
Extra Additional information Using index = Covering index
Using filesort = Extra sorting needed

Composite Indexes: The Leftmost Prefix Rule

A composite index is sorted by its first column, then by the second within each first value, and so on, like a phone book sorted by last name and then first name. That's why the leftmost prefix rule exists: MySQL can only use the index starting from the left, without skipping columns. Get this wrong and you end up with an index that looks right in a migration and never gets picked.

Composite Index: (country, city, zip_code) country city zip_code Which queries can use this index? ✓ WHERE country = 'US' ✓ WHERE country = 'US' AND city = 'NYC' ✓ WHERE country = 'US' AND city = 'NYC' AND zip... ✗ WHERE city = 'NYC' ✗ WHERE zip_code = '10001' ⚠ WHERE country = 'US' AND zip... Index columns must be used from left to right without gaps

The leftmost prefix rule decides which queries can use a composite index

Picking the column order

Start from your queries, not from the columns. Columns compared with = go first, and a range column (>, BETWEEN, LIKE 'abc%') goes after them, because MySQL stops using later columns once it hits a range. Among the equality columns, putting the more selective one first is a reasonable default, but a column that many queries filter on alone may deserve the first slot instead.

Covering Indexes

If an index contains every column a query needs, MySQL can answer the query from the index alone and never touch the table rows. That's a covering index, and it skips a lookup per matching row, which adds up fast when a query returns thousands of rows.

SQL - Covering Index Example
-- Create a covering index for a common query
CREATE INDEX idx_covering ON orders (customer_id, status, total, created_at);

-- This query is "covered" - no table access needed!
SELECT customer_id, status, total, created_at 
FROM orders 
WHERE customer_id = 123 AND status = 'completed';

-- EXPLAIN will show "Using index" in the Extra column
EXPLAIN SELECT customer_id, status, total, created_at 
FROM orders 
WHERE customer_id = 123 AND status = 'completed';

-- This query is NOT covered (needs 'shipping_address')
SELECT customer_id, status, total, shipping_address 
FROM orders 
WHERE customer_id = 123;
-- MySQL must look up rows in the table for shipping_address
Covering Index vs Non-Covering Index Covering Index (Fast) Index contains all needed columns ✓ Query Complete Non-Covering Index (Slower) Index finds rows, needs more data Row IDs Table Data

Covering indexes eliminate the need for additional table lookups

Index Trade-offs: The Cost of Indexes

Indexes aren't free. Each one you add costs you in three places.

1

Storage Space

Each index consumes disk space. A table with many indexes can have index data larger than the actual table data.

2

Write Performance

Every INSERT, UPDATE, and DELETE also has to update each affected index. More indexes means slower writes.

3

Maintenance Overhead

Indexes fragment over time and statistics drift, so they occasionally need ANALYZE TABLE or a rebuild. More indexes means more of that work.

Don't Over-Index!

Adding an index on every column "just in case" slows down writes and wastes storage, and the optimizer still picks only one or two per table access. Index columns that are:

  • Frequently used in WHERE clauses
  • Used in JOIN conditions
  • Used in ORDER BY or GROUP BY
  • Part of a query that actually shows up as slow

Practical Indexing Rules

1. Index Columns in WHERE Clauses

SQL
-- If you frequently run this query:
SELECT * FROM orders WHERE customer_id = ? AND status = 'pending';

-- Create an index to support it:
CREATE INDEX idx_customer_status ON orders (customer_id, status);

2. Index Foreign Key Columns

Foreign key columns show up in almost every JOIN, so index them. InnoDB actually creates an index for a foreign key automatically if one doesn't exist, but naming it yourself keeps your schema and migrations readable. Other databases, PostgreSQL included, don't do this for you.

SQL
CREATE TABLE order_items (
    id INT PRIMARY KEY AUTO_INCREMENT,
    order_id INT NOT NULL,
    product_id INT NOT NULL,
    quantity INT,
    price DECIMAL(10,2),
    
    -- Always index foreign keys!
    INDEX idx_order_id (order_id),
    INDEX idx_product_id (product_id),
    
    FOREIGN KEY (order_id) REFERENCES orders(id),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

3. Avoid Indexing Low-Cardinality Columns

Cardinality is the number of distinct values in a column. If a column has two values split roughly 50/50, an index barely narrows anything down, and MySQL will often ignore it and scan the table anyway. Low-cardinality columns still work well as the second column of a composite index.

Column Cardinality Index Value
email (unique per user) High (1M unique values) Excellent
created_at (timestamps) High (many unique values) Excellent
country (~200 values) Medium Good for filtering subsets
status (active/inactive) Low (2 values) Poor alone
is_deleted (0/1) Very Low (2 values) Usually not worth it

4. Use Prefix Indexes for Long Text Columns

MySQL lets you index only the first N characters of a string column. The index gets smaller, but it can't act as a covering index for that column, and you can't use it for ORDER BY on the full value. If you see ERROR 1071 (42000): Specified key was too long on an older setup, a prefix index is the usual fix.

SQL
-- For long text columns, index only a prefix
CREATE INDEX idx_title_prefix ON articles (title(50));

-- For URLs, the first 100 chars are usually enough
CREATE INDEX idx_url_prefix ON links (url(100));

-- This reduces index size while maintaining usefulness

5. Monitor and Remove Unused Indexes

SQL - Find Unused Indexes (MySQL 8.0+)
-- Check index usage statistics
SELECT 
    object_schema AS database_name,
    object_name AS table_name,
    index_name,
    count_star AS total_accesses,
    count_read AS reads,
    count_write AS writes
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'your_database'
    AND index_name IS NOT NULL
ORDER BY count_star ASC;

-- Indexes with 0 reads might be candidates for removal
-- But verify first - they might be used by rare but important queries!

Real-World Optimization Example

Here's the full loop on a typical e-commerce orders table: find the slow queries, confirm with EXPLAIN, add indexes, and check again.

Step 1: Analyze the Schema and Queries

SQL - Original Schema
CREATE TABLE orders (
    id INT PRIMARY KEY AUTO_INCREMENT,
    customer_id INT NOT NULL,
    status ENUM('pending', 'processing', 'shipped', 'delivered', 'cancelled'),
    total DECIMAL(10,2),
    shipping_country VARCHAR(50),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- Common queries that are running slow:
-- 1. Get all orders for a customer
SELECT * FROM orders WHERE customer_id = 12345;

-- 2. Get recent pending orders
SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC;

-- 3. Get orders by country and status for reporting
SELECT shipping_country, COUNT(*), SUM(total) 
FROM orders 
WHERE status = 'delivered' AND created_at > '2024-01-01'
GROUP BY shipping_country;

Step 2: Use EXPLAIN to Identify Problems

SQL - Before Optimization
EXPLAIN SELECT * FROM orders WHERE customer_id = 12345;

-- Output shows:
-- type: ALL (full table scan!)
-- rows: 1000000 (scanning all rows)
-- Extra: Using where

-- This is BAD - every query scans the entire table!

Step 3: Add Strategic Indexes

SQL - Adding Indexes
-- For customer lookups
CREATE INDEX idx_customer ON orders (customer_id);

-- For status + date queries (composite, ordered by selectivity and query pattern)
CREATE INDEX idx_status_created ON orders (status, created_at);

-- For reporting queries
CREATE INDEX idx_status_created_country ON orders (status, created_at, shipping_country);

-- Verify indexes are created
SHOW INDEX FROM orders;

Step 4: Verify Improvement

SQL - After Optimization
EXPLAIN SELECT * FROM orders WHERE customer_id = 12345;

-- Output now shows:
-- type: ref (using index!)
-- possible_keys: idx_customer
-- key: idx_customer
-- rows: 47 (only scanning matching rows!)
-- Extra: NULL

-- Query time drops from seconds to milliseconds on a table this size

Common Indexing Mistakes to Avoid

Mistake 1: Functions on Indexed Columns

Wrap an indexed column in a function and MySQL can't use the index, because the index stores created_at, not YEAR(created_at):

SQL
-- BAD: Index on created_at won't be used
SELECT * FROM orders WHERE YEAR(created_at) = 2024;

-- GOOD: Rewrite to allow index usage
SELECT * FROM orders 
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';

Mistake 2: Leading Wildcards in LIKE

A B-Tree is sorted from the first character, so a pattern that starts with % gives MySQL nowhere to start:

SQL
-- BAD: Index won't be used (leading wildcard)
SELECT * FROM users WHERE email LIKE '%@gmail.com';

-- GOOD: Trailing wildcard can use index
SELECT * FROM users WHERE email LIKE 'john%';

-- For email domain searches, consider a separate indexed column
-- or use a full-text index

Mistake 3: Implicit Type Conversion

This one is easy to miss because the query still returns the right rows. Compare a VARCHAR column to a number and MySQL converts every row's value to a number before comparing, which means a full scan. ORMs and loosely typed bindings in PHP or Node can cause this without you writing the number yourself:

SQL
-- If phone_number is VARCHAR:

-- BAD: Numeric comparison causes type conversion
SELECT * FROM users WHERE phone_number = 5551234567;

-- GOOD: String comparison matches column type
SELECT * FROM users WHERE phone_number = '5551234567';

Mistake 4: OR Conditions Without Proper Indexes

With OR across different columns, one composite index can't cover both sides. MySQL either uses an index merge (if each column has its own index) or falls back to a scan:

SQL
-- This might not use indexes efficiently
SELECT * FROM orders WHERE customer_id = 123 OR status = 'pending';

-- Solution 1: Ensure both columns are indexed
CREATE INDEX idx_customer ON orders (customer_id);
CREATE INDEX idx_status ON orders (status);

-- Solution 2: Use UNION for complex cases
SELECT * FROM orders WHERE customer_id = 123
UNION
SELECT * FROM orders WHERE status = 'pending';

When I'd Add an Index (and When I Wouldn't)

Here's what I go through before adding an index to a production table.

1

Is there a real slow query?

I start from the slow query log or an APM trace, not from the schema. If nothing slow is hitting the column, the index is pure write cost.

2

Can an existing index be extended instead?

If you already have (customer_id) and need (customer_id, status), replace the first rather than keeping both. The longer index serves both queries.

3

Is the table write heavy?

On log, event, or queue tables that take constant inserts and are rarely read by arbitrary columns, I keep indexes to the bare minimum. Same for small lookup tables of a few hundred rows, where a scan is already cheap.

4

Is the column selective enough?

A lone index on a boolean or a two-value status usually gets ignored. Put it second in a composite index, or skip it.

5

Did EXPLAIN confirm it?

After deploying, I check that key shows the new index and rows dropped. If the optimizer ignores it, it's dead weight and should go.

Where indexes stop helping

Indexes won't fix a query that returns half the table, an N+1 loop in your ORM, or a report that aggregates millions of rows on every page load. For those, look at pagination, eager loading, caching, or a summary table instead of piling on more indexes.

MySQL Database Performance Optimization SQL B-Tree Query Tuning
Mayur Dabhi

Written by Mayur Dabhi

Full-stack developer passionate about building scalable web applications, optimizing database performance, and sharing knowledge with the developer community.