Programming Intermediate 10 min

How to Add the Right Database Indexes

Indexes are the most impactful performance tool available to a developer without touching application code — a single correctly placed index can turn a 10-second query into a 10-millisecond one. They are also the most commonly misused: too few and queries crawl, too many and writes slow to a grind.

This guide covers the mechanics of B-tree indexes, how to identify which columns need them, composite index ordering, covering indexes, UNIQUE indexes, and how to detect indexes that are wasting disk space and write overhead.

Step-by-step

  1. 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 on email, 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. 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_published boolean with 90% of rows being 1 has 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
  3. 3

    Build Composite Indexes in the Right Order

    A composite index on (a, b, c) can be used for queries on a, a + b, or a + b + c — but NOT for b alone or c alone. This is the leftmost prefix rule.

    The correct column order for a composite index:

    1. Equality conditions first (status = ?)
    2. Range conditions next (created_at > ?)
    3. 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;
  4. 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. 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. 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. 7

    Add Indexes via Laravel Migrations

    In Laravel, indexes are added in migration files on the Blueprint object. 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. 8

    Find and Drop Unused Indexes

    Unused indexes consume disk space and silently slow down every write. MySQL tracks index usage in the sys schema — 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.

#Database #Indexes #Performance
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.