SQLab Hub
Lesson 32 · Indexes
Lesson 32 · Advanced

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.

⚡ B-tree · composite · covering Reading EXPLAIN ▶ Runnable Playground examples 🧪 5 practice exercises 📝 50-question quiz ⏱ ~35 min

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 scanIndex seek
Rows read to find one email among 1,000,000Up to 1,000,000About 20 steps down a B-tree, then the row
Grows with table sizeIn proportion to the rowsVery slowly (logarithmically)
Extra storageNoneThe index itself
Cost on INSERT / UPDATE / DELETENoneThe 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:

SQLite · runs in the Playground
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

Indexes in the Playground database
index_nametable_name
sqlite_autoindex_employees_1employees
sqlite_autoindex_payments_1payments
sqlite_autoindex_products_1products
sqlite_autoindex_users_1users
✓ 4 rows · real output from the Playground database

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.

Diagram · B-tree (simplified)
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

SQL · DDL (not run in the Playground)
-- 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
The Playground only runs SELECT and WITH queries, so CREATE INDEX and DROP INDEX cannot be tried there. Run them in a local SQLite, PostgreSQL or MySQL database. Everything that only reads, including the catalogue queries on this page, runs in the Playground.

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 indexNon-clustered (secondary) index
What it isThe table's rows are stored in the index orderA separate structure with keys and pointers to the rows
How many per tableOneMany
Typical keyThe primary keyColumns you filter or join on
After finding a keyThe row is right thereOne more step to fetch the row (a key lookup), unless the index covers the query
Terms by databaseSQL Server, MySQL InnoDB (always the primary key); SQLite rowid tables behave like thisAll 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:

SQLite · runs in the Playground
SELECT COUNT(*) AS rows,
       COUNT(DISTINCT email) AS distinct_emails
FROM users;

▶ Run this query in the SQL Playground

users.email has a unique index, so every value is distinct
rowsdistinct_emails
150150
✓ 1 row · real output from the Playground database

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 onCan use (user_id, order_date)?
user_id = 4Yes: the leftmost column
user_id = 4 AND order_date >= '2024-01-01'Yes: both columns, in order
user_id = 4 ORDER BY order_dateYes: the rows are already in date order per user
order_date >= '2024-01-01' onlyNot 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

SQL · DDL
-- 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:

SQLite · runs in the Playground
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

Indexes on products and their columns
index_nameis_uniquecolumn_name
sqlite_autoindex_products_11sku
✓ 1 row · real output from the Playground database
DatabaseList indexes
SQLiteSELECT * FROM sqlite_master WHERE type = 'index'
PostgreSQLSELECT * FROM pg_indexes WHERE tablename = 'orders'
MySQLSHOW INDEX FROM orders
SQL ServerEXEC 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:

SQLite · run locally (the Playground only accepts SELECT and WITH)
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 saysMeaning
SCAN table / Seq Scan / Table ScanEvery row is read
SEARCH ... USING INDEX / Index Seek / Index Scan with a conditionThe index narrows the rows
USING COVERING INDEX / Index Only ScanAnswered 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 / SortAn 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:

SQLite · runs in the Playground
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

Selectivity of some Playground columns
column_namedistinct_valuesrowsselectivity
users.email1501501
users.city201500.133
users.is_premium21500.013
orders.user_id1142000.57
orders.status62000.03
✓ 5 rows · real output from the Playground database

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

ClauseHelpful indexExample
WHERE equalityOn the filtered columnWHERE email = ? → users(email)
WHERE rangeOn the range column, or after = columns in a compositeWHERE user_id = ? AND order_date >= ? → orders(user_id, order_date)
JOINOn the join column of the table being looked upJOIN orders o ON o.user_id = u.id → orders(user_id)
ORDER BY ... LIMITOn the sort columns, in the same orderORDER BY order_date DESC LIMIT 10 → orders(order_date)
GROUP BYOn the grouped columnsGROUP BY user_id → orders(user_id)
MIN / MAXOn the columnSELECT 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 patternWhy the index is skippedFix
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 letterPrefix searches (LIKE 'phone%') or full-text search
WHERE strftime('%Y', order_date) = '2024'A function around the columnWHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'
WHERE user_id = '4' (text vs number)Implicit type conversion in some databasesCompare with the column's own type
WHERE order_date >= ? on an index (user_id, order_date)Not the leftmost columnAn index that starts with order_date
A filter matching most rowsA scan is cheaper than many lookupsNothing; 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:

SQLite · runs in the Playground
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

Two ways to filter on a year, same result
with_functionwith_range
6666
✓ 1 row · real output from the Playground database

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.

SituationIndexing 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 loadsSometimes faster to drop indexes, load, then rebuild them
An index nobody usesDrop 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

❌ Slow writes, little gain
SQL
CREATE INDEX i1 ON orders (status);
CREATE INDEX i2 ON orders (discount);
CREATE INDEX i3 ON orders (item_count);
-- ...one per column
✅ Index what queries use
SQL
-- 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

❌ Cannot serve user lookups
SQL
CREATE INDEX idx ON orders (order_date, user_id);
-- query: WHERE user_id = ? ORDER BY order_date
✅ Equality column first
SQL
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

❌ Index ignored
SQL
WHERE strftime('%Y', order_date) = '2024'
✅ Compare the column itself
SQL
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

❌ Matches half the table
SQL
CREATE INDEX idx ON users (is_premium);
✅ Composite or partial index
SQL
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

❌ Every join scans orders
SQL
-- no index on orders.user_id
SELECT ... FROM users u JOIN orders o ON o.user_id = u.id
✅ Index the join column
SQL
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

❌ Assumes the index is used
SQL
-- "I added an index, so it must be fast now"
✅ Look at EXPLAIN
SQL
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

TaskCommand
CreateCREATE INDEX idx_t_col ON t (col);
UniqueCREATE UNIQUE INDEX idx_t_col ON t (col);
CompositeCREATE INDEX idx_t_a_b ON t (a, b); (filter on a, or a and b)
PartialCREATE INDEX idx ON t (col) WHERE status = 'open';
ExpressionCREATE INDEX idx ON t (LOWER(col));
DropDROP INDEX idx_t_col; (MySQL, SQL Server: ... ON t)
See the planEXPLAIN QUERY PLAN (SQLite), EXPLAIN (PostgreSQL, MySQL)
List indexesSELECT * 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.

  1. 1Easy List every index in the Playground database together with the SQL that created it. Table: sqlite_master

    Show solution
    SQLite · runs in the Playground
    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.

  2. 2Easy Work out the selectivity of orders.status and orders.shipping_city. Table: orders

    Show solution
    SQLite · runs in the Playground
    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.

  3. 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: orders

    Show solution
    SQLite · runs in the Playground
    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.

  4. 4Medium Find how many orders each customer has, to judge whether an index on orders.user_id would narrow lookups well. Table: orders

    Show solution
    SQLite · runs in the Playground
    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.

  5. 5Medium List the indexes on the payments table and whether each one is unique. Table: pragma_index_list

    Show solution
    SQLite · runs in the Playground
    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

Question 1 of 50

What is an index in SQL?

Question 2 of 50

What is the main purpose of an index?

Question 3 of 50

What happens when a query has no useful index?

Question 4 of 50

What structure do most SQL indexes use?

Question 5 of 50

What is the basic syntax to create an index?

Question 6 of 50

Which index determines the physical order of a table's rows?

Question 7 of 50

How many clustered indexes can a table have?

Question 8 of 50

What does a non-clustered index store?

Question 9 of 50

How many non-clustered indexes can a table have?

Question 10 of 50

What does CREATE UNIQUE INDEX enforce?

Question 11 of 50

What is a composite index?

Question 12 of 50

What is the leftmost-prefix rule?

Question 13 of 50

Why does column order matter in a composite index?

Question 14 of 50

What is a covering index?

Question 15 of 50

What is a partial (filtered) index?

Question 16 of 50

What is an expression (function-based) index?

Question 17 of 50

Which command shows how a query will be executed?

Question 18 of 50

What does an index seek mean compared with a scan?

Question 19 of 50

Does creating an index guarantee it will be used?

Question 20 of 50

Why do indexes speed up joins?

Question 21 of 50

Can an index help ORDER BY?

Question 22 of 50

Can an index help GROUP BY?

Question 23 of 50

Why is an index on a yes/no column often useless on its own?

Question 24 of 50

Why can LIKE 'abc%' use an index but LIKE '%abc' cannot?

Question 25 of 50

Why can a function on a column stop index use?

Question 26 of 50

What is over-indexing?

Question 27 of 50

What is index selectivity?

Question 28 of 50

What is index fragmentation?

Question 29 of 50

Which kind of system usually has more read-oriented indexes?

Question 30 of 50

Are indexes a common interview topic?

Question 31 of 50

What is a key lookup in an execution plan?

Question 32 of 50

Can too many indexes slow a database down?

Question 33 of 50

What is the main trade-off when adding an index?

Question 34 of 50

Which is usually a poor index candidate on its own?

Question 35 of 50

Can an index on (user_id, order_date) efficiently serve WHERE order_date = ? alone?

Question 36 of 50

Why does a tiny lookup table rarely need an index?

Question 37 of 50

How can an index help COUNT(*) with a WHERE?

Question 38 of 50

What does the optimizer use to decide whether to use an index?

Question 39 of 50

Why might a materialized view also need indexes?

Question 40 of 50

How are primary keys and indexes related?

Question 41 of 50

What best describes an index?

Question 42 of 50

Which command removes an index?

Question 43 of 50

In SQL Server, how is a table without a clustered index stored?

Question 44 of 50

Why is EXPLAIN essential for tuning?

Question 45 of 50

What is a sensible first step for a slow query on a large table?

Question 46 of 50

What two jobs does a unique index do?

Question 47 of 50

How does an index on (last_name, first_name) serve WHERE last_name = ? AND first_name = ??

Question 48 of 50

What does index maintenance usually include?

Question 49 of 50

Are foreign-key columns indexed automatically?

Question 50 of 50

Why can SELECT * work against good indexing?

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.