Split View: EXPLAIN ANALYZE 읽는 법 — 실행 계획에서 진짜 병목을 찾는 순서
EXPLAIN ANALYZE 읽는 법 — 실행 계획에서 진짜 병목을 찾는 순서
- 들어가며 — 실행 계획은 봤는데 어디가 느린지 모르겠다면
- EXPLAIN과 EXPLAIN ANALYZE — 하나는 예측, 하나는 실행
- 출력을 읽는 순서 — 안쪽 노드부터, 아래에서 위로
- cost는 시간이 아니고, rows의 괴리가 진짜 신호다
- loops가 곱해진다 — actual time은 1회 평균이다
- Seq Scan은 죄가 없다, 그리고 조인 세 가지가 갈리는 지점
- BUFFERS와 Rows Removed by Filter — 나머지 두 단서
- 마치며 — 노드 이름이 아니라 괴리를 보라
들어가며 — 실행 계획은 봤는데 어디가 느린지 모르겠다면
느린 쿼리를 만나면 대부분 EXPLAIN ANALYZE를 붙여 봅니다. 그리고 화면을 가득 채운 들여쓰기와 괄호 속 숫자를 한참 보다가, 결국 "Seq Scan이 보이니 인덱스를 만들자"는 결론으로 건너뜁니다. 그 결론이 맞을 때도 있습니다. 하지만 실행 계획이 알려 주려던 것은 대개 다른 이야기입니다.
실행 계획을 읽는다는 것은 노드 이름을 훑는 일이 아닙니다. 옵티마이저의 예측과 실제 결과 사이의 괴리를 찾는 일입니다. 괴리가 없다면 옵티마이저는 자기가 아는 정보 안에서 최선을 골랐다는 뜻이고, 남은 병목은 물리적인 문제입니다. 괴리가 크다면 옵티마이저는 틀린 전제 위에서 정확하게 계산한 것이고, 손봐야 할 대상은 쿼리가 아니라 통계입니다.
이 글은 실제 출력을 한 줄씩 해부하면서, 어떤 숫자를 어떤 순서로 보아야 하는지를 정리합니다. PostgreSQL을 기준으로 하고, MySQL이 달라지는 지점은 그때그때 짚겠습니다.
EXPLAIN과 EXPLAIN ANALYZE — 하나는 예측, 하나는 실행
두 명령은 이름만 비슷할 뿐 하는 일이 다릅니다.
-- 계획만 세우고 끝. 쿼리는 실행되지 않는다.
EXPLAIN
SELECT * FROM orders WHERE user_id = 42;
-- 실제로 실행한 뒤, 계획에 실측치를 얹어서 보여준다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 42;
EXPLAIN만 붙이면 옵티마이저가 통계를 근거로 세운 계획과 추정치만 나옵니다. 빠르고 안전하지만, 그 추정이 맞는지는 알 수 없습니다. ANALYZE 옵션을 붙이면 쿼리를 실제로 실행하고 각 노드가 실제로 몇 행을 내놓았는지, 얼마나 걸렸는지를 함께 출력합니다.
여기서 자주 사고가 납니다. EXPLAIN ANALYZE UPDATE ...는 진짜로 UPDATE를 수행합니다. 쓰기 쿼리를 분석할 때는 반드시 트랜잭션으로 감싸고 롤백해야 합니다.
BEGIN;
EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders SET status = 'shipped' WHERE id = 1001;
ROLLBACK;
옵션은 몇 가지 더 있고, 실무에서 유용한 조합은 사실상 정해져 있습니다.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, FORMAT TEXT)
SELECT ...;
BUFFERS— 노드별 블록 읽기 통계. 이게 없으면 캐시 문제를 볼 수 없습니다.VERBOSE— 출력 컬럼 목록과 스키마 한정 이름. 조인이 복잡할 때 유용합니다.SETTINGS— 기본값에서 벗어난 플래너 파라미터를 표시합니다. 남이 만든 환경을 조사할 때 결정적입니다.FORMAT JSON— 도구에 넣어 파싱할 때만 쓰고, 사람이 읽을 때는 TEXT가 낫습니다.
MySQL에서는 8.0.18부터 EXPLAIN ANALYZE가 생겼고, 그 이전에는 EXPLAIN FORMAT=TREE나 SHOW WARNINGS로 옵티마이저가 다시 쓴 쿼리를 확인하는 방식이었습니다. 출력 형태는 다르지만 읽는 원칙은 같습니다.
출력을 읽는 순서 — 안쪽 노드부터, 아래에서 위로
실행 계획은 트리입니다. 텍스트로 출력될 때 들여쓰기가 깊을수록 트리의 안쪽, 즉 먼저 실행되는 노드입니다. 위에서 아래로 읽으면 반대로 읽는 셈입니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.name, o.id, o.total
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.country = 'KR'
AND o.created_at >= '2026-06-01';
Hash Join (cost=1842.00..24310.55 rows=18420 width=44)
(actual time=8.412..131.207 rows=17903 loops=1)
Hash Cond: (o.user_id = u.id)
Buffers: shared hit=4021 read=9877
-> Seq Scan on orders o (cost=0.00..20117.00 rows=92300 width=20)
(actual time=0.019..92.441 rows=91188 loops=1)
Filter: (created_at >= '2026-06-01 00:00:00'::timestamp)
Rows Removed by Filter: 708812
Buffers: shared hit=3110 read=9004
-> Hash (cost=1610.00..1610.00 rows=18560 width=28)
(actual time=8.287..8.288 rows=18402 loops=1)
Buckets: 32768 Batches: 1 Memory Usage: 1408kB
Buffers: shared hit=911 read=873
-> Seq Scan on users u (cost=0.00..1610.00 rows=18560 width=28)
(actual time=0.011..5.902 rows=18402 loops=1)
Filter: (country = 'KR'::text)
Rows Removed by Filter: 61598
Buffers: shared hit=911 read=873
Planning Time: 0.184 ms
Execution Time: 133.902 ms
읽는 순서는 이렇습니다.
- 가장 깊이 들여쓰인
Seq Scan on users u가 먼저 실행되어 18402행을 만듭니다. - 그 결과가
Hash노드로 올라가 메모리에 해시 테이블로 적재됩니다. - 그다음
Seq Scan on orders o가 91188행을 흘려보냅니다. - 최상단
Hash Join이 둘을 결합해 17903행을 냅니다.
즉 형제 노드가 여럿일 때는 위쪽이 먼저이고, 부모 노드는 자식이 끝난 뒤에 완성됩니다. 트리 구조에 익숙해지면 계획을 보자마자 "데이터가 어디서 만들어져 어디로 흐르는가"가 눈에 들어옵니다.
한 가지 주의할 점이 있습니다. 부모 노드의 actual time은 자식의 시간을 포함한 누적값입니다. 위 출력에서 Hash Join의 131ms 중 92ms는 orders 스캔이 쓴 시간입니다. 특정 노드가 순수하게 쓴 시간을 알려면 자식의 시간을 빼야 합니다. 이 사실을 모르면 "조인이 131ms나 걸린다"는 잘못된 결론에 도달합니다.
cost는 시간이 아니고, rows의 괴리가 진짜 신호다
cost=1842.00..24310.55에서 앞의 값은 첫 행을 내놓기까지의 비용, 뒤의 값은 마지막 행까지의 총비용입니다. 문제는 이 숫자의 단위입니다. 밀리초가 아닙니다. 초도 아닙니다. 순차 페이지 한 장을 읽는 비용을 1.0으로 놓은 임의 단위입니다.
-- 비용 단위의 기준값들
SHOW seq_page_cost; -- 1.0 (기준)
SHOW random_page_cost; -- 4.0 (기본값, SSD라면 1.1 근처가 현실적)
SHOW cpu_tuple_cost; -- 0.01
SHOW cpu_operator_cost; -- 0.0025
그래서 cost 24310이 24초를 뜻하지 않습니다. 이 값은 같은 쿼리의 여러 계획 후보를 서로 비교하기 위한 점수일 뿐이고, 다른 쿼리의 cost와 비교하는 것도 사실상 의미가 없습니다. "cost가 10000이 넘으면 위험하다" 같은 기준을 세우는 조언을 종종 보는데, 근거가 없습니다.
정말 봐야 할 것은 같은 노드 안의 rows= 두 개입니다.
-> Seq Scan on orders o (cost=... rows=92300 ...) (actual ... rows=91188 loops=1)
예상 92300, 실제 91188. 오차 1.2퍼센트면 통계가 건강하다는 뜻입니다. 반대로 이런 출력을 만나면 이야기가 달라집니다.
-> Index Scan using idx_orders_status on orders o
(cost=0.42..8.44 rows=1 width=20)
(actual time=0.031..214.882 rows=482913 loops=1)
예상 1행, 실제 482913행. 4만 배 이상 틀렸습니다. 옵티마이저는 "1행만 나올 테니 Nested Loop로 붙이면 되겠다"고 판단했을 것이고, 그 전제가 무너지면서 안쪽 노드를 48만 번 반복하게 됩니다. 이때 고쳐야 할 것은 조인 힌트가 아니라 통계입니다.
-- 1차 처방: 통계 갱신
ANALYZE orders;
-- 그래도 어긋나면 특정 컬럼의 히스토그램 정밀도를 올린다
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
-- 컬럼 간 상관관계 때문에 틀리는 경우 (예: 도시와 우편번호)
CREATE STATISTICS stat_orders_geo (dependencies, ndistinct)
ON city, postal_code FROM orders;
ANALYZE orders;
마지막 확장 통계는 의외로 자주 필요합니다. 옵티마이저는 기본적으로 조건들이 서로 독립이라고 가정하고 선택도를 곱합니다. 실제로는 상관관계가 있는 컬럼 쌍이 많고, 그럴 때 추정치는 실제보다 훨씬 작게 나옵니다.
loops가 곱해진다 — actual time은 1회 평균이다
가장 많은 오독이 여기서 나옵니다.
-> Index Scan using idx_order_items_order_id on order_items i
(cost=0.43..3.21 rows=4 width=16)
(actual time=0.008..0.011 rows=4 loops=52310)
actual time=0.008..0.011을 보고 "0.011밀리초면 무시해도 되겠다"고 넘어가면 안 됩니다. 괄호 안의 시간과 행 수는 모두 1회 실행 기준 평균값입니다. 실제 총합은 loops를 곱해야 나옵니다.
- 총 소요 시간: 0.011ms 곱하기 52310회 = 약 575ms
- 총 반환 행: 4행 곱하기 52310회 = 약 209,240행
전체 쿼리가 700ms라면 이 노드 하나가 80퍼센트를 쓴 셈입니다. 계획 어디에도 "575"라는 숫자는 적혀 있지 않기 때문에, 곱셈을 직접 해 보지 않으면 병목을 지나칩니다.
같은 이유로 Nested Loop 안쪽 노드는 항상 loops를 먼저 확인해야 합니다. loops가 수만 회를 넘어간다면 바깥쪽의 행 수 추정이 틀렸을 가능성이 큽니다. 조인 순서를 바꿀지, 인덱스를 추가할지는 그다음 문제입니다.
병렬 쿼리에서는 한 겹이 더 있습니다. Gather 아래 노드의 loops는 워커 수를 반영하고, Workers Launched가 요청한 수보다 적을 수 있습니다.
Gather (cost=1000.00..38210.13 rows=210 width=8)
(actual time=0.412..312.884 rows=198 loops=1)
Workers Planned: 4
Workers Launched: 2
4개를 계획했지만 2개만 떴다면 max_parallel_workers가 이미 소진된 상태라는 뜻이고, 그만큼 예상보다 느려집니다. 벤치마크 결과가 재현되지 않는다면 이 줄을 먼저 보십시오.
Seq Scan은 죄가 없다, 그리고 조인 세 가지가 갈리는 지점
"Seq Scan이 보이면 인덱스를 만들어라"는 조언은 절반만 맞습니다. 순차 스캔이 인덱스 스캔보다 빠른 상황이 분명히 존재합니다.
첫째, 테이블이 작을 때입니다. 몇백 행짜리 코드 테이블은 통째로 읽어도 페이지 몇 장입니다. 인덱스를 타면 인덱스 페이지를 읽고 힙으로 다시 랜덤 접근해야 하므로 오히려 손해입니다.
둘째, 선택도가 낮을 때입니다. 조건이 테이블의 상당 비율을 통과시킨다면 인덱스는 불리합니다. 인덱스 스캔은 매칭되는 행마다 힙 페이지를 랜덤하게 방문하는데, 방문할 행이 많으면 결국 테이블 전체를 랜덤 순서로 읽는 꼴이 됩니다. 순차 읽기는 디스크와 프리페치에 훨씬 친화적입니다. 대략 전체의 5에서 10퍼센트를 넘어가면 순차 스캔이 이기기 시작하고, 정확한 경계는 random_page_cost와 물리적 정렬도가 결정합니다.
-- 물리적 정렬도 확인: 1.0에 가까울수록 인덱스 순서와 힙 순서가 일치한다
SELECT attname, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('id', 'created_at', 'user_id');
attname | correlation
-------------+-------------
id | 0.999812
created_at | 0.998304
user_id | 0.003911
user_id의 상관도가 0에 가깝다는 것은 같은 사용자의 주문이 테이블 전체에 흩어져 있다는 뜻입니다. 이런 컬럼은 같은 선택도라도 인덱스 이득이 훨씬 작습니다. 옵티마이저는 이 값을 이미 알고 계산에 넣습니다.
조인 알고리즘도 마찬가지로 "무엇이 좋다"가 아니라 "언제 무엇이 맞다"의 문제입니다.
| 조인 방식 | 선택되는 조건 | 비용 특성 | 계획에서 의심할 신호 |
|---|---|---|---|
| Nested Loop | 바깥쪽 행 수가 적고 안쪽에 조인 키 인덱스가 있을 때 | 바깥쪽 행 수 곱하기 안쪽 조회 비용 | loops가 수만 회 이상이고 안쪽이 Seq Scan |
| Hash Join | 등호 조인이고 작은 쪽을 메모리 해시 테이블로 올릴 때 | 작은 쪽 전체를 메모리에 적재 | Batches가 1보다 크고 Disk 사용량이 표시됨 |
| Merge Join | 양쪽이 조인 키로 정렬되어 있거나 정렬이 싸게 끝날 때 | 정렬 비용 더하기 각 입력 1회 스캔 | 앞단 Sort가 external merge로 떨어짐 |
Hash Join에서 Batches: 1이 아니면 해시 테이블이 work_mem에 다 들어가지 못해 디스크로 쪼개졌다는 뜻입니다.
-> Hash (actual time=412.331..412.332 rows=1840221 loops=1)
Buckets: 65536 Batches: 32 Memory Usage: 4097kB
Batches 32는 32번에 나눠 처리했다는 의미이고, 그 사이 임시 파일 입출력이 발생합니다. 이 경우 인덱스를 만드는 것보다 해당 세션의 work_mem을 올리는 편이 훨씬 효과적입니다.
SET LOCAL work_mem = '256MB';
전역 설정을 올리는 것은 위험합니다. work_mem은 커넥션당이 아니라 쿼리 안의 정렬이나 해시 연산 하나당 할당되기 때문에, 동시 실행 쿼리가 많으면 메모리가 순식간에 몇 배로 불어납니다. 무거운 배치 쿼리 앞에서 세션 단위로만 올리는 것이 안전합니다.
BUFFERS와 Rows Removed by Filter — 나머지 두 단서
BUFFERS가 없는 EXPLAIN ANALYZE는 반쪽입니다. 같은 쿼리가 어제는 20ms, 오늘은 900ms인 이유는 대개 계획이 아니라 캐시에 있습니다.
-> Bitmap Heap Scan on events e (actual time=44.201..811.339 rows=214402 loops=1)
Recheck Cond: (tenant_id = 77)
Heap Blocks: exact=41883
Buffers: shared hit=1204 read=40801
shared hit은 버퍼 캐시에서 바로 찾은 블록, shared read는 캐시에 없어 운영체제나 디스크로 내려간 블록입니다. 위 출력은 4만 블록, 약 320MB를 캐시 밖에서 읽었다는 뜻입니다. 두 번째 실행에서 read가 급감하고 시간이 짧아진다면 계획은 문제가 없고 워킹셋이 shared_buffers보다 크다는 이야기입니다. 이때 인덱스를 더 만드는 것은 답이 아닙니다.
dirtied와 written도 눈여겨볼 값입니다. SELECT인데 dirtied가 크다면 힌트 비트 갱신이나 사후 정리가 일어나고 있다는 신호로, 대량 적재 직후에 흔히 보입니다.
두 번째 단서는 Rows Removed by Filter입니다. 앞의 예제로 돌아가 보겠습니다.
-> Seq Scan on orders o (actual time=0.019..92.441 rows=91188 loops=1)
Filter: (created_at >= '2026-06-01 00:00:00'::timestamp)
Rows Removed by Filter: 708812
80만 행을 읽어서 71만 행을 버렸습니다. 필요한 행은 전체의 11퍼센트뿐입니다. 이 정도 선택도라면 인덱스가 이길 여지가 충분합니다.
CREATE INDEX CONCURRENTLY idx_orders_created_at
ON orders (created_at);
ANALYZE orders;
Hash Join (cost=1842.00..9714.22 rows=18420 width=44)
(actual time=7.902..28.113 rows=17903 loops=1)
Hash Cond: (o.user_id = u.id)
Buffers: shared hit=6188 read=1204
-> Index Scan using idx_orders_created_at on orders o
(cost=0.43..5901.10 rows=92300 width=20)
(actual time=0.028..14.882 rows=91188 loops=1)
Index Cond: (created_at >= '2026-06-01 00:00:00'::timestamp)
...
Execution Time: 29.440 ms
133ms에서 29ms로 줄었고, Rows Removed by Filter 줄이 사라졌으며 Index Cond로 바뀌었습니다. 이 차이는 중요합니다. Filter는 행을 읽은 뒤에 버리는 것이고, Index Cond는 애초에 읽지 않는 것입니다. 계획에서 Filter 아래 큰 숫자를 발견했다면 그 조건을 인덱스로 올릴 수 있는지 먼저 검토하십시오.
반대로 이런 경우도 있습니다.
-> Index Scan using idx_orders_user_id on orders o
(actual time=0.041..38.221 rows=112 loops=1)
Index Cond: (user_id = 42)
Filter: (status = 'pending')
Rows Removed by Filter: 9884
인덱스로 1만 행을 좁혔지만 그중 112행만 남았습니다. 이 경우는 복합 인덱스나 부분 인덱스가 답입니다.
CREATE INDEX CONCURRENTLY idx_orders_user_pending
ON orders (user_id)
WHERE status = 'pending';
마치며 — 노드 이름이 아니라 괴리를 보라
실행 계획을 볼 때 순서를 하나만 기억하십시오.
Execution Time과Planning Time을 먼저 봅니다. 계획 시간이 실행 시간을 넘는다면 문제는 다른 곳에 있습니다.- 각 노드에서 예상 rows와 실제 rows를 비교합니다. 한 자릿수 배율 이상 벌어진 가장 안쪽 노드가 범인입니다.
- loops가 1이 아닌 노드는 곱셈을 해서 실제 기여 시간을 계산합니다.
Rows Removed by Filter가 큰 노드를 찾아 인덱스 기회를 확인합니다.Buffers의 read 비율로 이것이 계획 문제인지 캐시 문제인지 가릅니다.
Seq Scan이나 Nested Loop 같은 노드 이름 자체는 좋고 나쁨을 말해 주지 않습니다. 옵티마이저는 자기가 가진 통계 안에서는 거의 항상 합리적으로 판단합니다. 그러니 계획이 이상해 보인다면 던져야 할 질문은 왜 옵티마이저가 멍청한가가 아니라, 내가 옵티마이저에게 무엇을 잘못 알려 주었는가입니다.
How to Read EXPLAIN ANALYZE — Finding the Real Bottleneck in a Query Plan
- Introduction — When You Have Read the Query Plan but Still Cannot Tell What Is Slow
- EXPLAIN and EXPLAIN ANALYZE — One Predicts, One Executes
- The Order to Read Output — Innermost Node First, Bottom to Top
- Cost Is Not Time, and the Gap in rows Is the Real Signal
- loops Multiply — actual time Is a Per-Execution Average
- Seq Scan Is Not Guilty, and Where the Three Joins Diverge
- BUFFERS and Rows Removed by Filter — The Remaining Two Clues
- Closing — Look at the Gap, Not the Node Names
Introduction — When You Have Read the Query Plan but Still Cannot Tell What Is Slow
When you hit a slow query, you probably attach EXPLAIN ANALYZE. Then you stare at a screen full of indentation and parenthesised numbers for a while, and eventually jump to the conclusion: "there is a Seq Scan, so let us build an index." Sometimes that conclusion is right. But what the query plan was trying to tell you is usually a different story.
Reading a query plan is not about skimming node names. It is the work of finding the gap between what the optimizer predicted and what actually happened. If there is no gap, the optimizer picked the best option available given what it knew, and whatever bottleneck remains is a physical problem. If the gap is large, the optimizer calculated precisely on top of a false premise, and the thing that needs fixing is not the query but the statistics.
This article dissects real output line by line and lays out which numbers to look at in which order. It uses PostgreSQL as the baseline, and points out where MySQL diverges as we go.
EXPLAIN and EXPLAIN ANALYZE — One Predicts, One Executes
The two commands only sound alike; they do different things.
-- Only builds the plan and stops. The query is not executed.
EXPLAIN
SELECT * FROM orders WHERE user_id = 42;
-- Actually executes, then overlays measured values on the plan.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 42;
With EXPLAIN alone you get the plan the optimizer built from statistics, plus its estimates. That is fast and safe, but you cannot tell whether those estimates are correct. Add the ANALYZE option and the query is actually executed, and the output also reports how many rows each node really produced and how long it took.
Accidents happen here all the time. EXPLAIN ANALYZE UPDATE ... really performs the UPDATE. When you analyze a write query, you must wrap it in a transaction and roll it back.
BEGIN;
EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders SET status = 'shipped' WHERE id = 1001;
ROLLBACK;
There are a few more options, and the combination that is useful in practice is essentially fixed.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, FORMAT TEXT)
SELECT ...;
BUFFERS— per-node block read statistics. Without it you cannot see caching problems.VERBOSE— the output column list and schema-qualified names. Useful when joins get complicated.SETTINGS— shows planner parameters that deviate from their defaults. Decisive when you are investigating an environment somebody else built.FORMAT JSON— use it only when feeding a tool that parses it; for human reading, TEXT is better.
MySQL gained EXPLAIN ANALYZE in 8.0.18; before that you used EXPLAIN FORMAT=TREE or SHOW WARNINGS to see the query the optimizer had rewritten. The output shape differs, but the reading principles are the same.
The Order to Read Output — Innermost Node First, Bottom to Top
A query plan is a tree. When printed as text, the deeper the indentation, the further inside the tree the node sits — that is, the earlier it executes. Reading top to bottom means reading it backwards.
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.name, o.id, o.total
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.country = 'KR'
AND o.created_at >= '2026-06-01';
Hash Join (cost=1842.00..24310.55 rows=18420 width=44)
(actual time=8.412..131.207 rows=17903 loops=1)
Hash Cond: (o.user_id = u.id)
Buffers: shared hit=4021 read=9877
-> Seq Scan on orders o (cost=0.00..20117.00 rows=92300 width=20)
(actual time=0.019..92.441 rows=91188 loops=1)
Filter: (created_at >= '2026-06-01 00:00:00'::timestamp)
Rows Removed by Filter: 708812
Buffers: shared hit=3110 read=9004
-> Hash (cost=1610.00..1610.00 rows=18560 width=28)
(actual time=8.287..8.288 rows=18402 loops=1)
Buckets: 32768 Batches: 1 Memory Usage: 1408kB
Buffers: shared hit=911 read=873
-> Seq Scan on users u (cost=0.00..1610.00 rows=18560 width=28)
(actual time=0.011..5.902 rows=18402 loops=1)
Filter: (country = 'KR'::text)
Rows Removed by Filter: 61598
Buffers: shared hit=911 read=873
Planning Time: 0.184 ms
Execution Time: 133.902 ms
The reading order goes like this.
- The most deeply indented
Seq Scan on users uruns first and produces 18402 rows. - That result rises into the
Hashnode and is loaded into memory as a hash table. - Then
Seq Scan on orders ostreams 91188 rows through. - The topmost
Hash Joincombines the two and emits 17903 rows.
So when there are several sibling nodes, the upper one comes first, and a parent node is completed only after its children finish. Once you get used to the tree structure, you can see at a glance where the data is produced and where it flows.
There is one thing to watch out for. A parent node actual time is a cumulative value that includes the time of its children. In the output above, 92ms of the 131ms in the Hash Join was spent by the orders scan. To know the time a specific node spent purely on its own, you have to subtract the time of its children. Without knowing this you arrive at the wrong conclusion that "the join takes a whole 131ms."
Cost Is Not Time, and the Gap in rows Is the Real Signal
In cost=1842.00..24310.55, the first value is the cost to produce the first row and the second is the total cost through the last row. The problem is the unit of these numbers. It is not milliseconds. It is not seconds either. It is an arbitrary unit that puts the cost of reading one sequential page at 1.0.
-- The reference values for the cost unit
SHOW seq_page_cost; -- 1.0 (baseline)
SHOW random_page_cost; -- 4.0 (default; on SSD, something near 1.1 is realistic)
SHOW cpu_tuple_cost; -- 0.01
SHOW cpu_operator_cost; -- 0.0025
So a cost of 24310 does not mean 24 seconds. This value is merely a score for comparing several candidate plans for the same query against each other, and comparing it with the cost of a different query is effectively meaningless. You often see advice that sets a threshold like "a cost above 10000 is dangerous," but there is no basis for it.
What you really should look at is the two rows= values inside the same node.
-> Seq Scan on orders o (cost=... rows=92300 ...) (actual ... rows=91188 loops=1)
Estimated 92300, actual 91188. An error of 1.2 percent means the statistics are healthy. Meet output like the following and the story changes.
-> Index Scan using idx_orders_status on orders o
(cost=0.42..8.44 rows=1 width=20)
(actual time=0.031..214.882 rows=482913 loops=1)
Estimated 1 row, actual 482913. Off by more than forty thousand times. The optimizer presumably decided "only 1 row will come out, so a Nested Loop will do," and when that premise collapses it ends up repeating the inner node 480 thousand times. What needs fixing here is not a join hint but the statistics.
-- First prescription: refresh the statistics
ANALYZE orders;
-- If it is still off, raise the histogram precision on a specific column
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
-- When the miss comes from correlation between columns (for example, city and postal code)
CREATE STATISTICS stat_orders_geo (dependencies, ndistinct)
ON city, postal_code FROM orders;
ANALYZE orders;
That last extended statistic is needed surprisingly often. By default the optimizer assumes the conditions are independent of one another and multiplies their selectivity. In reality many column pairs are correlated, and in those cases the estimate comes out far smaller than the truth.
loops Multiply — actual time Is a Per-Execution Average
This is where the most misreadings happen.
-> Index Scan using idx_order_items_order_id on order_items i
(cost=0.43..3.21 rows=4 width=16)
(actual time=0.008..0.011 rows=4 loops=52310)
Do not look at actual time=0.008..0.011 and move on thinking "0.011 milliseconds, negligible." The time and row count inside the parentheses are both averages per single execution. To get the real total you have to multiply by loops.
- Total elapsed time: 0.011ms times 52310 executions = about 575ms
- Total rows returned: 4 rows times 52310 executions = about 209,240 rows
If the whole query is 700ms, this single node consumed 80 percent of it. Because the number "575" is written nowhere in the plan, you will walk right past the bottleneck unless you do the multiplication yourself.
For the same reason you should always check loops first on the inner node of a Nested Loop. If loops runs into the tens of thousands, there is a good chance the outer row estimate is wrong. Whether to change the join order or add an index is the next question.
With parallel queries there is one more layer. The loops of a node under Gather reflect the worker count, and Workers Launched may be fewer than requested.
Gather (cost=1000.00..38210.13 rows=210 width=8)
(actual time=0.412..312.884 rows=198 loops=1)
Workers Planned: 4
Workers Launched: 2
If 4 were planned but only 2 came up, max_parallel_workers was already exhausted, and things are correspondingly slower than expected. If a benchmark result will not reproduce, look at this line first.
Seq Scan Is Not Guilty, and Where the Three Joins Diverge
The advice "if you see a Seq Scan, build an index" is only half right. Situations where a sequential scan beats an index scan definitely exist.
First, when the table is small. A code table of a few hundred rows is only a handful of pages even if you read the whole thing. Going through an index means reading index pages and then randomly accessing the heap again, which is a net loss.
Second, when selectivity is low. If a condition lets a substantial fraction of the table through, an index is at a disadvantage. An index scan visits a heap page randomly for every matching row, and when there are many rows to visit you end up reading the entire table in random order. Sequential reads are far friendlier to disks and prefetching. Roughly past 5 to 10 percent of the whole, sequential scan starts winning, and the exact boundary is determined by random_page_cost and physical ordering.
-- Check physical ordering: the closer to 1.0, the more index order matches heap order
SELECT attname, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('id', 'created_at', 'user_id');
attname | correlation
-------------+-------------
id | 0.999812
created_at | 0.998304
user_id | 0.003911
A correlation near 0 for user_id means the orders of the same user are scattered across the whole table. Such a column yields a far smaller index benefit at the same selectivity. The optimizer already knows this value and factors it into its calculation.
Join algorithms are likewise not a question of "which is good" but of "when is which right."
| Join method | Condition under which it is chosen | Cost characteristics | Signal to be suspicious of in a plan |
|---|---|---|---|
| Nested Loop | Few outer rows and a join-key index on the inner side | Outer row count times inner lookup cost | loops in the tens of thousands and Seq Scan inside |
| Hash Join | Equality join and the smaller side fits an in-memory hash | The entire smaller side loaded into memory | Batches greater than 1 and Disk usage shown |
| Merge Join | Both sides sorted on the join key, or sorting is cheap | Sort cost plus one scan of each input | An upstream Sort dropping to external merge |
In a Hash Join, anything other than Batches: 1 means the hash table did not fit in work_mem and was split out to disk.
-> Hash (actual time=412.331..412.332 rows=1840221 loops=1)
Buckets: 65536 Batches: 32 Memory Usage: 4097kB
Batches 32 means the work was split into 32 passes, with temporary file I/O in between. In this case raising work_mem for that session is far more effective than creating an index.
SET LOCAL work_mem = '256MB';
Raising the global setting is dangerous. work_mem is allocated not per connection but per sort or hash operation inside a query, so when many queries run concurrently memory balloons several times over in an instant. Raising it only at the session level right before a heavy batch query is the safe approach.
BUFFERS and Rows Removed by Filter — The Remaining Two Clues
An EXPLAIN ANALYZE without BUFFERS is half a picture. The reason the same query took 20ms yesterday and 900ms today is usually not the plan but the cache.
-> Bitmap Heap Scan on events e (actual time=44.201..811.339 rows=214402 loops=1)
Recheck Cond: (tenant_id = 77)
Heap Blocks: exact=41883
Buffers: shared hit=1204 read=40801
shared hit is blocks found directly in the buffer cache; shared read is blocks that were not cached and had to go down to the operating system or disk. The output above means 40 thousand blocks, roughly 320MB, were read from outside the cache. If on a second execution read drops sharply and the time shortens, the plan is fine and the story is that the working set is larger than shared_buffers. Creating more indexes is not the answer here.
dirtied and written are also worth watching. If it is a SELECT and dirtied is large, that signals hint bit updates or after-the-fact cleanup happening, which is commonly seen right after a bulk load.
The second clue is Rows Removed by Filter. Let us go back to the earlier example.
-> Seq Scan on orders o (actual time=0.019..92.441 rows=91188 loops=1)
Filter: (created_at >= '2026-06-01 00:00:00'::timestamp)
Rows Removed by Filter: 708812
It read 800 thousand rows and threw away 710 thousand. Only 11 percent of the total was needed. At this level of selectivity there is plenty of room for an index to win.
CREATE INDEX CONCURRENTLY idx_orders_created_at
ON orders (created_at);
ANALYZE orders;
Hash Join (cost=1842.00..9714.22 rows=18420 width=44)
(actual time=7.902..28.113 rows=17903 loops=1)
Hash Cond: (o.user_id = u.id)
Buffers: shared hit=6188 read=1204
-> Index Scan using idx_orders_created_at on orders o
(cost=0.43..5901.10 rows=92300 width=20)
(actual time=0.028..14.882 rows=91188 loops=1)
Index Cond: (created_at >= '2026-06-01 00:00:00'::timestamp)
...
Execution Time: 29.440 ms
It went from 133ms to 29ms, the Rows Removed by Filter line disappeared, and it turned into Index Cond. This difference matters. Filter throws rows away after reading them; Index Cond never reads them in the first place. If you find a large number under Filter in a plan, first examine whether that condition can be lifted into an index.
There is also the opposite case.
-> Index Scan using idx_orders_user_id on orders o
(actual time=0.041..38.221 rows=112 loops=1)
Index Cond: (user_id = 42)
Filter: (status = 'pending')
Rows Removed by Filter: 9884
The index narrowed things to 10 thousand rows, but only 112 of them survived. Here a composite index or a partial index is the answer.
CREATE INDEX CONCURRENTLY idx_orders_user_pending
ON orders (user_id)
WHERE status = 'pending';
Closing — Look at the Gap, Not the Node Names
When you look at a query plan, remember just one order.
- Look at
Execution TimeandPlanning Timefirst. If planning time exceeds execution time, the problem lies elsewhere. - Compare estimated rows against actual rows at each node. The innermost node where they diverge by an order of magnitude or more is the culprit.
- For nodes where loops is not 1, do the multiplication to compute the real contributed time.
- Find nodes with a large
Rows Removed by Filterand check for an index opportunity. - Use the read ratio in
Buffersto decide whether this is a plan problem or a cache problem.
Node names like Seq Scan or Nested Loop do not by themselves tell you good from bad. Within the statistics it holds, the optimizer almost always judges reasonably. So when a plan looks strange, the question to ask is not why the optimizer is stupid, but what did I tell the optimizer that was wrong.