OnCallReady

Lesson 25.32 · PostgreSQL Operations · 25 min read

Indexes and EXPLAIN

In plain words

Before a delivery company sends a van, a planner draws the route. EXPLAIN shows you the route PostgreSQL planned for your query: which streets (tables) it will drive down, whether it uses the motorway (an index) or checks every house on every street (a sequential scan).

EXPLAIN ANALYZE actually drives the route and writes down how long each part took and how many parcels were dropped off. A route that visits forty thousand houses to deliver ten parcels needs a better map - an index.

Why this lesson exists

"The orders page is slow for some customers" ends, nine times out of ten, at one query doing far more work than it needs to - usually reading the whole table because no index fits. You do not need to be a query tuner to fix most of these, but you need to read the plan the database chose, see where the time goes, and know which index would change it. EXPLAIN is how PostgreSQL tells you what it is doing; this lesson teaches you to read it on real plans from the lab's orders database.

What you need to know already: SQL WHERE, JOIN, ORDER BY ... LIMIT; psql and \timing (lesson 3); table sizes with \dt+ (lesson 3); MVCC and dead tuples (lessons 6-7).

The words you need first

Reading a plan

The query behind the slow page: the latest orders of one customer - customer 1234, a key account with a few thousand orders (most customers have about ten).

$ sudo -u postgres psql -d orders -c "explain select id, status, total_cents, created_at from orders where customer_id = 1234 order by created_at desc limit 10"
                               QUERY PLAN
------------------------------------------------------------------------
 Limit  (cost=999.47..999.49 rows=10 width=26)
   ->  Sort  (cost=999.47..1006.88 rows=2966 width=26)
         Sort Key: created_at DESC
         ->  Seq Scan on orders  (cost=0.00..935.38 rows=2966 width=26)
               Filter: (customer_id = 1234)
(5 rows)

Read from the innermost (most indented) node out: a Seq Scan on orders with a Filter on customer_id, feeding a Sort, feeding the Limit. rows= is how many rows the planner expects; width the average row size in bytes. EXPLAIN alone does not run the query. EXPLAIN ANALYZE does - and adds what really happened:

$ sudo -u postgres psql -d orders -c "explain (analyze) select id, status, total_cents, created_at from orders where customer_id = 1234 order by created_at desc limit 10"
                                                       QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=999.47..999.49 rows=10 width=26) (actual time=6.067..6.074 rows=10.00 loops=1)
   Buffers: shared hit=398
   ->  Sort  (cost=999.47..1006.88 rows=2966 width=26) (actual time=6.065..6.067 rows=10.00 loops=1)
         Sort Key: created_at DESC
         Sort Method: top-N heapsort  Memory: 17kB
         Buffers: shared hit=398
         ->  Seq Scan on orders  (cost=0.00..935.38 rows=2966 width=26) (actual time=0.084..4.629 rows=3000.00 loops=1)
               Filter: (customer_id = 1234)
               Rows Removed by Filter: 39990
               Buffers: shared hit=398
 Planning Time: 0.027 ms
 Execution Time: 6.158 ms
(12 rows)
in the outputmeans
actual time=0.02..8.31ms to the first row .. to the last, per loop
rows=10 loops=1rows really produced; multiply by loops for nested loops
Rows Removed by Filter: 39990rows read and thrown away - the waste
Buffers: shared hit=N read=Mpages from PostgreSQL's cache / from the OS or disk (shown by default with ANALYZE since 18)
Planning Time / Execution Timethe whole thing, in ms

The waste is plain: the whole table read, ten rows kept. Because ANALYZE really executes the statement, never run EXPLAIN ANALYZE on an UPDATE or DELETE you do not want to happen - wrap it: BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK;.

The right index

An index on the filtered column:

$ sudo -u postgres psql -d orders -c "create index orders_customer_id_idx on orders (customer_id)"
CREATE INDEX
$ sudo -u postgres psql -d orders -c "explain (analyze) select id, status, total_cents, created_at from orders where customer_id = 1234 order by created_at desc limit 10"
                                                                     QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=538.45..538.47 rows=10 width=26) (actual time=2.548..2.554 rows=10.00 loops=1)
   Buffers: shared hit=42 read=4
   I/O Timings: shared read=0.018
   ->  Sort  (cost=538.45..545.86 rows=2966 width=26) (actual time=2.546..2.548 rows=10.00 loops=1)
         Sort Key: created_at DESC
         Sort Method: top-N heapsort  Memory: 17kB
         Buffers: shared hit=42 read=4
         I/O Timings: shared read=0.018
         ->  Bitmap Heap Scan on orders  (cost=39.28..474.35 rows=2966 width=26) (actual time=0.220..1.224 rows=3000.00 loops=1)
               Recheck Cond: (customer_id = 1234)
               Heap Blocks: exact=39
               Buffers: shared hit=42 read=4
               I/O Timings: shared read=0.018
               ->  Bitmap Index Scan on orders_customer_id_idx  (cost=0.00..38.53 rows=2966 width=0) (actual time=0.204..0.204 rows=3000.00 loops=1)
                     Index Cond: (customer_id = 1234)
                     Index Searches: 1
                     Buffers: shared hit=3 read=4
                     I/O Timings: shared read=0.018
 Planning:
   Buffers: shared hit=2 read=1
   I/O Timings: shared read=0.015
 Planning Time: 0.092 ms
 Execution Time: 2.568 ms
