Module 04 · Lesson 4 of 14Free
Indexes: speeding up queries without full scans
- Level
- Beginner
- Topic
- SQL databases: modeling, indexing and transactions
- Reading time
- 15 min
When you finish this page you can
- Trace how an index finds matching rows without scanning an entire table.
- Choose index columns and their order for a specific query.
- Explain when a full scan can cost less than an indexed lookup.
- Evaluate an index against its storage, write and deployment costs.
Builds on 2 lessons
Start with lesson 115 minVideo lesson · coming soon
Twenty products, a million checks
A customer opens a seller’s page on . The database checks a million product rows to show just 20 products. During the festival sale, other customers compete for those same database resources.
How can the database find the right products without checking every row?
Kartly is now split into services with sharded data. This lesson follows one catalog shard containing a million products. Assume seller 7 owns 500 of them. These are illustrative numbers for this lesson, not measurements.
The catalog service asks for that seller’s cheapest products:
SELECT product_id, name, price_minor
FROM products
WHERE seller_id = 7
ORDER BY price_minor, product_id
LIMIT 20;price_minor stores integer paise. product_id breaks price ties, giving the query a stable order.
There is no index supporting this filter or ordering. Does LIMIT 20 let the database stop after finding 20 products from seller 7?Show answer
An index stores selected values in a structure that supports faster lookup. PostgreSQL can use it instead of scanning every row.1
Diagram not drawn yet: catalog-index-read-path.svg
The catalog service sends SQL to the database executor. The executor seeks seller 7 in a seller index, receives matching row locations, and fetches product details from the products table. The index and table both live inside PostgreSQL.
The problem is finding candidates cheaply. Returning fewer rows does not necessarily mean examining fewer rows.
Store a route to the rows
Without a useful index, the simplest solution is a full scan: examine the table and test each row. No extra lookup structure needs maintenance.
For our query, the executor keeps seller 7’s rows and selects the cheapest results. It need not sort every product, but it still checks every row.
A seller index gives it a different route:
-- Catalog database: supports finding one seller's products.
CREATE INDEX products_seller_idx ON products (seller_id);Think of entries grouped by seller, each leading back to a product row. Duplicate seller values are expected: one seller owns many products. SQLite’s documentation illustrates this key-to-row lookup mechanism explicitly.2
The entries are not full product records. Our seller index does not contain the product names or prices the response needs.
Follow one read
- The catalog service sends the SQL through its data-access layer, covered in Repository pattern.
- The database executor seeks the beginning of seller 7’s entries.
- It follows matching entries to fetch the product rows.
- It reads prices, selects the cheapest products, and returns the requested fields.
If the index has no matching entry, this path returns an empty result. It does not need a second full scan.
The index belongs to the database. Application code does not fetch row locations or maintain entries itself.
| Part | Responsibility | Talks to | State kept | Failure consequence |
|---|---|---|---|---|
| Catalog service | Request seller products | PostgreSQL | No index copy | Request fails if the service stops |
| Database executor | Choose and execute an access path | Index and table | Query working state | An expensive path delays the response |
| Seller index | Locate matching rows | Executor and table | Seller keys and row references | A missing index leaves the scan path |
| Products table | Store product details | Executor | Product rows | Database unavailability prevents the read |
A seller index removes unrelated sellers from this path. It does not yet solve the price ordering.
Buy fewer checks with extra writes
An index is not automatically the right answer. First compare the avoided work with the work it introduces.
Should this seller page get an index?
Keep the full scan.
Every request checks a million rows. This needs no additional index storage or maintenance, and can be reasonable for a tiny table or an occasional report.
Add an index on seller_id.
The lookup targets this seller’s 500 rows. The database must also store the index and maintain it as products change.
Why this choice
This page is a frequent request returning a small part of the shard. Repeatedly checking unrelated sellers wastes work. Choose the index because it removes that work, not because an index exists for every column. Reconsider if the table becomes tiny or the query becomes rare.
For this scenario, compare logical row candidates:
Find candidates without the index
1,000,000 rows checked per request
The seller filter has no lookup structure that lets it skip unrelated rows.
Find candidates through the seller index
500 matching rows fetched per request
The executor also does index navigation. It still needs the matching rows to read their prices.
Compare candidate counts
1,000,000 ÷ 500 = 2,000 times fewer row candidates
This is not a prediction of a 2,000-fold improvement. Row placement, memory and index navigation also affect elapsed time.
The fraction of rows matched is often called . Here, the seller filter matches a small fraction, making targeted lookup attractive.
Now change the request. Suppose a catalog export needs almost every product and has no ordering requirement. Following index references would add work without skipping much data. A sequential pass becomes a reasonable alternative.
The chooses an execution strategy from available paths. PostgreSQL uses statistics to help decide whether an index is worthwhile.1 Query plans explains how to inspect that choice.
The index is an option, not an instruction to use it.
Column order determines the shortcut
The seller index still leaves a problem. It finds the seller’s products, but not in price order.
A uses more than one column as its key. The first column determines the main grouping. Later columns order entries within equal earlier values.2
For our query, use seller, then price, then product ID:
-- Catalog database schema fragment.
CREATE TABLE products (
product_id BIGINT PRIMARY KEY,
seller_id BIGINT NOT NULL,
name TEXT NOT NULL,
price_minor BIGINT NOT NULL CHECK (price_minor >= 0)
);
-- Supports one seller's cheapest products, with stable price ties.
CREATE INDEX products_seller_price_id_idx
ON products (seller_id, price_minor, product_id);The table relationships are covered in Relational data modeling. This fragment shows only fields relevant to our access path.
Consider these index keys:
| seller_id | price_minor | product_id |
|---|---|---|
| 7 | 12000 | 101 |
| 7 | 12000 | 108 |
| 7 | 18000 | 104 |
| 8 | 9000 | 205 |
Seller 8’s cheaper product comes after seller 7’s products because seller is the first key.
This arrangement lets the executor seek seller 7’s range and read in the requested order. An ordered composite index can support filtering and ordering together.2
In our simplified example, it can stop after collecting 20 matching products. It no longer needs all 500 prices before choosing the cheapest ones. Names still come from the table.
Which index order fits the seller page?
Use seller_id, price_minor, product_id.
All entries for seller 7 are adjacent. Within that range, prices and product IDs already match the requested order.
Put price_minor first.
Entries are globally price-ordered. The executor may pass many other sellers before collecting enough products from seller 7.
Create separate seller and price indexes.
Separate indexes provide separate access paths. Finding sellers and finding low prices are not the same as having this seller’s products already ordered.
Why this choice
Choose the seller-first index because the request fixes one seller before ordering products. Price-first becomes attractive for a different screen: the cheapest products across all sellers. Separate indexes can help independent seller and price queries, but do not reproduce this combined ordering.
The leading columns also determine which other lookups fit naturally. MySQL documents this as using the leftmost prefixes of a composite index.3
Our chosen index can locate one seller without specifying a price. A price-only filter does not identify one continuous seller-first range.
That is a design rule, not a claim that databases can never use later columns. Judge the access path for the actual query.
Every shortcut needs maintenance
Suppose a seller adds a product. The catalog service writes the row; PostgreSQL maintains the index automatically.1
Conceptually, the write path is:
- Accept the product fields.
- Store the table row.
- Add the corresponding seller-price-ID entry.
- Report success after the database operation succeeds.
The application must not write the table and index separately.
For a price change, the key’s old price position becomes obsolete. The database must maintain the index accordingly. Two concurrent price changes are still a problem, not something a normal lookup index resolves. Isolation levels and locking covers that behavior.
This exposes the cost of “index every column.” Each extra structure occupies space and adds maintenance. PostgreSQL explicitly warns about this write overhead.1
For Kartly, the original seller-only index may now duplicate a useful leading part of the composite index. Treat it as a removal candidate. Check the wider workload before removing it.
Deploy without stopping sellers
Building the index also requires work against existing data. PostgreSQL’s ordinary index creation permits reads but blocks writes during the build.1
On the live catalog, sellers could be unable to save product changes. Use the deployment approach covered in Online index builds on live tables, rather than running this lesson’s basic command without planning.
After deployment, compare seller-page latency, product-update latency and index size. Test a large seller as well as a typical seller.
If seller pages stay slow, inspect the chosen access path before adding another index. If writes become slower, review which indexes actually serve requests. Remove unnecessary ones through a planned change.
An index should earn its ongoing cost, not only make one isolated read look faster.
Common mistakes
- Treating a small response as little work. Our first query returns few rows but must identify the cheapest matches.
- Choosing columns without choosing their order. Seller-first and price-first answer different questions efficiently.
- Calling every full scan a failure. A broad export may benefit little from indexed row lookups.
- Expecting an index to remove output cost. Returning many matching products still requires producing and transferring them.
Where this shows up
- Design: a key-value store built on SQL: key lookup needs an access path that avoids searching unrelated records.
- Design: a fast product catalog page: efficient database reads matter when a requested page is not cached.
We have chosen an ordered access path; next, explain how that order stays searchable as products change.
Check your understanding
A seller page gains a much larger description field. Does the seller-price-ID index now contain that description automatically?Show answer
Kartly adds a screen showing the cheapest products across every seller. Is the seller-first index already ordered correctly for it?Show answer
The database chooses a full scan for an export returning nearly every product. Should you immediately force the seller index?Show answer
Seller pages become faster after adding several indexes, but price updates slow down. What should you investigate first?Show answer
Summary
- An index finds relevant keys and leads to matching rows without checking every unrelated row.
- Choose indexes for concrete queries, including their filters, ordering and limits.
- Composite column order matters: seller first supports one seller’s price-ordered range.
- A full scan can be appropriate when a query needs most of the table.
- Balance avoided read work against index storage, write maintenance and deployment cost.