SQL Indexes: How They Work and When to Use Them
An index is a separate, sorted structure that lets the database find rows without reading the whole table. It works like the index at the back of a book: instead of reading every page to find "JOIN", you look the word up and go straight to the right pages. Indexes make reads faster and writes a little slower.
What Is an Index?
Take SELECT * FROM users WHERE email = 'x@example.com'. Without an index on email, the database has to read every row and compare its email: a full table scan. With an index, it looks the value up in a sorted structure and jumps to the one matching row: an index seek.
| Full table scan | Index seek | |
|---|---|---|
| Rows read to find one email among 1,000,000 | Up to 1,000,000 | About 20 steps down a B-tree, then the row |
| Grows with table size | In proportion to the rows | Very slowly (logarithmically) |
| Extra storage | None | The index itself |
| Cost on INSERT / UPDATE / DELETE | None | The index must be updated too |
The Playground is a SQLite database, and you can see which indexes it has by querying its catalogue table, sqlite_master:
SELECT name AS index_name, tbl_name AS table_name
FROM sqlite_master
WHERE type = 'index'
ORDER BY tbl_name;
▶ Run this query in the SQL Playground
| index_name | table_name |
|---|---|
| sqlite_autoindex_employees_1 | employees |
| sqlite_autoindex_payments_1 | payments |
| sqlite_autoindex_products_1 | products |
| sqlite_autoindex_users_1 | users |
There are 4, and nobody wrote CREATE INDEX for them. SQLite created them automatically for the UNIQUE columns: users.email, employees.email, products.sku and payments.transaction_id. Every database does this, because enforcing "no duplicates" needs a fast way to look a value up. (The INTEGER PRIMARY KEY columns need no separate index in SQLite: the table itself is stored in id order.)
How a B-Tree Index Works
Most indexes are B-trees: balanced trees whose leaves hold the indexed values in sorted order, each with a pointer to its row. To find a value, the database starts at the root and at each level picks the branch whose range contains it. Even for millions of rows that is only three or four levels.
Root: [ ... | M | ... ]
/ \
Branch: [ A ... F | G ... L ] [ M ... R | S ... Z ]
| |
Leaves: values A..F, each values M..R, each
with a pointer to with a pointer to
its table row its table row
Because the leaves are sorted, a B-tree helps with more than equality: ranges (BETWEEN, >), prefixes (LIKE 'abc%'), sorting (ORDER BY) and MIN/MAX. Hash indexes, available in some databases, only help with equality.
Creating and Dropping Indexes
-- a single-column index
CREATE INDEX idx_orders_user_id ON orders (user_id);
-- a unique index: also prevents duplicates
CREATE UNIQUE INDEX idx_users_email ON users (email);
-- a composite (multi-column) index
CREATE INDEX idx_orders_user_date ON orders (user_id, order_date);
-- remove an index
DROP INDEX idx_orders_user_id; -- SQLite, PostgreSQL, Oracle
DROP INDEX idx_orders_user_id ON orders; -- MySQL, SQL Server
A common naming convention is idx_<table>_<columns>. Index names must be unique within a schema (in SQL Server and MySQL, within a table).
Clustered, Non-Clustered, Unique, Composite, Covering
Clustered vs non-clustered
| Clustered index | Non-clustered (secondary) index | |
|---|---|---|
| What it is | The table's rows are stored in the index order | A separate structure with keys and pointers to the rows |
| How many per table | One | Many |
| Typical key | The primary key | Columns you filter or join on |
| After finding a key | The row is right there | One more step to fetch the row (a key lookup), unless the index covers the query |
| Terms by database | SQL Server, MySQL InnoDB (always the primary key); SQLite rowid tables behave like this | All databases |
PostgreSQL has no permanent clustered index: tables are stored as heaps and every index is secondary. A table in SQL Server without a clustered index is also a heap.
Unique index
A unique index both speeds up lookups and rejects duplicates. That is what the automatic Playground indexes do. You can check that the indexed column really is unique:
SELECT COUNT(*) AS rows,
COUNT(DISTINCT email) AS distinct_emails
FROM users;
▶ Run this query in the SQL Playground
| rows | distinct_emails |
|---|---|
| 150 | 150 |
Composite index and the leftmost-prefix rule
A composite index on (user_id, order_date) is sorted by user_id first, then by order_date within each user, like a phone book sorted by last name, then first name. It can be used for:
| Query filters on | Can use (user_id, order_date)? |
|---|---|
user_id = 4 | Yes: the leftmost column |
user_id = 4 AND order_date >= '2024-01-01' | Yes: both columns, in order |
user_id = 4 ORDER BY order_date | Yes: the rows are already in date order per user |
order_date >= '2024-01-01' only | Not efficiently: dates are scattered across all users |
So column order matters. Put the column that is always filtered with = first and a range or sort column after it.
Covering index
An index covers a query when it contains every column the query needs. The database can then answer from the index alone, without visiting the table. SELECT order_date FROM orders WHERE user_id = 4 is covered by an index on (user_id, order_date). SQL Server and PostgreSQL 11+ can add extra non-key columns with INCLUDE (...).
Partial (filtered) and expression indexes
-- only index the rows you query
CREATE INDEX idx_orders_open ON orders (order_date)
WHERE status IN ('pending', 'processing'); -- SQLite, PostgreSQL; SQL Server: filtered index
-- index the result of an expression
CREATE INDEX idx_users_email_lower ON users (LOWER(email)); -- SQLite, PostgreSQL, Oracle, MySQL 8.0.13+
An expression index lets WHERE LOWER(email) = '...' use an index; a plain index on email cannot help that query.
Looking at Indexes and Their Columns
Each database has its own catalogue. In SQLite, pragma_index_list lists a table's indexes and pragma_index_info their columns. Both can be queried with SELECT:
SELECT il.name AS index_name, il."unique" AS is_unique, ii.name AS column_name
FROM pragma_index_list('products') il
JOIN pragma_index_info(il.name) ii;
▶ Run this query in the SQL Playground
| index_name | is_unique | column_name |
|---|---|---|
| sqlite_autoindex_products_1 | 1 | sku |
| Database | List indexes |
|---|---|
| SQLite | SELECT * FROM sqlite_master WHERE type = 'index' |
| PostgreSQL | SELECT * FROM pg_indexes WHERE tablename = 'orders' |
| MySQL | SHOW INDEX FROM orders |
| SQL Server | EXEC sp_helpindex 'orders' |
Reading an Execution Plan
EXPLAIN shows how the database intends to run a query, including whether it uses an index. The command differs: EXPLAIN QUERY PLAN in SQLite, EXPLAIN or EXPLAIN ANALYZE in PostgreSQL and MySQL, and the graphical or SET SHOWPLAN plan in SQL Server. In SQLite the output for an email lookup looks like this:
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE email = 'x@example.com';
-- SEARCH users USING INDEX sqlite_autoindex_users_1 (email=?)
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE city = 'Austin';
-- SCAN users
| Plan says | Meaning |
|---|---|
| SCAN table / Seq Scan / Table Scan | Every row is read |
| SEARCH ... USING INDEX / Index Seek / Index Scan with a condition | The index narrows the rows |
| USING COVERING INDEX / Index Only Scan | Answered from the index alone |
| Key Lookup / Bookmark Lookup (SQL Server) | Found in the index, then fetched from the table |
| USE TEMP B-TREE FOR ORDER BY / Sort | An extra sort step; an index on the sort column could remove it |
The optimizer chooses by estimated cost. An index existing does not mean it is used: if a filter matches most of the table, reading the table directly is cheaper, and the optimizer will skip the index.
Selectivity: Which Columns Are Worth Indexing?
An index helps most when a filter on the column narrows the rows a lot. A rough measure is selectivity: distinct values divided by rows. Close to 1 is highly selective; close to 0 is not. Here it is for some Playground columns:
SELECT 'users.email' AS column_name, COUNT(DISTINCT email) AS distinct_values, COUNT(*) AS rows,
ROUND(1.0 * COUNT(DISTINCT email) / COUNT(*), 3) AS selectivity FROM users
UNION ALL
SELECT 'users.city', COUNT(DISTINCT city), COUNT(*), ROUND(1.0 * COUNT(DISTINCT city) / COUNT(*), 3) FROM users
UNION ALL
SELECT 'users.is_premium', COUNT(DISTINCT is_premium), COUNT(*), ROUND(1.0 * COUNT(DISTINCT is_premium) / COUNT(*), 3) FROM users
UNION ALL
SELECT 'orders.user_id', COUNT(DISTINCT user_id), COUNT(*), ROUND(1.0 * COUNT(DISTINCT user_id) / COUNT(*), 3) FROM orders
UNION ALL
SELECT 'orders.status', COUNT(DISTINCT status), COUNT(*), ROUND(1.0 * COUNT(DISTINCT status) / COUNT(*), 3) FROM orders;
▶ Run this query in the SQL Playground
| column_name | distinct_values | rows | selectivity |
|---|---|---|---|
| users.email | 150 | 150 | 1 |
| users.city | 20 | 150 | 0.133 |
| users.is_premium | 2 | 150 | 0.013 |
| orders.user_id | 114 | 200 | 0.57 |
| orders.status | 6 | 200 | 0.03 |
Email is unique, so a lookup by email finds one row: an ideal index. is_premium has two values, so a filter on it still matches about half the table and an index on it alone rarely helps. Selectivity is only a guide: an index on a low-selectivity column can still pay off when you filter on the rare value, for example the few orders still pending, and a partial index is often the better tool for that.
On real tables of a few hundred rows, like the Playground's, a full scan is already instant and indexes make no measurable difference. They matter from tens of thousands of rows upwards.
Where Indexes Help: WHERE, JOIN, ORDER BY, GROUP BY
| Clause | Helpful index | Example |
|---|---|---|
| WHERE equality | On the filtered column | WHERE email = ? → users(email) |
| WHERE range | On the range column, or after = columns in a composite | WHERE user_id = ? AND order_date >= ? → orders(user_id, order_date) |
| JOIN | On the join column of the table being looked up | JOIN orders o ON o.user_id = u.id → orders(user_id) |
| ORDER BY ... LIMIT | On the sort columns, in the same order | ORDER BY order_date DESC LIMIT 10 → orders(order_date) |
| GROUP BY | On the grouped columns | GROUP BY user_id → orders(user_id) |
| MIN / MAX | On the column | SELECT MAX(order_date) reads one end of the index |
Foreign-key columns such as orders.user_id, order_items.order_id and reviews.product_id are the classic candidates, because they are joined on constantly. Most databases do not index foreign keys automatically (MySQL InnoDB is the exception).
When an Index Is Not Used
| Query pattern | Why the index is skipped | Fix |
|---|---|---|
WHERE LOWER(email) = 'a@x.com' | The index stores email, not LOWER(email) | An expression index, or store the value normalised |
WHERE name LIKE '%phone' | A leading wildcard can match any starting letter | Prefix searches (LIKE 'phone%') or full-text search |
WHERE strftime('%Y', order_date) = '2024' | A function around the column | WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01' |
WHERE user_id = '4' (text vs number) | Implicit type conversion in some databases | Compare with the column's own type |
WHERE order_date >= ? on an index (user_id, order_date) | Not the leftmost column | An index that starts with order_date |
| A filter matching most rows | A scan is cheaper than many lookups | Nothing; this is the right choice |
The date rewrite is worth seeing on real data. Both queries count 2024 orders; the second compares the column directly, so an index on order_date could serve it:
SELECT
(SELECT COUNT(*) FROM orders WHERE strftime('%Y', order_date) = '2024') AS with_function,
(SELECT COUNT(*) FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01') AS with_range;
▶ Run this query in the SQL Playground
| with_function | with_range |
|---|---|
| 66 | 66 |
The Cost of Indexes
Every index is a copy of some columns that has to be kept in sync. Each INSERT adds an entry to every index on the table; each UPDATE of an indexed column moves an entry; each DELETE removes one. Indexes also take disk space and memory.
| Situation | Indexing approach |
|---|---|
| Transaction system (many small writes) | Index primary keys, foreign keys and the main lookup columns; avoid extras |
| Reporting or data warehouse (mostly reads) | More indexes, or column-store formats built for scans |
| Bulk loads | Sometimes faster to drop indexes, load, then rebuild them |
| An index nobody uses | Drop it; it only costs |
Over time, many updates can leave an index fragmented. SQL Server has ALTER INDEX ... REBUILD / REORGANIZE, PostgreSQL REINDEX, SQLite REINDEX and VACUUM. Keeping table statistics fresh (ANALYZE) matters as much, because the optimizer relies on them.
Common Index Mistakes
Six errors and how to avoid them.
1. Indexing every column
CREATE INDEX i1 ON orders (status);
CREATE INDEX i2 ON orders (discount);
CREATE INDEX i3 ON orders (item_count);
-- ...one per column
-- the columns real queries filter, join and sort on
CREATE INDEX idx_orders_user_date ON orders (user_id, order_date);
Each index slows every write. Start from the slow queries, not from the columns.
2. The wrong column order in a composite index
CREATE INDEX idx ON orders (order_date, user_id);
-- query: WHERE user_id = ? ORDER BY order_date
CREATE INDEX idx ON orders (user_id, order_date);
The leftmost-prefix rule: the index is sorted by its first column, so it helps only queries that constrain that column.
3. A function on an indexed column
WHERE strftime('%Y', order_date) = '2024'
WHERE order_date >= '2024-01-01'
AND order_date < '2025-01-01'
The index holds order_date values, not years. The range form returns the same rows and can use the index.
4. Indexing a two-value column on its own
CREATE INDEX idx ON users (is_premium);
CREATE INDEX idx ON users (is_premium, created_at);
-- or a partial index on the rare value
A filter that matches 50% of the rows is faster as a scan, so the optimizer will not use the index.
5. Forgetting foreign-key columns
-- no index on orders.user_id
SELECT ... FROM users u JOIN orders o ON o.user_id = u.id
CREATE INDEX idx_orders_user_id ON orders (user_id);
Joins look up the child table by the foreign key; without an index each lookup reads the whole table.
6. Guessing instead of checking the plan
-- "I added an index, so it must be fast now"
EXPLAIN QUERY PLAN SELECT ...; -- SQLite
EXPLAIN ANALYZE SELECT ...; -- PostgreSQL
The optimizer may ignore an index for good reasons. The plan tells you what actually happens.
Index Cheat Sheet
| Task | Command |
|---|---|
| Create | CREATE INDEX idx_t_col ON t (col); |
| Unique | CREATE UNIQUE INDEX idx_t_col ON t (col); |
| Composite | CREATE INDEX idx_t_a_b ON t (a, b); (filter on a, or a and b) |
| Partial | CREATE INDEX idx ON t (col) WHERE status = 'open'; |
| Expression | CREATE INDEX idx ON t (LOWER(col)); |
| Drop | DROP INDEX idx_t_col; (MySQL, SQL Server: ... ON t) |
| See the plan | EXPLAIN QUERY PLAN (SQLite), EXPLAIN (PostgreSQL, MySQL) |
| List indexes | SELECT * FROM sqlite_master WHERE type = 'index' (SQLite) |
Practice Indexes Queries
5 exercises on the real Playground tables.
Write each query yourself first. Every solution has been run against the Playground database.
-
1Easy List every index in the Playground database together with the SQL that created it. Table:
sqlite_masterShow solution
SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'index';▶ Run this query in the SQL Playground
Automatic indexes have no SQL (NULL), because SQLite created them for UNIQUE constraints rather than from a CREATE INDEX statement.
-
2Easy Work out the selectivity of orders.status and orders.shipping_city. Table:
ordersShow solution
SELECT ROUND(1.0 * COUNT(DISTINCT status) / COUNT(*), 3) AS status_selectivity, ROUND(1.0 * COUNT(DISTINCT shipping_city) / COUNT(*), 3) AS city_selectivity FROM orders;▶ Run this query in the SQL Playground
Both are low: few distinct values for many rows.
-
3Medium Rewrite this filter so an index on order_date could be used, and check that the count is unchanged: WHERE strftime('%Y-%m', order_date) = '2024-06'. Table:
ordersShow solution
SELECT COUNT(*) AS june_2024_orders FROM orders WHERE order_date >= '2024-06-01' AND order_date < '2024-07-01';▶ Run this query in the SQL Playground
A half-open range on the column itself is index-friendly and matches the same dates.
-
4Medium Find how many orders each customer has, to judge whether an index on orders.user_id would narrow lookups well. Table:
ordersShow solution
SELECT MIN(orders) AS fewest, MAX(orders) AS most, ROUND(AVG(orders), 2) AS average FROM (SELECT user_id, COUNT(*) AS orders FROM orders GROUP BY user_id) t;▶ Run this query in the SQL Playground
A lookup by user_id finds only a handful of rows, so an index on it is selective.
-
5Medium List the indexes on the payments table and whether each one is unique. Table:
pragma_index_listShow solution
SELECT name, "unique" AS is_unique, origin FROM pragma_index_list('payments');▶ Run this query in the SQL Playground
origin is "u" for an index created by a UNIQUE constraint and "c" for CREATE INDEX.
📝 Quiz — Indexes
Test your understanding · 0/50 answered
What is an index in SQL?
Like the index at the back of a book.
What is the main purpose of an index?
Unique indexes also prevent duplicates.
What happens when a query has no useful index?
Fine for small tables, slow for large ones.
What structure do most SQL indexes use?
B-trees support equality, ranges and sorting.
What is the basic syntax to create an index?
CREATE INDEX name ON table (columns).
Which index determines the physical order of a table's rows?
In SQL Server and MySQL InnoDB.
How many clustered indexes can a table have?
Rows can only be stored in one order.
What does a non-clustered index store?
Reaching the rest of the row is an extra lookup.
How many non-clustered indexes can a table have?
Each one costs on writes, though.
What does CREATE UNIQUE INDEX enforce?
The Playground's four indexes are all unique.
What is a composite index?
Such as (user_id, order_date).
What is the leftmost-prefix rule?
An index on (a, b) helps filters on a, or a and b, not b alone.
Why does column order matter in a composite index?
Put the always-equal column first.
What is a covering index?
Plans show it as "covering index" or "index only scan".
What is a partial (filtered) index?
Useful for a small, frequently queried subset.
What is an expression (function-based) index?
It lets WHERE LOWER(email) = ... use an index.
Which command shows how a query will be executed?
SQL Server shows the execution plan instead.
What does an index seek mean compared with a scan?
Seeks are what you hope to see for selective filters.
Does creating an index guarantee it will be used?
For filters that match most rows, a scan is cheaper.
Why do indexes speed up joins?
Foreign-key columns are the classic case.
Can an index help ORDER BY?
Especially with LIMIT.
Can an index help GROUP BY?
It can also make the query a covering one.
Why is an index on a yes/no column often useless on its own?
is_premium in the Playground has a selectivity of about 0.013.
Why can LIKE 'abc%' use an index but LIKE '%abc' cannot?
Prefix searches map to a range in the sorted index.
Why can a function on a column stop index use?
Use an expression index or compare the raw column.
What is over-indexing?
Every index must be maintained on every write.
What is index selectivity?
users.email has selectivity 1.
What is index fragmentation?
REBUILD, REORGANIZE or REINDEX fix it.
Which kind of system usually has more read-oriented indexes?
Transaction systems keep indexes lean to keep writes fast.
Are indexes a common interview topic?
Especially the leftmost-prefix rule.
What is a key lookup in an execution plan?
A covering index avoids it.
Can too many indexes slow a database down?
Reads usually get faster, writes slower.
What is the main trade-off when adding an index?
Add indexes for the queries that need them.
Which is usually a poor index candidate on its own?
Low selectivity; consider a composite or partial index.
Can an index on (user_id, order_date) efficiently serve WHERE order_date = ? alone?
Some databases can do a slower "skip scan", but do not rely on it.
Why does a tiny lookup table rarely need an index?
Indexes matter as tables grow.
How can an index help COUNT(*) with a WHERE?
The table may not need to be read at all.
What does the optimizer use to decide whether to use an index?
Keep statistics fresh with ANALYZE.
Why might a materialized view also need indexes?
PostgreSQL and Oracle allow indexes on materialized views.
How are primary keys and indexes related?
In SQLite, an INTEGER PRIMARY KEY is the rowid the table is stored by.
What best describes an index?
Most often a B-tree.
Which command removes an index?
MySQL and SQL Server add ON table_name.
In SQL Server, how is a table without a clustered index stored?
PostgreSQL tables are always heaps.
Why is EXPLAIN essential for tuning?
Tune based on the plan, not guesses.
What is a sensible first step for a slow query on a large table?
Measure, then change one thing at a time.
What two jobs does a unique index do?
That is why UNIQUE constraints create one automatically.
How does an index on (last_name, first_name) serve WHERE last_name = ? AND first_name = ??
Like looking up a name in a phone book.
What does index maintenance usually include?
REBUILD, REORGANIZE, REINDEX and ANALYZE.
Are foreign-key columns indexed automatically?
Joins on them are very common.
Why can SELECT * work against good indexing?
Select the columns you need.
Indexes Interview Questions
Q1. What is the difference between a clustered and a non-clustered index?
A clustered index is the table itself, stored in key order, so there is one per table. A non-clustered index is a separate sorted structure of keys and row pointers; a table can have many, and reaching the full row takes an extra lookup unless the index covers the query.
Q2. How would you design an index for WHERE user_id = ? AND order_date >= ? ORDER BY order_date?
A composite index on (user_id, order_date): equality column first, range and sort column second. It serves the filter and returns rows already in date order.
Q3. Why might the optimizer ignore an index?
The filter matches a large share of rows, the column is wrapped in a function or compared with another type, the query does not use the leading column of a composite index, or the statistics are out of date.
Q4. What are the downsides of indexes?
Every index slows INSERT, UPDATE and DELETE, takes storage and memory, and needs maintenance. Unused indexes are pure cost.
Q5. What makes a query "sargable"?
A condition the database can answer with an index seek: the indexed column on its own on one side of the comparison. order_date >= '2024-01-01' is sargable; strftime('%Y', order_date) = '2024' is not.
Q6. What is a covering index and why does it help?
An index that holds every column the query reads. The database answers from the index alone and skips the table lookups, which can be a large saving for frequent queries.
Q7. How do you find out whether a query uses an index?
Read its execution plan: EXPLAIN QUERY PLAN in SQLite, EXPLAIN or EXPLAIN ANALYZE in PostgreSQL and MySQL, the actual execution plan in SQL Server.
Frequently Asked Questions
What is an index in SQL?
An index is a separate, sorted data structure, usually a B-tree, built on one or more columns of a table. It lets the database find matching rows quickly instead of scanning every row.
Why do we use indexes?
To speed up WHERE filters, joins, ORDER BY, GROUP BY and MIN/MAX on large tables, by letting the database go straight to the matching rows instead of reading the whole table.
What is a clustered index?
A clustered index defines the order in which the table's rows are stored, so a table can have only one. In SQL Server and MySQL InnoDB it is usually the primary key.
What is a composite index?
An index on two or more columns together, such as (user_id, order_date). Column order matters because of the leftmost-prefix rule: the index helps queries that use its first column, or its first and second, and so on.
Can too many indexes slow down a database?
Yes. Every index has to be updated on each INSERT, UPDATE and DELETE, and takes space. Too many indexes slow writes while adding little for reads.
How do indexes improve performance?
The database walks a few levels of a sorted tree to find the matching keys instead of reading every row. The work grows very slowly with table size, while a scan grows in proportion to it.
How do data analysts benefit from indexes?
Dashboards and reports that filter by date, customer or product run faster when the tables behind them are indexed on those columns. Knowing why a query is slow also helps analysts ask for the right index.
Are indexes important in SQL interviews?
Yes. Clustered versus non-clustered indexes, composite index column order, selectivity, reading execution plans and the cost of over-indexing are common interview topics.