(23 rows)

Bitmap Index Scan on the new index finds the customer's rows, Bitmap Heap Scan fetches them, a sort picks the newest ten. Far less work - but customer 1234 is a big account with thousands of orders, and every one of them is fetched and sorted to return ten. Better still, an index that also has the order the query wants, so the database can read the newest 10 entries and stop:

$ sudo -u postgres psql -d orders -c "create index orders_customer_created_idx on orders (customer_id, created_at desc)"
CREATE INDEX
$ sudo -u postgres psql -d orders -c "explain (analyze) select id, status, total_cents, created_at from orders where customer_id = 1234 order by created_at desc limit 10"
                                                                     QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.29..5.98 rows=10 width=26) (actual time=0.025..0.035 rows=10.00 loops=1)
   Buffers: shared hit=6 read=2
   I/O Timings: shared read=0.009
   ->  Index Scan using orders_customer_created_idx on orders  (cost=0.29..1687.50 rows=2966 width=26) (actual time=0.023..0.029 rows=10.00 loops=1)
         Index Cond: (customer_id = 1234)
         Index Searches: 1
         Buffers: shared hit=6 read=2
         I/O Timings: shared read=0.009
 Planning:
   Buffers: shared hit=2 read=1
   I/O Timings: shared read=0.014
 Planning Time: 0.089 ms
 Execution Time: 0.046 ms
(13 rows)

Index Scan using orders_customer_created_idx with no sort node at all. The rules for multi-column B-tree indexes:

Now the first index is redundant - the composite one covers customer_id = ? too. Unused indexes are not free: every INSERT and most UPDATEs must update every index, they take disk and cache, and they stop HOT updates (an update that touches no indexed column can stay on the same page without touching any index). Drop what you do not use:

$ sudo -u postgres psql -d orders -c "drop index orders_customer_id_idx"
DROP INDEX
$ sudo -u postgres psql -d orders -c "select indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) as size from pg_stat_user_indexes where relname = 'orders' order by idx_scan"
        indexrelname         | idx_scan |  size
-----------------------------+----------+---------
 orders_customer_created_idx |        1 | 1336 kB
 orders_pkey                 |    40000 | 960 kB
(2 rows)

idx_scan = 0 for weeks on a production server (statistics are per server - check the replicas too) means the index is dead weight.

Other index shapes

needindexexample
a function in the WHEREexpression indexcreate index on customers (lower(email)) for where lower(email) = lower($1)
only some rows are ever queriedpartial indexcreate index on orders (created_at) where status = 'new' - small and hot
answer without visiting the tablecovering indexcreate index on orders (customer_id) include (status, total_cents) -> Index Only Scan
jsonb, arrays, full textGINcreate index on events using gin (payload)
huge, naturally ordered tables (time series)BRINtiny; for created_at ranges on append-only tables

A query that applies a function to an indexed column cannot use a plain index on it:

$ sudo -u postgres psql -d orders -c "explain select id from customers where lower(email) = '[email protected]'"
                         QUERY PLAN
------------------------------------------------------------
 Seq Scan on customers  (cost=0.00..105.00 rows=20 width=8)
   Filter: (lower(email) = '[email protected]'::text)
(2 rows)

Seq Scan despite the unique index on email - the index is on email, not lower(email).

When the plan is wrong: statistics

The planner chooses by estimated rows. When the estimate is far from actual rows, the plan can be wildly wrong (a nested loop over 100,000 rows it thought were 10). Estimates come from ANALYZE; autovacuum runs it after enough changes, but a bulk load or a big delete can leave stale statistics behind:

$ sudo -u postgres psql -d orders -c "select attname, n_distinct, most_common_vals from pg_stats where tablename = 'orders' and attname in ('status', 'customer_id')"
   attname   | n_distinct |           most_common_vals
-------------+------------+---------------------------------------
 customer_id |       4000 | {1234}
 status      |          5 | {paid,shipped,new,cancelled,refunded}
(2 rows)
$ sudo -u postgres psql -d orders -c "select relname, last_analyze, last_autoanalyze, n_mod_since_analyze from pg_stat_user_tables where relname = 'orders'"
 relname |       last_analyze        | last_autoanalyze | n_mod_since_analyze
