This note goes hand in hand with the noteIndex .
-- 0) Refresh planner statistics (affects planning quality)
ANALYZE users;
-- Collects table & column statistics (row count, value distribution, MCV, histograms).
-- Required after bulk data changes to make EXPLAIN meaningful.
-- Most common values (MCV) and histograms drastically affect selectivity estimates.
-- If stats are stale, row estimates can be wildly wrong (leading to bad plans).
-- 1) Show the planner's **estimated plan (NO execution)**
EXPLAIN
SELECT ...;
-- Displays planner **estimates only** (cost, estimated rows, chosen plan).
-- Uses statistics from ANALYZE.
-- Does NOT execute the query.
-- 2) Execute the query and show real **runtime behavior**
EXPLAIN ANALYZE
SELECT ...;
-- Executes the query and shows **actual time**, actual rows, loops.
-- **Still uses planner estimates for plan selection**.
-- **Does NOT refresh statistics**.
-- 3) Add I/O visibility (heap vs index access)
EXPLAIN (ANALYZE, BUFFERS)
SELECT ...;
-- PostgreSQL’s cost model uses default I/O and CPU cost constants;
-- tuning these can change plan choices but does not affect correctness.
-- 4) Maximum debugging context
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT ...;
Example: Users and Orders
CREATE TABLE users (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL,
country TEXT NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE TABLE orders (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id),
amount INT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMP NOT NULL
);
INSERT INTO users (email, country, created_at)
SELECT
'user' || i || '@example.com',
CASE WHEN i % 2 = 0 THEN 'US' ELSE 'TW' END,
NOW() - (i || ' days')::INTERVAL
FROM generate_series(1, 1000) AS i; -- ~1,000 users
If the table is small relative to the predicate density, Sequential Scan is often cheapest despite having no index usage.
EXPLAIN
SELECT *
FROM users
WHERE country = 'TW';
-- Output
Seq Scan on users (cost=0.00..21.50 rows=500 width=34)
Filter: (country = 'TW'::text)
(pages_read × seq_page_cost) + (rows × cpu_tuple_cost) + (rows × cpu_operator_cost)Prerequisite: Where/How PostgreSQL stores the data
Add One Index and Rerun
CREATE INDEX idx_users_country ON users(country);
-- Force PostgreSQL not using Sequential Scan
-- Because the dataset is small or the data selectivity is high (~50%),
-- the cost of I/O (reading index pages) more than reading the table once
SET enable_seqscan = off;
EXPLAIN
SELECT *
FROM users
WHERE country = 'TW';
-- Reset afterward
RESET enable_seqscan;
-- Output
Bitmap Heap Scan on users (cost=8.03..23.27 rows=500 width=34)
Recheck Cond: (country = 'TW'::text)
-> Bitmap Index Scan on idx_users_country (cost=0.00..7.90 rows=500 width=0)
Index Cond: (country = 'TW'::text)
SET enable_seqscan = off; is needed.The approach reduce the I/O cost: