An index helps a database find rows without checking every row. Indexes can reduce the work a query needs to do, but they use storage and add work to writes.

These examples use MySQL's InnoDB engine. The people table has an id, a name, and a city. Its primary key, id, uniquely identifies each row.

Table scans

A full table scan examines every row to find those that match a query. The people table is ordered by id. To find every person named Zoe, the database checks each row's name.

Table Scan

SELECT * FROM people WHERE name = 'Zoe'
Primary index · ID + rowKeys47Page 101Liam02Ava03NoahPage 204Emma05Oliver06MiaPage 307Leo08Isla09Zoe
0 of 9 rows checked

Index and page routing

InnoDB's indexes use a B-tree structure. The tree groups index entries into pages. Branch pages contain routing keys that direct a lookup to a child page. Leaf pages contain the entries.

For id = 9, a routing key directs the lookup to the last leaf page. The database skips the other leaf pages.

Tree Lookup

SELECT * FROM people WHERE id = 9
Primary index · ID + rowKeys47Page 101Liam02Ava03NoahPage 204Emma05Oliver06MiaPage 307Leo08Isla09Zoe

Leaf pages are linked in key order. A range query can find its first matching entry, then follow neighboring leaf pages until it reaches the end of the range.

Secondary indexes

InnoDB stores full rows in the leaves of its primary index, ordered by the primary key. A secondary index provides another order for finding those rows.

An index on name stores names alongside primary keys, such as Zoe → 9. To fetch Zoe's full row, the database looks up Zoe in the name index to get ID 9, then looks up that ID in the primary index.

Secondary Lookup

SELECT * FROM people WHERE name = 'Zoe'
Name index · name + IDKeysLeoNoahPage 1Ava→ 2Emma→ 4Isla→ 8Page 2Leo→ 7Liam→ 1Mia→ 6Page 3Noah→ 3Oliver→ 5Zoe→ 9Primary index · ID + rowKeys47Page 101Liam02Ava03NoahPage 204Emma05Oliver06MiaPage 307Leo08Isla09Zoe

Covering indexes

A covering index contains all the columns a particular query needs. The name index already contains name and the primary key, id, so a query for those two columns can read them from the name index alone. It avoids the second lookup shown above.

Covering Index

SELECT name, id FROM people WHERE name = 'Zoe'
Name index · name + IDKeysLeoNoahPage 1Ava→ 2Emma→ 4Isla→ 8Page 2Leo→ 7Liam→ 1Mia→ 6Page 3Noah→ 3Oliver→ 5Zoe→ 9Primary index · ID + rowKeys47Page 101Liam02Ava03NoahPage 204Emma05Oliver06MiaPage 307Leo08Isla09Zoe

Whether an index covers a query depends on the columns the query needs. Adding city to the result would require fetching the row, because the name index does not contain that value.

Composite indexes

A composite index is defined on more than one column. An index on (city, name) sorts entries by city first, then by name within each city.

All Austin entries sit together. Zoe can appear in several city groups, so a search by name alone does not identify one continuous range of entries.

Column Order

WHERE city = 'Austin'
City + name indexKeysBostonAustinAva→ 2Mia→ 6Zoe→ 9BostonAva→ 12Mia→ 16Zoe→ 19

This grouping resembles partitioning: specifying a city narrows the search to that city's names. Here, the groups are ranges within one ordered index. Table partitioning divides the table's rows into separate storage partitions.

Choose column order to support the filters and sorting your queries use.

Paying for faster reads

An insert adds an entry to the primary index and every secondary index. Updating an indexed value also changes its index entry. Each additional index uses storage and adds work to writes.

Write Cost

INSERT INTO people VALUES (9, 'Zoe', 'Boston')
Primary indexKeys47Last page07Leo08IslaName indexKeysLeoNoahLast pageNoah→ 3Oliver→ 5City indexKeysBostonLast pageBoston→ 4Boston→ 8

The demo counts index entries, not physical disk writes. An index needs to skip enough work to help: fetching most rows through a secondary index can cost more than scanning the table.

Comparing real access paths

Different access paths can return the same rows while doing different amounts of work. Compare the rows each path examines and its execution time.

Use EXPLAIN to inspect the access path and estimated row counts for your application's queries. For a SELECT you can safely run, EXPLAIN ANALYZE executes the query and reports actual row counts and timing. Check these plans before adding another index.

Code examples

The conceptual-database crate contains three runnable Rust examples, an HTTP API, and a MySQL comparison script. All links below point to the same pinned revision.

The Rust examples store rows in an in-memory hash map and keep secondary indexes of row IDs. Their row and index counts represent logical work. They do not model InnoDB disk I/O or covering reads.

  • Indexing walkthrough demonstrates primary and secondary lookups, date ranges, latest orders, forced scans, and rollback of a failed batch.

  • Access-path comparison loads one million generated orders and compares five ways to answer the same query:

    SELECT * FROM orders
    WHERE product_id = 7
      AND state = 'cancelled';

    Every access path returns the same 24 orders, but reads different numbers of index entries and rows:

    Access pathIndex entries readRows fetched
    Full scan01,000,000
    State index, then filter by product20,08720,087
    Product index, then filter by state886886
    Intersect the product and state indexes20,96224
    Composite product/state index2424
    • Index intersection walks two ordered ID lists and fetches rows whose IDs appear in both.
    • Work counters report index entries read and full rows fetched. These counts are from the Rust program, not MySQL measurements.

    Run the pinned version with Rust installed:

    git clone https://github.com/wcygan/examples.git
    cd examples
    git checkout --detach 3205ebd3742799518de1e3c616fe218d9c90d5dd
    cargo run --release --locked -p conceptual-database --example index_strategies
  • Tree implementation comparison compares database workloads using Rust's standard B-tree and bbtree.

    • Swapping trees rebuilds the indexes with the other tree and checks that query results stay the same.
  • HTTP API examples show requests for seeding rows, querying indexes, and comparing forced scans. The setup commands include starting the server.

  • MySQL 8.4 comparison creates an InnoDB orders table, generates data, and compares access paths with EXPLAIN ANALYZE. Run it in a disposable database: it drops and recreates orders. Its data generator differs from the Rust examples, so its row counts differ from the table above. Inspect the resulting plans to see which indexes MySQL actually uses.