---------+---------------------------+------------------+---------------------
 orders  | 2026-09-22 20:01:50.75+00 |                  |                   0
(1 row)

n_distinct positive = an absolute number of distinct values, negative = a fraction of the rows (-1 = unique). After a bulk change, an ANALYZE orders is cheap and often the whole fix.

Settings that change plans on every query - set them right for your hardware once:

settingdefaultnote
random_page_cost4.0the cost of a random page read vs a sequential one (1.0). On SSD/NVMe and cloud disks 1.1 is the usual value - with 4.0 the planner under-uses indexes
effective_cache_size4GBhow much the OS + PostgreSQL can cache; a hint, allocates nothing. ~50-75% of RAM
work_mem4MBmemory per sort or hash node, per query before spilling to disk. Too small: Sort Method: external merge Disk: ... in the plan. Too big x many connections: out of memory

A sort that spills:

$ sudo -u postgres psql -d orders -c "set work_mem = '64kB'" -c "explain (analyze, costs off) select * from orders order by total_cents" 2>&1 | grep -E 'Sort Method|Execution'
   Sort Method: external merge  Disk: 2440kB
 Execution Time: 56.804 ms
$ sudo -u postgres psql -d orders -c "set work_mem = '16MB'" -c "explain (analyze, costs off) select * from orders order by total_cents" 2>&1 | grep -E 'Sort Method|Execution'
   Sort Method: quicksort  Memory: 3666kB
 Execution Time: 34.660 ms

Raise work_mem for the role or the session that runs the big report (ALTER ROLE reporting SET work_mem = '256MB'), not globally.

Tools worth knowing: EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) pasted into a visualiser (explain.depesz.com, explain.dalibo.com) for big plans, and auto_explain (a contrib module that logs the plans of slow queries automatically - next lesson's neighbour).

In an interview: "A query got slow - how do you find out why?" - run it with EXPLAIN (ANALYZE, BUFFERS), read from the innermost node: a Seq Scan with a big Rows Removed by Filter, estimates far from actual rows (stale statistics -> ANALYZE), a sort spilling to disk (work_mem). Fix with an index that matches the query - equality columns first, then the sort/range column - built with CREATE INDEX CONCURRENTLY on a live table.

What you can do now

Why it helps

"The page is slow for some customers" ends at one query doing far more work than needed, nine times out of ten. You do not have to be a query tuner, but you need to read a plan, see the Seq Scan with a huge Rows Removed by Filter, and know which index changes it - equality columns first, then the sort column.

It also makes you a better reviewer: unused indexes slow every write, functions on indexed columns defeat indexes, and stale statistics produce bad plans.

Commands in this lesson

psql

FAQ

Is EXPLAIN ANALYZE safe to run in production?

It really executes the statement. For a SELECT that is the cost of running it once. For UPDATE, DELETE or INSERT it changes data, so wrap it: BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK. And remember a slow query explained with ANALYZE takes as long as the query itself.

Why is my index not used?

Common reasons: the query applies a function or cast to the column (lower(email) does not use an index on email), the condition matches most of the table so a sequential scan really is cheaper, the leading column of a multi-column index is not in the WHERE, statistics are stale, or random_page_cost is still the spinning-disk default. The plan tells you which.

What order should columns have in a composite index?

Equality columns first, then the range or ORDER BY column, in the direction the query sorts. An index on (customer_id, created_at desc) serves customer_id = ? ORDER BY created_at DESC LIMIT 10 without any sort, stopping after ten entries. An index whose leading column the query does not filter on helps little.

Do indexes have a cost?

Yes. Every INSERT, and every UPDATE that touches an indexed column, must update each index; they use disk and memory; and an update that changes an indexed column cannot be a HOT update. Indexes with idx_scan = 0 in pg_stat_user_indexes for weeks (on the primary and the replicas) are candidates to drop.

What does Buffers mean in the plan?

Pages the step read: shared hit from PostgreSQL's own cache, read from the operating system or disk. Since PostgreSQL 18, EXPLAIN ANALYZE shows them by default. High read counts explain slow queries that look cheap, and they show the effect of an index more clearly than timings on a warm cache.

In an interview Mid

A query got slow. How do you find out why?

Run it with EXPLAIN (ANALYZE, BUFFERS) (inside BEGIN/ROLLBACK if it writes) and read from the innermost node out. Look for a Seq Scan with a large Rows Removed by Filter, estimated rows far from actual rows (stale statistics - run ANALYZE), a sort spilling to disk (work_mem), or nested loops over many rows. Fix it with an index that matches the query - a composite index with equality columns first and then the ORDER BY column, an expression index for functions like lower(email), a partial index for a hot subset - built with CREATE INDEX CONCURRENTLY, then compare the new plan.

Also asked: What is the difference between EXPLAIN and EXPLAIN ANALYZE? · When is a sequential scan the right plan? · How do you find unused indexes?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.