Now we’ll go full-on query performance pro mode using EXPLAIN ANALYZE and real plans. We’ll learn how PostgreSQL makes decisions, how to catch slow queries, and how your indexes make them 10x faster.
๐ Part 1: What is EXPLAIN ANALYZE?
EXPLAIN shows how PostgreSQL plans to execute your query.
ANALYZE runs the query and adds actual time, rows, loops, etc.
Syntax:
EXPLAIN ANALYZE
SELECT * FROM users WHERE username = 'bob';
โ๏ธ Example 1: Without Index
SELECT * FROM users WHERE username = 'bob';
If username has no index, plan shows:
Seq Scan on users
Filter: (username = 'bob')
Rows Removed by Filter: 9999
โ PostgreSQL scans all rows = Sequential Scan = slow!
โ Add Index:
CREATE INDEX idx_users_username ON users (username);
Now rerun:
EXPLAIN ANALYZE SELECT * FROM users WHERE username = 'bob';
You’ll see:
Index Scan using idx_users_username on users
Index Cond: (username = 'bob')
โ
PostgreSQL uses B-tree index
๐ Massive speed-up!
๐ฅ Want even faster?
SELECT username FROM users WHERE username = 'bob';
If PostgreSQL shows:
Index Only Scan using idx_users_username on users
Index Cond: (username = 'bob')
๐ Index Only Scan! = covering index success!
No heap fetch = lightning-fast.
โ ๏ธ Note: Index-only scan only works if:
- Index covers all selected columns
- Table is vacuumed (PostgreSQL uses visibility map)
If you still get Seq scan output like:
test_db=# EXPLAIN ANALYSE SELECT * FROM users where username = 'aman_chetri';
QUERY PLAN
-------------------------------------------------------------------------------------------------
Seq Scan on users (cost=0.00..1.11 rows=1 width=838) (actual time=0.031..0.034 rows=1 loops=1)
Filter: ((username)::text = 'aman_chetri'::text)
Rows Removed by Filter: 2
Planning Time: 0.242 ms
Execution Time: 0.077 ms
(5 rows)
even after adding an index, because PostgreSQL is saying:
- ๐ค “The table is so small (cost = 1.11), scanning the whole thing is cheaper than using the index.”
- Also: Your query uses only
SELECT username, which could be eligible for Index Only Scan, but heap fetch might still be needed due to visibility map.
๐ง Step-by-step Fix:
โ 1. Add Data for Bigger Table
If the table is small (few rows), PostgreSQL will prefer Seq Scan no matter what.
Try adding ~10,000 rows:
INSERT INTO users (username, email, phone_number)
SELECT 'user_' || i, 'user_' || i || '@mail.com', '1234567890'
FROM generate_series(1, 10000) i;
Then VACUUM ANALYZE users; again and retry EXPLAIN.
โ 2. Confirm Index Exists
First, check your index exists and is recognized:
\d users
You should see something like:
Indexes:
"idx_users_username" btree (username)
If not, add:
CREATE INDEX idx_users_username ON users(username);
โ
3. Run ANALYZE (Update Stats)
ANALYZE users;
This updates statistics โ PostgreSQL might not be using the index if it thinks only one row matches or the table is tiny.
โ 4. Vacuum for Index-Only Scan
Index-only scans require the visibility map to be set.
Run:
VACUUM ANALYZE users;
This marks pages in the table as “all-visible,” enabling PostgreSQL to avoid reading the heap.
โ 5. Force PostgreSQL to Consider Index
You can turn off sequential scan temporarily (for testing):
SET enable_seqscan = OFF;
EXPLAIN SELECT username FROM users WHERE username = 'bob';
You should now see:
Index Scan using idx_users_username on users ...
โ ๏ธ Use this only for testing/debugging โ not in production.
๐ก Extra Tip (optional): Use EXPLAIN (ANALYZE, BUFFERS)
EXPLAIN (ANALYZE, BUFFERS)
SELECT username FROM users WHERE username = 'bob';
This will show:
- Whether heap was accessed
- Buffer hits
- Actual rows
๐ Summary
| Step | Command |
|---|---|
| Check Index | \d users |
| Analyze table | ANALYZE users; |
| Vacuum for visibility | VACUUM ANALYZE users; |
| Disable seq scan for test | SET enable_seqscan = OFF; |
| Add more rows (optional) | INSERT INTO ... |
๐จ How to catch bad index usage?
Always look for:
- “Seq Scan” instead of “Index Scan” โ missing index
- “Heap Fetch” โ not a covering index
- “Rows Removed by Filter” โ inefficient filtering
- “Loops: 1000+” โ possible N+1 issue
Common Pattern Optimizations
| Pattern | Fix |
|---|---|
WHERE column = ? | B-tree index on column |
WHERE column LIKE 'prefix%' | B-tree works (with text_ops) |
SELECT col1 WHERE col2 = ? | Covering index: (col2, col1) or (col2) INCLUDE (col1) |
WHERE col BETWEEN ? | Composite index with range second: (status, created_at) |
WHERE col IN (?, ?, ?) | Index still helps |
ORDER BY col LIMIT 10 | Index on col helps sort fast |
โก Tip: Use pg_stat_statements to Find Slow Queries
Enable it in postgresql.conf:
shared_preload_libraries = 'pg_stat_statements'
Then run:
SELECT query, total_exec_time, calls
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;
๐ฏ Find your worst queries & optimize them with new indexes!
๐งช Try It Yourself
Want a little lab setup to practice?
CREATE TABLE users (
user_id serial PRIMARY KEY,
username VARCHAR(220),
email VARCHAR(150),
phone_number VARCHAR(20)
);
-- Insert 100K fake rows
INSERT INTO users (username, email, phone_number)
SELECT
'user_' || i,
'user_' || i || '@example.com',
'999-000-' || i
FROM generate_series(1, 100000) i;
Then test:
EXPLAIN ANALYZE SELECT * FROM users WHERE username = 'user_5000';- Add
INDEX ON username - Re-run, compare speed!
๐ฏ Extra Pro Tools for Query Performance
EXPLAIN ANALYZEโ Always first toolpg_stat_statementsโ Find slow queries in real appsauto_explainโ Log slow plans automaticallypgBadgerorpgHeroโ Visual query monitoring
๐ฅ Now We Know:
โ
How to read query plans
โ
When you’re doing full scans vs index scans
โ
How to achieve index-only scans
โ
How to catch bad performance early
โ
How to test and fix in real world
Happy Performance Fixing..ย ๐