Skip to content
Educora
University30 min20 / 22

Query optimisation and indexes

Learn how a B-tree index works, how to read a query plan, how to write index-friendly (sargable) conditions, what composite and covering indexes are, and when an index does harm.

Check yourself
In this lesson you will learn
  • Estimate the benefit of an index by calculating B-tree height and selectivity
  • Read EXPLAIN QUERY PLAN and EXPLAIN ANALYZE output and rewrite non-sargable conditions
  • Design composite and covering indexes and recognise when not to create an index

On a table with ten million orders, a query that finds one customer's orders takes 8 seconds; after the right index, the same query runs in milliseconds. SQL is declarative: we write what we want, and the DBMS optimiser chooses how to find it. It compares several possible plans and picks the cheapest one based on table statistics. In this lesson we learn how the optimiser thinks: the inner structure of an index, how to read a plan and how to write queries that make its job easier. The good news: no special tools are needed — everything starts with the EXPLAIN command and a few simple rules.

Inside a B-tree index

In most DBMSs the default index is a B-tree (strictly speaking, a B+ tree): keys are kept sorted in disk pages, every inner page holds hundreds of keys with pointers to the level below, and the leaves point to rows and are chained to each other. The tree is always balanced: the path from the root to any leaf has the same length. That is why a lookup takes only a few page reads, and the leaf chain makes range queries (BETWEEN, >, ORDER BY) cheap.

h = ⌈ log N ÷ log f ⌉
where:
  • hthe height of the tree — the number of pages read to find one key
  • Nthe number of keys (rows) in the index
  • fthe fan-out — how many keys fit in one page (usually hundreds)

The height grows logarithmically with the number of rows: when the table grows 500 times, the tree gains just one level.

Example 1: index or full scan?

The orders table has N = 10⁸ rows, 100 rows fit in a page, and the index fan-out is f = 500. How many pages are read to find one customer's 12 orders with and without an index?

Show solution
1) Full scan: the whole table is read — 10⁸ ÷ 100 = 10⁶ pages.
2) Tree height: log 10⁸ ÷ log 500 = 8 ÷ 2.70 ≈ 2.96, rounded up h = 3 pages.
3) In the worst case the 12 rows found are on 12 different pages: about 3 + 12 = 15 pages in total.
4) Difference: 10⁶ ÷ 15 ≈ 67,000 times fewer reads. Sanity check: if 30% of the rows were needed, reading scattered pages one by one would cost more than a sequential scan — then the optimiser chooses the full scan.
s = n ÷ N
where:
  • sthe selectivity of the condition (between 0 and 1)
  • nthe number of rows matching the condition
  • Nall rows in the table

A small s (a condition that returns few rows) is good for an index; when s is large, a full scan may be cheaper.

Sargable conditions

Sargable (search-argument-able) means a condition that can use an index for searching: the column stands “bare” on one side of the comparison. When you apply a function or arithmetic to the column (strftime('%m', order_date) = '03', price * 1.18 > 100), the sorted values in the index are of no use and the DBMS has to check every row. Rewriting the same condition as a range is enough.

SQL
CREATE INDEX idx_orders_date ON orders (order_date);

-- not sargable: a function on the column
EXPLAIN QUERY PLAN
SELECT id FROM orders
WHERE strftime('%m', order_date) = '03';
▸ Expected output
id | parent | notused | detail
2 | 0 | 214 | SCAN orders USING COVERING INDEX idx_orders_date
SCAN — every entry of the index is checked; the index was chosen only because it is smaller than the table.
SQL
CREATE INDEX idx_orders_date ON orders (order_date);

-- sargable: a range on the bare column
EXPLAIN QUERY PLAN
SELECT id FROM orders
WHERE order_date >= '2025-03-01' AND order_date < '2025-04-01';
▸ Expected output
id | parent | notused | detail
2 | 0 | 154 | SEARCH orders USING COVERING INDEX idx_orders_date (order_date>? AND order_date<?)
SEARCH — the tree is descended to the start of the range and only the needed leaves are read. SQLite stores the id in every index, so the index is “covering”. The first three numbers depend on the version; the detail column is what matters.

Composite and covering indexes

A composite index is sorted by several columns: (customer_id, order_date) first by customer and, within the same customer, by date. It works like a phone book: you can search by surname, or by surname and first name, but not by first name alone — this is the leftmost prefix rule. If the index contains all the columns a query needs, the DBMS never touches the table — such an index is called covering. On top of that, when the index order matches ORDER BY, the separate sorting step disappears.

Example 2: fixing a slow query

A query from the customer's account page: SELECT id, order_date FROM orders WHERE customer_id = 1 AND strftime('%Y', order_date) = '2025' ORDER BY order_date. Its plan shows SCAN orders and USE TEMP B-TREE FOR ORDER BY. How can it be sped up?

