Skip to content

Grokem · https://www.grokem.com/databases/administration-and-tuning/indexing/database-indexes-explained

IndexingExplainer

Database indexes explained: why a lookup stops scanning the whole table

What an index is, what it costs, how to read the difference between a scan and a search, and the four reasons a query ignores the index you made.

Grokem Editorial

Editorial desk

Published
  • Beginner
  • 6 min read

What you will learn

  • Explain what an index is and how it speeds up a lookup
  • Choose the column order of a composite index
  • Recognise queries that cannot use an index
  • Weigh the write and storage cost of an index

Imagine looking for one customer in a printed list of 200,000 orders. With no help you read from the first line until you find every match. If the list were sorted by customer you could jump near the right place and read a handful of lines. An index is that sorted help: a second structure the database keeps in step with the table.

What an index is

Most relational databases build indexes as B-trees: sorted, balanced trees where each step narrows the search by a large factor. Finding one value in a million takes a few steps rather than a million reads, and the cost grows very slowly as the table grows.

An index stores the indexed column's values in order, each paired with a pointer to the row. Looking up customer_id = 4242 walks the tree to the first matching entry and reads along it while the value stays 4242.

See it work

This script builds a table of 200,000 orders in SQLite and asks for one customer's orders before and after adding an index. EXPLAIN QUERY PLAN shows what the database decides to do.

python
import random
import sqlite3
import time

conn = sqlite3.connect(":memory:")
conn.execute(
    "CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, status TEXT, total REAL, created_at TEXT)"
)
random.seed(1)
rows = [
    (i, random.randint(1, 5000), random.choice(["new", "paid", "shipped", "cancelled"]),
     round(random.uniform(5, 500), 2), f"2025-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}")
    for i in range(1, 200_001)
]
conn.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", rows)
conn.commit()


def plan(sql):
    for row in conn.execute("EXPLAIN QUERY PLAN " + sql):
        print("   ", row[3])


def timed(sql):
    start = time.perf_counter()
    count = len(conn.execute(sql).fetchall())
    print(f"    {count} rows in {(time.perf_counter() - start) * 1000:.2f} ms")


query = "SELECT id, total FROM orders WHERE customer_id = 4242"
print("1. no index")
plan(query)
timed(query)

conn.execute("CREATE INDEX idx_orders_customer ON orders (customer_id)")
print("2. index on customer_id")
plan(query)
timed(query)

On one run on a laptop this printed:

text
1. no index
    SCAN orders
    32 rows in 4.55 ms
2. index on customer_id
    SEARCH orders USING INDEX idx_orders_customer (customer_id=?)
    32 rows in 0.05 ms

Your timings will differ, but the words matter more than the numbers. SCAN means every row was read. SEARCH ... USING INDEX means the database jumped to the matching entries. The same words (or Seq Scan and Index Scan) appear in the plans of other databases.

Composite indexes: order matters

An index on (customer_id, status, created_at) is sorted by customer_id, then by status within each customer, then by created_at within each status. That makes it useful for queries that filter on the leftmost columns in order, and it can even return rows already sorted.

python
query2 = "SELECT id FROM orders WHERE customer_id = 4242 AND status = 'paid' ORDER BY created_at"
print("3. filter on two columns and sort, single-column index only")
plan(query2)
conn.execute("CREATE INDEX idx_orders_cust_status_date ON orders (customer_id, status, created_at)")
print("4. composite index (customer_id, status, created_at)")
plan(query2)
text
3. filter on two columns and sort, single-column index only
    SEARCH orders USING INDEX idx_orders_customer (customer_id=?)
    USE TEMP B-TREE FOR ORDER BY
4. composite index (customer_id, status, created_at)
    SEARCH orders USING COVERING INDEX idx_orders_cust_status_date (customer_id=? AND status=?)

Before, the database found the customer's rows and then had to sort them (USE TEMP B-TREE FOR ORDER BY). With the composite index the rows come out already in order and the sort disappears. The index is also covering: it holds every column the query asks for, so the table is never touched.

