A table stores rows. An index stores selected column values in searchable order, plus a pointer back to the row.
That one sentence explains most index behavior: why reads can get faster, why writes get slower, why column order matters, and why some indexes do nothing useful.
What happens without an index
Suppose you have a table:
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
total_cents INTEGER NOT NULL
);
And this query:
SELECT *
FROM orders
WHERE customer_id = 42;
Assume orders has 10,000,000 rows, and customer 42 has 120 orders.
Without an index on customer_id, the database has no direct way to jump to “rows for customer 42.” The rows are stored in data pages on disk and in memory. A page is a fixed-size block — commonly 8 KB or 16 KB depending on the database. Each page contains multiple rows, plus metadata.
To answer the query, the database performs a table scan:
- Read the first table page.
- Inspect each row on that page.
- Evaluate
customer_id = 42.
- Return the row if it matches.
- Move to the next page.
- Repeat until the table is exhausted.
Physically, this means the database touches the table’s storage from beginning to end, or at least all pages relevant to the table. If the table occupies 80 GB, the database may need to read a large portion of that 80 GB, unless the pages are already in memory.
A sequential scan is not always bad. It is often very efficient per byte read. Databases are good at reading consecutive pages, prefetching them, and evaluating predicates quickly. If your query needs 80% of the table anyway, a sequential scan may be the best possible plan.
But for the query above, you want 120 rows out of 10,000,000. Reading every row to find them is wasteful. The database needs a structure that narrows the search before visiting the table rows.
That structure is an index.
What structure an index adds
A typical relational database index is a B-tree or B-tree-like structure. You do not need to memorize the algorithms, but you do need the shape:
- The index is separate from the table.
- It is stored in pages too.
- Entries are kept in sorted order by the indexed column or columns.
- Leaf entries contain the indexed key value and a row locator.
- Internal pages guide the database to the correct leaf page.
For an index on customer_id:
CREATE INDEX idx_orders_customer_id
ON orders (customer_id);
The index contains entries conceptually like:
customer_id | row pointer
------------+------------
1 | row location A
1 | row location B
2 | row location C
42 | row location D
42 | row location E
42 | row location F
...
The “row pointer” depends on the database. In PostgreSQL, it is a tuple identifier pointing to a table block and row offset. In SQL Server clustered indexes, the locator may be the clustered key. In InnoDB, secondary indexes point to the primary key, which is then used to find the row. The exact implementation varies, but the concept is the same: the index entry tells the database where the actual row can be found.
The reason the structure is sorted is that sorted data can be searched by range. If the index entries are ordered by customer_id, the database can navigate to the first entry where customer_id = 42, then continue forward until the key is no longer 42.
It does not need to inspect entries for customers 1 through 41 one by one. It uses the tree’s internal pages to jump close to the target.
For the earlier query:
SELECT *
FROM orders
WHERE customer_id = 42;
With 10,000,000 rows and 120 matching rows, the work becomes roughly:
- Read a few index pages to navigate the tree.
- Find the first index entry for
customer_id = 42.
- Read the nearby index entries for the remaining 119 matches.
- Use their row locators to fetch the actual table rows.
Instead of scanning 10,000,000 table rows, the database may inspect a tiny number of index pages plus 120 table rows.
That is the read speedup.
The important detail is that an index does not make the table smaller. It gives the optimizer an alternative access path. If that path is cheaper than scanning the table, the optimizer will use it.
A worked example
Imagine this table:
CREATE TABLE events (
id BIGINT PRIMARY KEY,
account_id BIGINT NOT NULL,
event_type TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
payload JSONB NOT NULL
);
It has 50,000,000 rows.
You run:
SELECT id, created_at, event_type
FROM events
WHERE account_id = 9001
ORDER BY created_at DESC
LIMIT 20;
Assume account 9001 has 40,000 events.
Without a useful index, the database may need to:
- Scan 50,000,000 rows.
- Keep only rows where
account_id = 9001.
- Sort those 40,000 matching rows by
created_at DESC.
- Return the first 20.
That is a lot of work to return 20 rows.
Now add:
CREATE INDEX idx_events_account_created
ON events (account_id, created_at DESC);
The index is sorted first by account_id, then by created_at DESC within each account.
Now the database can:
- Navigate directly to the index region for
account_id = 9001.
- Read entries in
created_at DESC order.
- Stop after 20 entries because of
LIMIT 20.
- Fetch those 20 table rows, unless the index contains everything needed.
This is not merely “an index lookup.” The structure matches the query:
account_id = 9001 selects a contiguous section of the index.
created_at DESC is already the order inside that section.
LIMIT 20 allows the database to stop early.
That is the kind of index that can turn a painful query into a cheap one.
Why writes become more expensive
Indexes speed up reads by adding more organized copies of data. The cost is that every write must keep those copies correct.
Consider an insert:
INSERT INTO orders (id, customer_id, status, created_at, total_cents)
VALUES (123456789, 42, 'paid', now(), 5999);
Without secondary indexes, the database mainly has to:
- Find space for the new row in a table page.
- Write the row.
- Log the change for durability.
- Update any mandatory structures, such as the primary key index.
With an index on customer_id, it also has to insert an entry into that index:
42 -> location of new row
That sounds small, but mechanically it requires real work:
- Traverse the index tree to find the correct leaf page for key
42.
- Latch or lock the relevant index pages while modifying them.
- Insert the new index entry in sorted position.
- If the leaf page has no room, split it into two pages.
- Update parent pages so the tree can find the new split pages.
- Write the index page changes to the write-ahead log or transaction log.
- Eventually flush dirty index pages to disk.
A page split is especially expensive. Because index pages have finite space, inserting into the middle of a sorted structure can require making a new page, moving some entries to it, and updating the parent level. That parent page may also split. B-trees are designed to make this bounded and manageable, not free.
Now multiply this by every index on the table.
If orders has these indexes:
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
CREATE INDEX idx_orders_status ON orders (status);
CREATE INDEX idx_orders_created_at ON orders (created_at);
CREATE INDEX idx_orders_customer_status ON orders (customer_id, status);
Then each inserted row requires entries in all four secondary indexes, plus whatever primary key or clustering structure the database maintains.
An update can be even more subtle.
UPDATE orders
SET status = 'refunded'
WHERE id = 123456789;
If status is indexed, the database cannot simply change the table row. It must also reflect the status change in the index:
- Remove or invalidate the old index entry for
status = 'paid'.
- Add a new index entry for
status = 'refunded'.
- Log those changes.
- Maintain any composite indexes containing
status.
If the updated column is not indexed, some databases can avoid changing secondary indexes. But not always. Implementation details matter: MVCC systems may create new row versions, clustered storage may move rows, and some optimizations apply only under certain conditions. The safe mental model is: every index that includes a changed value must be maintained, and every additional index increases the write path’s work, logging, locking, cache pressure, and storage.
Deletes have a similar cost. Deleting a row means the corresponding index entries must no longer be visible to future queries. Depending on the database, they may be immediately removed, marked dead, or cleaned up later by background maintenance. Either way, the index now participates in the write.
That is why indexes make writes slower: not because databases dislike indexes, but because indexes are extra persistent data structures that must remain transactionally correct.
Why (a, b) helps with a but not with b
Composite indexes are ordered lexicographically. An index on:
CREATE INDEX idx_example_a_b
ON example (a, b);
is sorted like a phone book sorted by last name, then first name — but let’s be precise.
The entries are ordered by a first. For rows with the same a, entries are ordered by b.
Conceptually:
a | b | row pointer
--+---+------------
1 | 1 | ...
1 | 2 | ...
1 | 9 | ...
2 | 1 | ...
2 | 5 | ...
3 | 1 | ...
3 | 4 | ...
A query filtering on a can use this index well:
SELECT *
FROM example
WHERE a = 2;
All rows with a = 2 are contiguous in the index. The database can navigate to the first (2, anything) entry and scan forward until a changes.
A query filtering on both a and b can use it even better:
SELECT *
FROM example
WHERE a = 2
AND b = 5;
The database can navigate to the specific (2, 5) region.
A query filtering on a and ranging on b also fits:
SELECT *
FROM example
WHERE a = 2
AND b >= 10
AND b < 20;
Because within a = 2, values are sorted by b.
But a query filtering only on b does not generally benefit much:
SELECT *
FROM example
WHERE b = 5;
Rows where b = 5 are not contiguous in an (a, b) index. They are scattered across every value of a:
(1, 5)
(2, 5)
(3, 5)
(4, 5)
...
Since a is the first ordering key, the database cannot jump directly to all b = 5 entries. It would have to scan across many or all a groups looking for b = 5.
Some databases can perform variations such as skip scans under certain conditions, where they repeatedly probe the index for each distinct a. But that only helps when the number of distinct a values is small and the optimizer estimates it as cheaper than a table scan. It is not the general behavior you should count on.
The rule of thumb is the left-prefix rule:
A composite B-tree index is most useful when your query constrains the leading column or columns of the index.
So (a, b) can help with:
WHERE a = ?
WHERE a = ? AND b = ?
WHERE a = ? AND b BETWEEN ? AND ?
It usually cannot help much with:
WHERE b = ?
For that, you likely need an index beginning with b, such as (b) or (b, a).
When adding an index is the wrong fix
Indexes are powerful, but they are not free, and they do not solve every slow query. Here are three common situations where adding one is the wrong fix.
1. The query returns a large fraction of the table
Suppose:
SELECT *
FROM orders
WHERE status = 'completed';
If 8,000,000 out of 10,000,000 orders are completed, an index on status may not help.
The index can find the completed entries, but then the database still has to fetch 8,000,000 rows. If those row fetches are scattered across the table, the index plan may be worse than simply scanning the table sequentially.
Indexes shine when they eliminate most of the table work. If a predicate is not selective, the database may correctly ignore the index.
A better fix might be:
- Change the query to fetch fewer rows.
- Add a more selective composite index matching additional filters.
- Use partitioning if the access pattern naturally isolates data.
- Precompute aggregates if the query is analytical.
An index on a low-cardinality column is not automatically bad, but it must match a query that actually becomes selective, such as:
WHERE status = 'pending'
AND created_at >= now() - interval '1 hour'
A composite index on (status, created_at) might be useful there because the combination narrows the result.
2. The query is slow because it does too much work after finding rows
Consider:
SELECT customer_id, SUM(total_cents)
FROM orders
WHERE created_at >= '2025-01-01'
GROUP BY customer_id
ORDER BY SUM(total_cents) DESC
LIMIT 100;
An index on created_at may help find recent rows. But if “recent” still means 30,000,000 rows, the expensive part may be grouping and aggregating, not locating rows.
The database has to read many rows, compute sums per customer, sort or rank the groups, and return the top 100. An index does not remove that aggregation work unless it changes the amount of data being aggregated or matches an access pattern that avoids sorting.
The right fix may be:
- Maintain a summary table.
- Use incremental aggregation.
- Limit the time range further.
- Move the analysis to a reporting system.
- Create an index that supports a more selective predicate, not just the visible date filter.
Indexes are access paths. They help the database find rows in an order. They do not make large computations disappear.
3. The predicate is written in a way that prevents useful index access
Suppose you already have:
CREATE INDEX idx_users_email
ON users (email);
But the query is:
SELECT *
FROM users
WHERE lower(email) = lower('ALEX@example.com');
A normal index on email is sorted by the stored email value, not by lower(email). The database generally cannot use that index to directly find the lowercase value. It would have to compute lower(email) for many rows and compare the result.
Adding another ordinary index on email is the wrong fix. The issue is that the predicate does not match the indexed expression.
Better fixes include:
- Store normalized email values.
- Use a case-insensitive column type or collation if your database supports it.
- Create an expression/function-based index:
CREATE INDEX idx_users_lower_email
ON users (lower(email));
The same pattern appears with date conversions:
WHERE DATE(created_at) = '2025-02-01'
A normal index on created_at is usually more useful if the query is written as a range:
WHERE created_at >= '2025-02-01'
AND created_at < '2025-02-02'
Now the predicate matches the ordering of the index.
The practical mental model
When deciding whether to add an index, ask four questions.
First: what rows can the database skip?
If the index does not let the database ignore a large amount of data, it may not help.
Second: are the matching index entries contiguous?
For B-tree indexes, the best queries are equality and range conditions that map to a contiguous slice of the sorted index.
Third: does the index order match the query?
For filtering, column order matters. For sorting, direction and leading columns matter. For composite indexes, the left-prefix rule matters.
Fourth: what writes now become more expensive?
Every insert must add index entries. Every delete must remove or invalidate them. Every update to indexed columns must move entries from old key values to new ones. Those changes consume CPU, memory bandwidth, locks or latches, log volume, disk space, and background maintenance.
An index is worth it when the read savings exceed the write and storage costs for your workload. It is wrong when it merely adds another structure the database must maintain without giving the optimizer a cheaper path.
That is the core tradeoff: indexes make reads faster by keeping searchable, sorted copies of selected values; they make writes slower because those copies must be updated every time the data changes.