An index is a separate, sorted structure the database keeps next to a table so it can find rows without reading all of them. It is the single biggest lever you have on read performance. It also has a cost, so you need to know which kind of index to create, and for which queries.
Why indexes exist#
Picture a users table with 10 million rows and this query:
SELECT * FROM users WHERE email = 'alex@example.com';
Without an index, the database reads every row and checks the email. That is a full table scan, :
| Row | Result | |
|---|---|---|
| 1 | john@example.com |
No |
| 2 | sarah@example.com |
No |
| … | … | … |
| 7,234,891 | alex@example.com |
Found, but it keeps scanning in case there are more |
With an index on email, the database searches a sorted structure and jumps straight to the right row, .
It works like the index at the back of a textbook. To find "deadlock" you don't flip every page. You look up "deadlock → page 142" and go there.
Here is a small table, stored in insertion order:
| Row | id | name | |
|---|---|---|---|
| 1 | 5 | john@... |
John |
| 2 | 2 | sarah@... |
Sarah |
| 3 | 9 | alex@... |
Alex |
And a separate, sorted index on email:
| Points to | |
|---|---|
alex@example.com |
Row 3 |
john@example.com |
Row 1 |
sarah@example.com |
Row 2 |
The index stores the indexed column's values in sorted order, each with a pointer back to its row. In practice the "sorted list" is a B-tree, explained in Read Heavy Indexing.
Types of index#
Primary vs secondary#
- Primary index: built on the primary key. Declaring
PRIMARY KEYcreates it for you. It is unique. - Secondary index: any other index you create. A table can have many, unique or not.
CREATE TABLE users (
id INT PRIMARY KEY, -- primary index, created automatically
email VARCHAR(255),
name VARCHAR(255)
);
CREATE INDEX idx_users_email ON users (email); -- secondary
CREATE INDEX idx_users_name ON users (name); -- secondary
You get the primary index for free. Every other index is a decision based on your query patterns.
Clustered vs non-clustered#
This is the most important distinction to understand.
A clustered index stores the table's rows in the order of the index key. The table is the index. A table can only have one, because rows can only be physically ordered one way.
Here is a table clustered on id:
| id | |
|---|---|
| 1 | alice@... |
| 2 | alex@... |
| 3 | john@... |
| 4 | sarah@... |
- Looking up
id = 3: the row is right there. WHERE id BETWEEN 2 AND 4: the rows are next to each other, so it's one sequential read.
A non-clustered index is a separate structure that points back to the rows, like the textbook index separate from the pages.
A non-clustered index on email, sitting next to the same table (still clustered on id), looks like this:
| email (index, sorted) | Points to |
|---|---|
alex@... |
id 2 |
alice@... |
id 1 |
john@... |
id 3 |
sarah@... |
id 4 |
Looking up alex@... takes two steps: find it in the index, then fetch the row from the table. That second step is called a bookmark lookup or table lookup.
| Clustered | Non-clustered | |
|---|---|---|
| Per table | One | Many |
| Stores | The rows themselves, in key order | Keys plus pointers to rows |
| Point lookup | Fastest, the data is right there | One extra hop to the table |
| Range queries | Excellent, rows are adjacent | Slower, rows can be scattered |
| Usually | The primary key | Every secondary index |
Databases differ here. MySQL's InnoDB always clusters the table on its primary key. SQL Server lets you choose the clustered index. PostgreSQL stores tables as an unordered heap, so all of its indexes are effectively non-clustered (its CLUSTER command reorders a table once, but doesn't keep it ordered).
Composite (multi-column)#
An index on several columns, sorted by the first column, then the second, and so on. Column order matters.
CREATE INDEX idx_name_age ON users (last_name, first_name, age);
The index entries are sorted like this:
| last_name | first_name | age |
|---|---|---|
| Adams | Alice | 25 |
| Adams | Bob | 30 |
| Adams | Bob | 45 |
| Baker | Alice | 22 |
| Baker | Charlie | 35 |
| Clark | Alice | 28 |
The leftmost prefix rule: the index helps only when the query filters on a leading prefix of its columns.
| Uses the index ✓ | Can't use the index ✗ |
|---|---|
WHERE last_name = 'Adams' |
WHERE first_name = 'Bob' |
WHERE last_name = 'Adams' AND first_name = 'Bob' |
WHERE age = 30 |
WHERE last_name = 'Adams' AND first_name = 'Bob' AND age = 30 |
WHERE first_name = 'Bob' AND age = 30 |
A phone book sorted by last name, then first name, makes "Adams" and "Adams, Bob" easy to find. Finding every "Bob" means reading the whole book.
Covering#
If an index contains every column a query needs, the database answers from the index alone and never touches the table.
Take this query:
SELECT email, name FROM users WHERE email = 'alex@example.com';
| Index | Steps |
|---|---|
On (email) only |
1. Find the email in the index, giving a row pointer. 2. Go to the table to fetch name (an extra read). |
On (email, name) |
1. Find the email in the index. name is stored right there. Done. |
CREATE INDEX idx_users_email_name ON users (email, name);
The query is now "covered" by the index.
Summary#
Here are all six kinds in one place:
| Index | What it is | Remember |
|---|---|---|
| Primary | On the primary key, automatic | Unique, one per table |
| Secondary | On any other column | Many per table |
| Clustered | Rows stored in key order | One per table, best for ranges |
| Non-clustered | Separate structure with pointers | Many per table, extra hop |
| Composite | Several columns | Leftmost prefix rule |
| Covering | Holds every column the query needs | Skips the table lookup |
Which index for which query#
Start from the query, then pick the index that serves it:
| Situation | Index | Example |
|---|---|---|
| Look up by ID | Primary (already there) | WHERE id = 123 |
| Search by another column | Secondary, non-clustered | Find user by email, order by customer_id, product by SKU |
| Scan ranges of adjacent data | Clustered on that key | Last 7 days of orders on created_at; chat messages on (conversation_id, sent_at) |
| Filter on several columns | Composite, equality columns first, range column last | (customer_id, created_at) |
| Very hot query returning few columns | Covering | (status, email, name) |
The composite and covering rows come from queries like these:
-- Composite (customer_id, created_at)
WHERE customer_id = 123 AND created_at > '2025-01-01'
-- Composite (category, price)
WHERE category = 'electronics' AND price BETWEEN 100 AND 500
-- Covering (status, email, name)
SELECT email, name FROM users WHERE status = 'online'
Why "equality first, range last"? With (customer_id, created_at), all of one customer's rows are together and sorted by date, so the range is one contiguous slice. With (created_at, customer_id), the date range spans every customer and the database has to filter through all of them.
Why only one clustered index? If orders are clustered on created_at for time-range queries, you still need a non-clustered index on order_id for direct lookups.
Covering indexes are worth their extra storage for queries that run thousands of times a second and return only a few columns.
The same ideas in DynamoDB#
DynamoDB has no CREATE INDEX in the SQL sense, but the same concepts map across:
| Relational idea | DynamoDB equivalent |
|---|---|
| Primary index | Partition key (or partition key + sort key) |
| Clustered within a group | Items with the same partition key are stored sorted by sort key |
| Composite (equality, then range) | Partition key = equality, sort key = range |
| Secondary index | Global and local secondary indexes (GSI, LSI) |
Key takeaways#
- An index is a separate sorted structure (usually a B-tree) that turns scans into lookups.
- A clustered index stores rows in key order (one per table, great for ranges). Non-clustered indexes point back to rows (many per table, one extra hop).
- Composite indexes follow the leftmost prefix rule. Put equality columns first and the range column last.
- A covering index includes every column a query needs, so the table is never touched.
- Index for your actual query patterns. Every index has a write cost (see Indexing Trade-offs).