A useful rule: put the columns you compare with = first, then the column you range over or sort by.

When the database ignores your index

An index is only useful when the query can use the sorted order. The usual reasons it cannot:

  1. A function wraps the column. WHERE lower(status) = 'paid' cannot use an index on status, because the index is sorted by the stored value, not the lowercased one. Compare the plans:
python
conn.execute("DROP INDEX idx_orders_cust_status_date")
conn.execute("DROP INDEX idx_orders_customer")
conn.execute("CREATE INDEX idx_orders_status ON orders (status)")
print("5. index on status")
plan("SELECT id FROM orders WHERE status = 'paid'")
print("6. the same column wrapped in a function")
plan("SELECT id FROM orders WHERE lower(status) = 'paid'")
text
5. index on status
    SEARCH orders USING COVERING INDEX idx_orders_status (status=?)
6. the same column wrapped in a function
    SCAN orders USING COVERING INDEX idx_orders_status

In the second query the database reads every entry of the index instead of jumping to the matches. Some databases let you index the expression itself (CREATE INDEX ... ON orders (lower(status))), which fixes this case.

  1. A leading wildcard. LIKE '%smith' cannot use an ordinary index, but LIKE 'smith%' can.
  2. The leftmost column is missing. The composite index above does not help a query that filters only on status.
  3. The index would not help. If a value matches most of the table, reading the table directly is cheaper than jumping back and forth. The planner may rightly choose a scan.

What indexes cost

  • Writes get slower. Every insert, update or delete that touches an indexed column must also update each index on it.
  • Space. Each index is a copy of the indexed columns plus pointers. Wide composite indexes can rival the table in size.
  • Maintenance. More indexes mean more to review, rebuild and keep in step with how the application really queries.

Add an index when a query you actually run is slow, confirm with the plan that the database uses it, and drop indexes that nothing uses.

Glossary

Index
A separate, sorted structure that lets the database find rows by a column's value without reading the whole table.
Scan
Reading every row (or every entry of an index) to find the matches.
Search
Using an index to jump straight to the matching rows.
Composite index
An index on several columns, sorted by the first, then the second within it, and so on.
Covering index
An index that contains every column a query needs, so the table itself is never read.
Selectivity
How few rows a value matches. High selectivity (a customer id) is what indexes are good at.

Frequently asked questions

Should every column have an index?

No. Each index costs disk space and slows down every insert, update and delete that touches its columns. Index the columns you filter, join and sort on, and measure.

Is a primary key indexed already?

Yes. A primary key or unique constraint creates an index for you, which is why looking up a row by id is fast without further work.

Next steps

  • Read a query plan before and after adding an index
  • Audit the indexes you already have for ones nothing uses

References

From Grokem: https://www.grokem.com/databases/administration-and-tuning/indexing/database-indexes-explained

How did this strike you?

Be the first to react – it is anonymous.

Community

Questions about this?

Ask in Questions and help and get help from other readers.

Ask a questionBrowse Questions and help

Comments (2)

  • Hannah W.

    If an index makes reads faster, why not index every column?

    • Grokem Editorial

      Each index has to be updated on every insert, update and delete, takes disk space and gives the planner more options to choose between. So an index pays for itself only when the queries that use it are frequent enough. Start from the slow query, check the plan, and add the one index it needs.

Choose what we may use. You can change this at any time with “Privacy choices” at the foot of every page. Details are in the Cookie policy and the Privacy policy.

Strictly necessary

Remembers your theme, the lessons you marked as done and this choice. It stays on your device, tracks nothing and cannot be switched off.

Always on
What exactly is collected, and why

Grokem’s own counts

Collects
The pages you open and how long you stay, how far you scroll, where you came from, your device type, browser, screen size, language and time zone. Your IP address is turned into a code that changes every day; it is never stored as it is.
Why
To see how long lessons are read and where readers leave. Not shared with anyone.
Stored
A random visitor ID, a visit ID and the time of your last page view in your browser’s storage (gr_vid, gr_sid, gr_last). No name or email is attached.