Step-by-step
-
1
Understand What a B-tree Index Does
A B-tree index is a sorted copy of one or more columns stored separately from the table. When MySQL executes
WHERE email = 'alice@example.com', without an index it reads every row (full table scan). With an index onemail, it traverses the B-tree — typically 3-4 nodes for a million-row table — and jumps directly to the matching rows.The trade-off: an index consumes disk space, and every INSERT/UPDATE/DELETE must maintain the index structure in addition to the row data. More indexes = faster reads, slower writes, more storage.
sql-- Without an index on email: scans all rows SELECT id, name FROM users WHERE email = 'alice@example.com'; -- EXPLAIN → type: ALL, rows: 500000 -- After adding an index CREATE INDEX idx_users_email ON users (email); -- Same query SELECT id, name FROM users WHERE email = 'alice@example.com'; -- EXPLAIN → type: ref, rows: 1 -
2
Identify Which Columns Need an Index
Index candidates are columns that appear in:
- WHERE clauses:
WHERE status = ?,WHERE user_id = ? - JOIN conditions:
JOIN orders ON orders.user_id = users.id— both sides of the JOIN need an index - ORDER BY:
ORDER BY created_at DESC— index prevents filesort - GROUP BY: similar to ORDER BY
Not every column deserves an index. A
is_publishedboolean with 90% of rows being1has near-zero selectivity — an index on it will not be used because scanning those rows is cheaper than traversing the index.sql-- High selectivity = good index candidate -- email: every value is unique → perfect CREATE INDEX idx_users_email ON users (email); -- slug: unique per record → perfect CREATE UNIQUE INDEX idx_posts_slug ON posts (slug); -- user_id on orders: one user has many orders → good CREATE INDEX idx_orders_user_id ON orders (user_id); -- Low selectivity = poor index candidate — avoid -- is_published TINYINT(1): only 2 distinct values → bad -- gender ENUM('m','f','other'): only 3 values → likely bad - WHERE clauses:
-
3
Build Composite Indexes in the Right Order
A composite index on
(a, b, c)can be used for queries ona,a + b, ora + b + c— but NOT forbalone orcalone. This is the leftmost prefix rule.The correct column order for a composite index:
- Equality conditions first (
status = ?) - Range conditions next (
created_at > ?) - ORDER BY columns last
This order matches how MySQL narrows the search space.
sql-- Query: WHERE status = 'active' AND created_at > '2024-01-01' ORDER BY created_at DESC -- Bad order: range before equality wastes the index CREATE INDEX idx_bad ON orders (created_at, status); -- Good order: equality first, range/sort second CREATE INDEX idx_orders_status_created ON orders (status, created_at); -- MySQL uses (status) to narrow to 'active' rows, -- then (created_at) to scan in order — no filesort needed -- Verify with EXPLAIN EXPLAIN SELECT id FROM orders WHERE status = 'active' AND created_at > '2024-01-01' ORDER BY created_at DESC; - Equality conditions first (
-
4
Use Covering Indexes to Avoid Table Lookups
A covering index contains all the columns a query needs — SELECT, WHERE, and ORDER BY. MySQL can answer the query entirely from the index without touching the main table. In EXPLAIN this shows as
Extra: Using index.Covering indexes are especially useful on high-traffic queries that read a small, predictable set of columns. Add the SELECT columns at the end of the index (after the WHERE and ORDER BY columns).
sql-- Query reads only id, status, created_at from orders SELECT id, status, created_at FROM orders WHERE user_id = 42 ORDER BY created_at DESC; -- A covering index includes all referenced columns CREATE INDEX idx_orders_covering ON orders (user_id, created_at, status, id); -- ^^^^^^^ WHERE ^^^^^^^^^^^ ORDER BY ^^^^^^^^ SELECT -- EXPLAIN will now show: Extra = Using index -- No table lookup required — 100% served from the index -
5
Add UNIQUE Indexes for Natural Keys
A UNIQUE index serves two purposes: it enforces a uniqueness constraint at the database level (not just the application) and it is a normal B-tree index that speeds lookups. Add a UNIQUE index on any column that must be unique by definition:
email,slug,username,invoice_number.The database constraint catches race conditions and bugs that application-level checks miss — two simultaneous requests cannot both insert the same email if a UNIQUE index is in place.
sql-- Single-column unique index ALTER TABLE users ADD UNIQUE INDEX idx_users_email_unique (email); -- Composite unique index (combination must be unique) -- e.g. a user can only follow a given tag once ALTER TABLE user_tag_follows ADD UNIQUE INDEX idx_user_tag_unique (user_id, tag_id); -- In a Laravel migration Schema::table('users', function (Blueprint $table) { $table->unique('email'); }); Schema::table('user_tag_follows', function (Blueprint $table) { $table->unique(['user_id', 'tag_id']); }); -
6
Always Index Foreign Key Columns
InnoDB creates the foreign key constraint but does NOT automatically create an index on the referencing column in some configurations. Without an index on the FK column, every DELETE or UPDATE on the parent table triggers a full scan of the child table to check for dependent rows.
Check after running migrations: if an FK column is missing an index, add it manually.
sql-- Check for un-indexed foreign keys SELECT kcu.TABLE_NAME, kcu.COLUMN_NAME, kcu.CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE kcu LEFT JOIN information_schema.STATISTICS s ON s.TABLE_SCHEMA = kcu.TABLE_SCHEMA AND s.TABLE_NAME = kcu.TABLE_NAME AND s.COLUMN_NAME = kcu.COLUMN_NAME WHERE kcu.REFERENCED_TABLE_NAME IS NOT NULL AND kcu.TABLE_SCHEMA = 'your_database' AND s.INDEX_NAME IS NULL; -- Returns FK columns that have no index — add one for each result -- In a Laravel migration, this is always safe Schema::table('orders', function (Blueprint $table) { $table->index('user_id'); // index the FK $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); }); -
7
Add Indexes via Laravel Migrations
In Laravel, indexes are added in migration files on the
Blueprintobject. The syntax is clean and the migration file acts as a permanent record of every index in the schema.php<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::table('orders', function (Blueprint $table) { // Single-column index $table->index('user_id'); // Composite index (equality + range) $table->index(['status', 'created_at'], 'idx_orders_status_created'); // Unique index $table->unique('reference_number'); // Covering index — include additional columns $table->index(['user_id', 'created_at', 'status', 'id'], 'idx_orders_covering'); }); } public function down(): void { Schema::table('orders', function (Blueprint $table) { $table->dropIndex('idx_orders_status_created'); $table->dropIndex('idx_orders_covering'); $table->dropUnique(['reference_number']); }); } }; -
8
Find and Drop Unused Indexes
Unused indexes consume disk space and silently slow down every write. MySQL tracks index usage in the
sysschema — after a week of production traffic, any index with zero reads is a candidate for removal.sql-- Find indexes never used in queries (MySQL 5.7+ with sys schema) SELECT object_schema, object_name AS table_name, index_name FROM sys.schema_unused_indexes WHERE object_schema = 'your_database' ORDER BY object_name; -- Also check for duplicate / redundant indexes SELECT * FROM sys.schema_redundant_indexes WHERE table_schema = 'your_database'; -- e.g. index on (a) is redundant if (a, b) already exists -- because (a, b) satisfies all queries that (a) alone would -- Drop an index ALTER TABLE orders DROP INDEX idx_old_unused_index; -- Or in Laravel -- $table->dropIndex('idx_old_unused_index');
Tips & gotchas
- When in doubt, look at <code>EXPLAIN</code> before and after adding an index — the <code>type</code> column tells you whether the index is actually being used.
- Indexes on columns with very few distinct values (booleans, status ENUMs with 2-3 values) are usually ignored by the optimizer. Use a composite index that pairs a low-selectivity column with a high-selectivity one.
- On MySQL/InnoDB, the primary key is part of every secondary index (it is how InnoDB resolves the row from the index). Keep your primary key short — a large UUID primary key inflates every index on the table.
- Run <code>ANALYZE TABLE table_name</code> after bulk-loading data to update index statistics. Stale statistics cause the optimizer to choose the wrong execution plan.
- Be careful adding indexes to large production tables during peak hours — <code>ALTER TABLE ADD INDEX</code> acquires a metadata lock that can queue up writes. Use <code>pt-online-schema-change</code> or MySQL 8+ online DDL for zero-downtime index additions.
Wrapping up
The right index on the right column is often the single biggest performance win available without rewriting application logic. Start with the queries that are provably slow, use EXPLAIN to confirm the access type, then add the minimum index that covers the WHERE, ORDER BY, and — if needed — the SELECT. Review unused indexes quarterly and remove the dead weight.