The transactional side of performance: B-trees, selectivity, sargable predicates, composite key order, index write cost, and why none of it exists on a warehouse.
⏱ 20 min readTopics chapter readerLevel · Advanced
01 · Overview
Why is this query slow?
⏱ 6 min · Topic 1 of 13
You can write the SQL. A senior engineer also asks: why is this query slow, what does the plan say, and what does it cost?
A plan is the list of steps the database chooses to run your query. The part that chooses is the planner. An index is one tool the planner can pick.
This chapter uses PostgreSQL and the shop. The shop has six orders, so the planner would just read them all.
To see index behaviour, imagine orders has 10 million rows. That is an assumption, not a measurement.
Every plan in this chapter is simplified. It shows the shape of the plan, not real costs or times. Three questions guide the chapter:
Can this query use an index? That is sargability.
Is the index worth having? That depends on selectivity.
What does the index cost? Every write pays for it.
Indexes belong to row stores such as PostgreSQL, which keep each row together. Columnar warehouses keep each column together and work differently. The section "Warehouses have no B-tree indexes" says how.
02 · Mechanism
What an index is
⏱ 6 min · Topic 2 of 13
An index is a sorted copy of some columns. Each entry also holds a pointer: the place of the full row in the table.
By default, PostgreSQL keeps the copy in a B-tree. A B-tree is a tree of sorted blocks.
The database starts at the top and picks one branch at each level. It reaches an entry in a few steps, even for millions of rows.
order_id is the primary key of orders, so PostgreSQL already built an index on it. Here is that index for the shop. It is sorted by order_id, and each entry points to a row place:
The index on order_id, drawn as a tableworked example
SQL
Input data
orders_pkey (the index)6 rows
order_id
row place
101
(0,1)
102
(0,2)
103
(0,3)
104
(0,4)
105
(0,5)
106
(0,6)
The row place is simplified. PostgreSQL stores a page number and a position.
orders6 rows
order_id
customer_id
status
amount
order_date
101
1
paid
40.00
2026-01-03
102
1
paid
25.00
2026-01-10
103
2
refunded
60.00
2026-01-11
104
3
paid
15.50
2026-01-15
105
3
pending
NULL
2026-01-20
106
4
paid
80.00
2026-01-21
-- Look up one order by its id.SELECT*FROMordersWHEREorder_id=104;
Result · 1 row
order_id
customer_id
status
amount
order_date
104
3
paid
15.50
2026-01-15
With the index, the database jumps to entry 104, follows its pointer, and reads one row. It does not read the other rows.
Because the copy is sorted, it does three jobs well:
Find one value: order_id = 104.
Read a range: order_date between two dates. Find the start, then walk forward.
Read in order: with an index on order_date, ORDER BY order_date LIMIT 3 needs no sort step.
Watch out
An index only helps with searches that follow its sort order. It also takes disk space, and most writes must update it.
03 · Mechanism
Read a plan with EXPLAIN
⏱ 8 min · Topic 3 of 13
EXPLAIN shows the plan without running the query. Put it in front of the order lookup:
The plan when order_id has an indexworked example
SQL
EXPLAINSELECT*FROMordersWHEREorder_id=104;
Result · 2 rows
QUERY PLAN
Index Scan using orders_pkey on orders
Index Cond: (order_id = 104)
Simplified plan. Real EXPLAIN output also shows costs and widths; they are removed here.
Index Cond is a test done inside the index. The database never reads the other rows.
Now imagine the same query with no index on order_id. The planner has only one choice:
The plan with no indexworked example
SQL
EXPLAINSELECT*FROMordersWHEREorder_id=104;
Result · 2 rows
QUERY PLAN
Seq Scan on orders
Filter: (order_id = 104)
Simplified plan. Real EXPLAIN output also shows costs and widths; they are removed here.
Seq Scan reads every row of the table. On a big table, the real plan may say Parallel Seq Scan: several workers share the reading. Filter means the test runs on each row after it is read, 10 million times here.
These are the four main scan types. This table says when each one fits:
Scan type
What it does
When the planner picks it
Seq Scan
Reads the whole table in order.
No usable index, or most rows match.
Index Scan
Finds entries in the index, then reads each row from the table.
Few rows match.
Index Only Scan
Answers from the index, and skips the table when it can.
The index holds every column the query needs.
Bitmap Heap Scan
Collects matching row places from the index into a list (a bitmap), then reads the table in order. Heap is PostgreSQL's word for the table. In the plan, a Bitmap Index Scan sits under it.
A medium share of rows match, or two indexes are combined.
EXPLAIN ANALYZE runs the query for real. It adds what really happened next to what the planner guessed:
EXPLAIN ANALYZE on the lookupworked example
SQL
EXPLAINANALYZESELECT*FROMordersWHEREorder_id=104;
Result · 3 rows
QUERY PLAN
Index Scan using orders_pkey on orders (cost=… rows=1 …) (actual time=… rows=1 loops=1)
Index Cond: (order_id = 104)
Execution Time: … ms
Simplified. The numbers for cost and time are replaced by … on purpose.
The first rows= is the planner's estimate. The rows= after actual is what really happened. When they are far apart, the planner guessed wrong; the next section shows why.
Watch out
EXPLAIN ANALYZE really runs the query. On an INSERT, UPDATE or DELETE, it changes data. Wrap it in BEGIN and ROLLBACK to undo the change.
Interview question
A query is slow, so what do you do first? Run EXPLAIN ANALYZE between BEGIN and ROLLBACK. Look for a Seq Scan on a big table, and for estimated rows far from actual rows.
04 · Decision
When an index is worth it
⏱ 8 min · Topic 4 of 13
Selectivity is how few rows a value matches. A value that matches few rows is selective. An index helps most when it narrows the search a lot.
Count the orders for each status in the shop:
How many rows does each status match?worked example
Orders 101, 102, 104 and 106 are paid. Two of every three rows match status = paid, so an index on status narrows almost nothing for that value.
Now imagine 10 million rows and an index on status. About 9 in 10 orders are paid, and about 1 in 1,000 is pending. These shares are assumptions.
The same index, two different plans (assumed shares)
Query
Share of rows that match
Likely plan
WHERE status = 'paid'
About 9 in 10
Seq Scan on orders. The index is ignored.
WHERE status = 'pending'
About 1 in 1,000
Index Scan or Bitmap Heap Scan using the status index.
The planner ignores the index for paid because an index scan jumps around the table. Reading nine million rows by jumping costs more than one straight read.
The planner decides from statistics about each column, such as how common each value is. The command ANALYZE refreshes them.
A background process called autovacuum usually runs ANALYZE for you. Old statistics make estimated rows differ from actual rows.
Watch out
If the value you search for matches many rows, the planner skips the index. You still pay for it on every write.
After a big load
After a big data load, run ANALYZE on the table. Until then, the planner uses old statistics and can pick the wrong plan.
05 · Rewrite
Predicates an index can use
⏱ 10 min · Topic 5 of 13
A predicate is a test in WHERE. A predicate is sargable when the database can use an index to jump to the matching rows. The name comes from "search argument able".
The index stores the column alone, sorted. We call this the bare column: no function and no math on it.
If you change the column inside the test, the stored values no longer match. So keep the bare column on one side, and do the work on the other side.
A function on the column
You want all orders from January 2026. The first version wraps order_date in date_trunc, which cuts a date back to the first day of its month. The second asks for a range:
Month filter: function versus rangeworked example
SQL
Input data
orders6 rows
order_id
customer_id
status
amount
order_date
101
1
paid
40.00
2026-01-03
102
1
paid
25.00
2026-01-10
103
2
refunded
60.00
2026-01-11
104
3
paid
15.50
2026-01-15
105
3
pending
NULL
2026-01-20
106
4
paid
80.00
2026-01-21
-- Before: the function hides the column.SELECTorder_idFROMordersWHEREdate_trunc('month',order_date)='2026-01-01';-- After: a range on the bare column.SELECTorder_idFROMordersWHEREorder_date>='2026-01-01'ANDorder_date<'2026-02-01';
Result · 2 rows
version
shop result
likely plan with 10 million rows and an index on order_date
before
101, 102, 103, 104, 105, 106
Seq Scan on orders
after
101, 102, 103, 104, 105, 106
Index Scan or Bitmap Heap Scan using the order_date index
Simplified plan. Real EXPLAIN output also shows costs and widths; they are removed here.
All six orders are in January, so both queries return the same rows. The range ends before 1 February, so it also works when order_date holds a time.
A wildcard at the start of LIKE
Imagine customers also has 10 million rows and an index on name. A B-tree is sorted from the first letter.
A pattern that starts with a letter can use it. A pattern that starts with % cannot:
Names that end with a, and names that start with Aworked example
SQL
Input data
customers5 rows
customer_id
name
country
1
Asha
IN
2
Ben
GB
3
Carla
US
4
Dev
NULL
5
Omar
IN
-- Leading wildcard: no sorted start to jump to.SELECTnameFROMcustomersWHEREnameLIKE'%a';-- Trailing wildcard: jump to 'A', then walk.SELECTnameFROMcustomersWHEREnameLIKE'A%';
Result · 2 rows
query
shop result
likely plan
name LIKE '%a'
Asha, Carla
Seq Scan on customers
name LIKE 'A%'
Asha
Index Scan or Bitmap Heap Scan using a text_pattern_ops index on name
Simplified plan. Real EXPLAIN output also shows costs and widths; they are removed here.
The two queries ask different questions. You cannot swap one for the other. Use the second form only when you know how the name starts.
PostgreSQL detail
For LIKE with a prefix, create the index with text_pattern_ops, a setting that compares text letter by letter. You can skip it if the column uses the C collation, a byte-by-byte sort order. To search inside text, use a trigram index from the pg_trgm extension: it stores every three-letter piece of the text.
A type that does not match
order_id is an integer. A literal is a value you type in the query, and 104.0 is a numeric literal. PostgreSQL then converts order_id to numeric on every row, and the integer index cannot help:
A numeric literal against an integer columnworked example
SQL
Input data
orders6 rows
order_id
customer_id
status
amount
order_date
101
1
paid
40.00
2026-01-03
102
1
paid
25.00
2026-01-10
103
2
refunded
60.00
2026-01-11
104
3
paid
15.50
2026-01-15
105
3
pending
NULL
2026-01-20
106
4
paid
80.00
2026-01-21
-- Before: 104.0 is numeric, the column is integer.SELECTorder_idFROMordersWHEREorder_id=104.0;-- After: the literal has the column's type.SELECTorder_idFROMordersWHEREorder_id=104;
Result · 2 rows
version
shop result
likely plan with 10 million rows
before
104
Seq Scan, Filter: ((order_id)::numeric = 104.0)
after
104
Index Scan using orders_pkey
Simplified plan. Real EXPLAIN output also shows costs and widths; they are removed here.
The ::numeric in the Filter line is a cast, a change of type. It is the warning sign. Some database libraries send numbers as numeric, so check what your application really sends.
Other engines
With a text column and a number, such as WHERE phone = 5550100, the engines differ. MySQL and SQL Server convert the text column on every row, so the index is not used. PostgreSQL stops with an error: operator does not exist: text = integer.
06 · Rewrite
OR, arithmetic and expressions
⏱ 10 min · Topic 6 of 13
OR across two columns
A test like customer_id = 3 OR status = 'refunded' needs an index on each column. If only customer_id has one, the planner reads the whole table. A UNION rewrite does not fix that.
Say both columns have an index. Here are the OR form and the UNION form. Both return the same rows:
OR across columns, and the UNION formworked example
SQL
Input data
orders6 rows
order_id
customer_id
status
amount
order_date
101
1
paid
40.00
2026-01-03
102
1
paid
25.00
2026-01-10
103
2
refunded
60.00
2026-01-11
104
3
paid
15.50
2026-01-15
105
3
pending
NULL
2026-01-20
106
4
paid
80.00
2026-01-21
-- One OR across two columns.SELECTorder_idFROMordersWHEREcustomer_id=3ORstatus='refunded';-- The same question as two queries.SELECTorder_idFROMordersWHEREcustomer_id=3UNIONSELECTorder_idFROMordersWHEREstatus='refunded';
Result · 2 rows
version
shop result
likely plan with 10 million rows
OR
103, 104, 105
BitmapOr of both indexes, then Bitmap Heap Scan
UNION
103, 104, 105
Two Index Scans, then Append and HashAggregate
Simplified plan. Real EXPLAIN output also shows costs and widths; they are removed here. Assumes both columns have an index and each side matches few rows.
Customer 3 owns orders 104 and 105, and order 103 is refunded. BitmapOr joins the row places from both indexes. HashAggregate removes duplicates after the UNION.
Select a key such as order_id. UNION removes duplicate output rows, so without a key it can drop real rows.
In PostgreSQL the OR form is often fine. The UNION rewrite helps when the OR spans two joined tables, such as c.country = 'IN' OR o.status = 'refunded'. One index cannot serve both tables.
OR across two joined tables, and the UNION rewriteworked example
SQL
Input data
orders6 rows
order_id
customer_id
status
amount
order_date
101
1
paid
40.00
2026-01-03
102
1
paid
25.00
2026-01-10
103
2
refunded
60.00
2026-01-11
104
3
paid
15.50
2026-01-15
105
3
pending
NULL
2026-01-20
106
4
paid
80.00
2026-01-21
customers5 rows
customer_id
name
country
1
Asha
IN
2
Ben
GB
3
Carla
US
4
Dev
NULL
5
Omar
IN
-- Before: the OR mixes customers and orders.SELECTo.order_idFROMordersASoJOINcustomersAScONc.customer_id=o.customer_idWHEREc.country='IN'ORo.status='refunded';-- After: each branch filters one table.SELECTo.order_idFROMordersASoJOINcustomersAScONc.customer_id=o.customer_idWHEREc.country='IN'UNIONSELECTo.order_idFROMordersASoJOINcustomersAScONc.customer_id=o.customer_idWHEREo.status='refunded';
Result · 3 rows
order_id
101
102
103
Asha and Omar have country 'IN' (India). Asha owns orders 101 and 102, and Omar has none. Order 103 is refunded, so both versions return 101, 102 and 103.
Read the plan before you rewrite.
Arithmetic on the column
Math on the column has the same problem as a function. The database cannot match amount * 2 to the stored amounts. Move the math to the other side, or remove it:
Orders above 50worked example
SQL
Input data
orders6 rows
order_id
customer_id
status
amount
order_date
101
1
paid
40.00
2026-01-03
102
1
paid
25.00
2026-01-10
103
2
refunded
60.00
2026-01-11
104
3
paid
15.50
2026-01-15
105
3
pending
NULL
2026-01-20
106
4
paid
80.00
2026-01-21
-- Before: arithmetic on the column.SELECTorder_idFROMordersWHEREamount*2>100;-- After: the bare column.SELECTorder_idFROMordersWHEREamount>50;
Result · 2 rows
version
shop result
likely plan with 10 million rows and an index on amount
before
103, 106
Seq Scan on orders
after
103, 106
Index Scan or Bitmap Heap Scan using the amount index
Simplified plan. Real EXPLAIN output also shows costs and widths; they are removed here.
Order 103 has 60.00 and order 106 has 80.00. Order 105 has a NULL amount, so NULL > 50 is not true and 105 is dropped in both queries.
When you cannot rewrite
Sometimes you really need the function, such as a search that ignores upper and lower case. Then index the expression itself. The query must use the same expression:
The index stores LOWER(name) for each row, so the test matches what is stored. WHERE name = 'Asha' or UPPER(name) = 'ASHA' would not use this index. After you create it, run ANALYZE customers so the planner has statistics for the expression.
Watch out
Sargable means the planner can use an index, not that it will. It can still choose a Seq Scan when most rows match. That is selectivity again.
07 · Design
Composite indexes and column order
⏱ 10 min · Topic 7 of 13
A composite index has more than one column. Take an index on (customer_id, order_date).
It is sorted by customer_id first. Inside each customer, it is sorted by order_date.
Here is that index for the shop:
The index on (customer_id, order_date)worked example
SQL
Input data
orders_customer_date_idx (the index)6 rows
customer_id
order_date
points to
1
2026-01-03
order 101
1
2026-01-10
order 102
2
2026-01-11
order 103
3
2026-01-15
order 104
3
2026-01-20
order 105
4
2026-01-21
order 106
In this tiny shop the dates happen to rise with customer_id. With 10 million real orders, every customer has dates from the whole year, so the dates restart for each customer.
CREATEINDEXorders_customer_date_idxONorders(customer_id,order_date);-- Customer 1, orders from 5 January on.SELECTorder_id,amountFROMordersWHEREcustomer_id=1ANDorder_date>='2026-01-05';
Result · 1 row
order_id
amount
102
25.00
The database jumps to customer 1, then to 5 January inside that customer, and walks to the end of customer 1. Only order 102 is found. Order 101 is before 5 January.
This index serves some queries and not others. This is the left-prefix rule: the search must start with the first column.
WHERE clause
Uses the index well?
Why
customer_id = 1
Yes
It starts with the first column.
customer_id = 1 AND order_date >= '2026-01-05'
Yes
First column, then a range on the second.
order_date >= '2026-01-15'
Not well
The dates restart inside every customer. No sorted start exists.
The third query returns orders 104, 105 and 106 in the shop. With 10 million orders, the planner usually scans the table.
It may read the whole index instead, which is smaller. That is only a small gain.
PostgreSQL 18 added a skip scan. It jumps from one customer_id to the next and searches the dates inside each one.
It helps only when the first column has few different values.
Column order follows two rules:
Put columns tested with = first.
Put the range column last.
The order of the tests inside WHERE does not matter.
Two more uses
WHERE customer_id = 1 ORDER BY order_date needs no sort step, because the index is already in that order. A plain index on (customer_id) alone is usually redundant: not needed. Check first that it is not unique and not used to enforce a rule.
Not redundant
An index on (order_date) alone is not redundant. customer_id has many values, so skip scan does not help there.
08 · Design
Covering indexes and INCLUDE
⏱ 6 min · Topic 8 of 13
A normal index scan has two steps: read the index, then read the table row. A covering index holds every column the query needs. Then the database can skip the second step.
INCLUDE adds extra columns to the index entry. They are stored but not sorted:
Customer 1 amounts from the index aloneworked example
Orders 101 and 102. Row order is not guaranteed without ORDER BY.
The query needs only customer_id and amount. Both are in the index, so the plan can be an Index Only Scan.
Look at the plan for that query:
The plan with a covering indexworked example
SQL
EXPLAINSELECTamountFROMordersWHEREcustomer_id=1;
Result · 2 rows
QUERY PLAN
Index Only Scan using orders_customer_amount_idx on orders
Index Cond: (customer_id = 1)
Simplified plan. Real EXPLAIN output also shows costs and widths; they are removed here.
If you also select order_date, the index no longer covers the query. The plan goes back to Index Scan.
An Index Only Scan still reads the table for rows on recently changed pages. VACUUM, a cleanup job, marks pages with no recent changes in a map called the visibility map.
EXPLAIN ANALYZE counts the other table reads as Heap Fetches.
Watch out
The index grows with each INCLUDE column, and so does its write cost.
09 · Cost
What indexes cost on writes
⏱ 8 min · Topic 9 of 13
An index speeds up some reads. It slows down writes. When you INSERT a row, PostgreSQL writes the table row and one entry in every index.
Say orders has three indexes: the primary key, (customer_id, order_date), and status. One new order is then four writes:
One row goes into the table. Each of the three indexes also gets one new entry.
An UPDATE writes a new row version. If an indexed column changes, every index gets a new entry, not only that column's index.
If no indexed column changes and the page has room, PostgreSQL skips the index work. This is called a HOT update (heap-only tuple; a tuple is one row version). The new row version lives only in the table.
The benefit is per query. The cost is per write, and nobody sees it on one slow statement. An index that no query reads has all the cost and none of the benefit.
PostgreSQL counts index use in pg_stat_user_indexes. This query lists indexes that were never scanned. It skips unique indexes, which enforce a rule even when no query reads them:
Only order 105 is pending, so the index holds one entry. The other five rows are not in it.
The planner uses a partial index only when it can see that every row your WHERE asks for is in the index. The safe way is to repeat the condition exactly.
Watch out
A query sent with a placeholder, such as status = $1, can lose the index. After a few runs, PostgreSQL may switch to a generic plan: one plan for any value. That plan cannot check the condition, so it skips the partial index.
This also answers the selectivity problem. A full index on status is ignored for paid. A partial index on the rare value keeps the benefit and skips the cost of the common ones.
11 · Contrast
Warehouses have no B-tree indexes
⏱ 6 min · Topic 11 of 13
Normal Snowflake and BigQuery tables have no B-tree indexes. There is nothing to create. A warehouse reads many rows to build a summary, so the goal is to read fewer bytes, not to find single rows.
They use other tools. Data is split into partitions, and the engine skips the partitions a filter does not need. This skipping is called pruning.
Clustering keeps similar values stored together, so pruning works better.
The ideas are similar, but the tools differ:
Row store (PostgreSQL)
Columnar warehouse
What is shared
B-tree index
Partition pruning
Both read less data. A function around the column can stop both.
Column order in a composite index
Clustering keys
Both help filters on the first column most.
Covering index
Column pruning
Both avoid reading data the query does not need.
Index write cost
Cost of keeping data clustered
Both add a cost to make reads faster.
Only the sargability habit stays the same: keep the bare column in the filter. Costs, partitions and bytes scanned are taught in the next chapter.
Snowflake hybrid tables
Snowflake hybrid tables are an exception. They are row-store tables with a required primary key. You can add more indexes with CREATE INDEX.
Search tools
Snowflake also offers a search optimization service for finding single rows. BigQuery has search indexes, mainly for text. These add to this model instead of replacing it.
12 · Practice
Practice Lab
⏱ 6 min · Topic 12 of 13
These four problems use system tables (the catalog): column statistics, a query log, and an index list. The workspace has no EXPLAIN. Index decisions often come from statistics and history, not from one plan.
Do the selectivity audit and the unused-index report together. They answer the same question in two ways.