Show solution
1) Make the date condition sargable: order_date >= '2025-01-01' AND order_date < '2026-01-01'.
2) Create a composite index with the equality column first and the range/sort column second: (customer_id, order_date).
3) The index searches exactly by customer_id and by range on order_date, and returns the rows already in date order — the temporary sort disappears.
4) Because id is also in the index, no table access is needed: the plan shows SEARCH ... USING COVERING INDEX.
SQL
-- before: function on the column, no suitable index
EXPLAIN QUERY PLAN
SELECT id, order_date FROM orders
WHERE customer_id = 1 AND strftime('%Y', order_date) = '2025'
ORDER BY order_date;
▸ Expected output
id | parent | notused | detail
3 | 0 | 216 | SCAN orders
15 | 0 | 0 | USE TEMP B-TREE FOR ORDER BY
SQL
-- after: sargable range + composite index
CREATE INDEX idx_orders_cust_date ON orders (customer_id, order_date);

EXPLAIN QUERY PLAN
SELECT id, order_date FROM orders
WHERE customer_id = 1
  AND order_date >= '2025-01-01' AND order_date < '2026-01-01'
ORDER BY order_date;
▸ Expected output
id | parent | notused | detail
3 | 0 | 46 | SEARCH orders USING COVERING INDEX idx_orders_cust_date (customer_id=? AND order_date>? AND order_date<?)

In PostgreSQL, EXPLAIN shows the plan and the estimated cost, while EXPLAIN ANALYZE actually runs the query and shows real times. Below is a typical result on a table with 1,000,000 rows before and after the index (the numbers depend on the hardware). cost=startup..total is in arbitrary units, and rows is the optimiser's estimate; if the estimate differs greatly from the actual rows, the statistics are stale and ANALYZE is needed.

SQL
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;

CREATE INDEX idx_orders_customer ON orders (customer_id);

EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;
Expected output
Seq Scan on orders  (cost=0.00..20834.00 rows=10 width=24) (actual time=0.018..61.922 rows=12 loops=1)
  Filter: (customer_id = 42)
  Rows Removed by Filter: 999988
Planning Time: 0.081 ms
Execution Time: 61.950 ms

Index Scan using idx_orders_customer on orders  (cost=0.42..44.58 rows=10 width=24) (actual time=0.024..0.039 rows=12 loops=1)
  Index Cond: (customer_id = 42)
Planning Time: 0.210 ms
Execution Time: 0.061 ms
PostgreSQL (not runnable, sample output). Rows Removed by Filter — rows read for nothing.

When not to index

  • Small tables — scanning a table of a few pages is cheaper than descending a tree.
  • Low selectivity — on a two-valued column such as is_active, a condition returns half of the rows.
  • Write-heavy tables — every index is updated on each INSERT, UPDATE and DELETE; on log and sensor tables that is expensive.
  • Duplicate indexes — if (customer_id, order_date) exists, a separate (customer_id) index is redundant: the leftmost prefix replaces it.
Exercise

Create an index on order_date and select the orders from the first quarter of 2025 (January–March) with a sargable range condition: id and order_date, sorted by date and id. Don't use strftime.

Exercise · SQL
CREATE INDEX idx_orders_date ON orders (order_date);

-- slow version: WHERE strftime('%m', order_date) IN ('01', '02', '03')
SELECT id, order_date
FROM orders
-- WHERE: a range on the bare column
ORDER BY order_date, id;
▸ Expected output
id | order_date
1 | 2025-01-15
2 | 2025-01-15
3 | 2025-02-02
4 | 2025-02-10
5 | 2025-03-05
6 | 2025-03-18
Exercise

Estimate how useful an index on products.category would be: for each category show the number of rows (rows_count) and the selectivity as a percentage (selectivity_pct, 1 decimal place). Sort by number of rows descending, then by category.

Exercise · SQL
SELECT category,
       COUNT(*) AS rows_count
       -- selectivity_pct = 100 * n / N
FROM products
GROUP BY category
ORDER BY rows_count DESC, category;
▸ Expected output
category | rows_count | selectivity_pct
Electronics | 4 | 40
Accessories | 2 | 20
Stationery | 2 | 20
Games | 1 | 10
Home | 1 | 10

Key points

  • A B-tree is balanced: its height is h = ⌈log N ÷ log f⌉, so a lookup takes only a few page reads.
  • An index pays off when the selectivity s = n ÷ N is small; when it is large, the optimiser may choose a full scan.
  • In a sargable condition the column stays bare: move functions and arithmetic to the constant side and use ranges for dates.
  • A composite index follows the leftmost prefix rule: equality column first, then the range/sort column; a covering index removes table access.
  • Check plans with EXPLAIN, avoid indexes on small, low-selectivity and write-heavy tables, and measure the effect of every change.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
A table has 250,000 rows and the fan-out is f = 500. What is the height of the B-tree?