Split View: SQL 실행계획 완전 가이드: 옵티마이저는 어떻게 계획을 고르는가
SQL 실행계획 완전 가이드: 옵티마이저는 어떻게 계획을 고르는가
- 들어가며
- 1. 한 문장이 계획이 되기까지
- 2. 통계 — 플래너가 세상을 보는 방식
- 3. 선택도 추정과 독립성 가정
- 4. 비용 모델 — cost 숫자는 무엇으로 조립되는가
- 5. 경로 비교 — 왜 이 스캔이고 왜 이 조인인가
- 6. 조인 순서 탐색과 GEQO
- 7. 추정이 틀렸을 때 교정하기
- 8. 계획이 흔들릴 때 — 준비된 문장과 일반 계획
- 퀴즈: 실력을 확인해 보세요
- 마치며
- 참고 자료
- 이어서 읽기
들어가며
실행계획을 다루는 글은 보통 EXPLAIN 출력을 읽는 법을 가르칩니다. 이 블로그의 EXPLAIN ANALYZE 읽는 법이 정확히 그 글입니다. 노드를 어떤 순서로 읽고, loops가 어떻게 곱해지고, BUFFERS로 무엇을 판단하는지를 다룹니다.
이 글은 반대편에서 봅니다. 출력을 읽는 사람이 아니라 그 출력을 만들어 낸 플래너의 입장입니다. 플래너는 어떤 정보를 가지고 있고, 그 정보로 무엇을 계산하며, 계산 결과가 틀렸을 때 우리가 무엇을 바로잡아 줄 수 있는가. 계획을 읽을 줄 아는데도 튜닝이 막히는 지점은 대개 여기입니다. "왜 이 계획이 나왔는지"를 모르면 "어떻게 바꿔야 하는지"도 모릅니다.
기준 엔진은 PostgreSQL 18 이고, 인용한 기본값은 모두 PostgreSQL 18 문서에서 확인한 값입니다. MySQL의 옵티마이저는 비용 모델도 통계 구조도 다르므로 섞지 않았습니다. MySQL 실행계획은 별도의 글이 필요합니다.
1. 한 문장이 계획이 되기까지
SQL 한 줄이 결과가 되기까지 네 단계를 거칩니다.
- 파서 — 문법을 검사하고 구문 트리를 만듭니다. 이 단계에서는 테이블이 존재하는지 정도만 봅니다.
- 재작성기(rewriter) — 뷰를 실제 정의로 펼치고, 규칙(rule)을 적용합니다. 뷰를 조회하면 이 단계에서 원본 쿼리로 바뀝니다.
- 플래너/옵티마이저 — 가능한 실행 경로들을 만들고 각각의 비용을 계산해 가장 싼 것을 고릅니다. 이 글의 주제입니다.
- 실행기(executor) — 선택된 계획 트리를 따라 실제로 데이터를 읽습니다.
여기서 중요한 사실 하나. 플래너는 데이터를 보지 않습니다. 플래너가 보는 것은 데이터의 요약, 즉 통계입니다. 통계가 실제와 다르면 아무리 정교한 비용 모델도 틀린 답을 냅니다. 실행계획 문제의 절대다수는 비용 모델의 결함이 아니라 통계와 현실의 괴리입니다.
2. 통계 — 플래너가 세상을 보는 방식
ANALYZE는 테이블에서 표본을 뽑아 통계를 만들고 pg_statistic에 저장합니다. 사람이 읽기 좋은 형태가 pg_stats 뷰입니다.
SELECT attname, null_frac, n_distinct,
most_common_vals, most_common_freqs,
correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('status', 'created_at');
각 항목의 의미는 다음과 같습니다.
- null_frac — NULL의 비율.
IS NULL조건의 선택도가 여기서 나옵니다. - n_distinct — 서로 다른 값의 개수. 양수면 절댓값 그대로, 음수면 행 수에 대한 비율입니다.
-1은 모든 값이 유일하다는 뜻입니다. - most_common_vals / most_common_freqs — 최빈값 목록과 그 빈도. 값 분포가 치우친 컬럼에서 결정적입니다.
- histogram_bounds — 최빈값을 제외한 나머지 값의 분포를 등빈도 구간으로 나눈 경계. 범위 조건의 선택도가 여기서 나옵니다.
- correlation — 컬럼 값의 논리적 순서와 물리적 행 순서의 상관계수. 1에 가까우면 인덱스 스캔의 랜덤 접근이 사실상 순차 접근이 되므로 비용이 크게 낮아집니다. BRIN 인덱스가 유효한지 판단하는 지표이기도 합니다.
표본 크기는 default_statistics_target이 결정합니다. PostgreSQL 18 문서 기준 기본값은 100 이고, 값이 클수록 최빈값 목록과 히스토그램 구간이 많아져 추정이 정확해지지만 ANALYZE 시간과 계획 수립 시간이 늘어납니다. 컬럼 단위로도 조정할 수 있습니다.
-- 값 분포가 심하게 치우친 컬럼만 표본을 늘린다
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;
ALTER TABLE ... SET STATISTICS는 SHARE UPDATE EXCLUSIVE 잠금만 취하므로 읽기와 쓰기를 막지 않습니다. ALTER TABLE의 다른 상당수 형태가 ACCESS EXCLUSIVE를 취하는 것과 대조적입니다.
통계는 autovacuum의 analyze 작업으로 갱신됩니다. PostgreSQL 18 문서 기준으로 analyze 임계값은 autovacuum_analyze_threshold(기본값 50 튜플)에 autovacuum_analyze_scale_factor(기본값 0.1, 즉 테이블의 10%)를 곱한 값을 더해 계산합니다. 1억 행 테이블이면 1천만 행이 변경되어야 analyze가 돕니다. 대용량 테이블에서 통계가 낡는 이유가 이것이며, 테이블 단위로 scale factor를 낮추는 것이 표준 대응입니다.
3. 선택도 추정과 독립성 가정
플래너가 통계로 계산하는 값이 선택도(selectivity) 입니다. 조건이 전체 행 중 몇 퍼센트를 통과시키는가를 뜻하며, 여기에 테이블 행 수를 곱하면 추정 행 수가 됩니다.
단일 조건의 선택도는 비교적 정확합니다. status = 'PAID'가 최빈값 목록에 있으면 그 빈도를 그대로 쓰고, 없으면 남은 값들에 균등 분배합니다.
문제는 조건이 둘 이상일 때입니다. 플래너는 기본적으로 조건들이 서로 독립이라고 가정하고 선택도를 곱합니다. 이 가정이 깨지는 순간 추정이 무너집니다.
-- 도시와 우편번호는 사실상 함수 종속 관계다
SELECT * FROM addresses
WHERE city = '서울특별시' AND postal_code = '06236';
city의 선택도가 0.2이고 postal_code의 선택도가 0.0001이라면 플래너는 0.00002를 계산합니다. 하지만 실제로는 우편번호가 정해지면 도시는 자동으로 정해지므로 실제 선택도는 0.0001에 가깝습니다. 플래너는 실제보다 5배 적은 행을 예상하고, 그 오차는 조인 위쪽으로 올라가며 눈덩이처럼 커집니다.
이것이 실행계획이 잘못되는 가장 흔한 원인입니다. 겉으로는 "Nested Loop을 골랐는데 실제 행이 많아서 느려졌다"로 보이지만, 근본 원인은 조인 방식 선택이 아니라 그 아래에서 이미 틀어진 행 수 추정입니다. 7절에서 교정 방법을 다룹니다.
4. 비용 모델 — cost 숫자는 무엇으로 조립되는가
EXPLAIN이 보여 주는 cost 값은 시간 단위가 아닙니다. 순차 페이지 읽기 한 번을 1.0으로 놓은 상대적인 추상 단위입니다. 플래너는 다섯 개의 비용 상수로 이 값을 조립합니다. PostgreSQL 18 문서 기준 기본값입니다.
| 파라미터 | 기본값 | 의미 |
|---|---|---|
seq_page_cost | 1.0 | 순차적으로 읽는 디스크 페이지 하나의 비용 |
random_page_cost | 4.0 | 무작위로 읽는 디스크 페이지 하나의 비용 |
cpu_tuple_cost | 0.01 | 행 하나를 처리하는 CPU 비용 |
cpu_index_tuple_cost | 0.005 | 인덱스 엔트리 하나를 처리하는 CPU 비용 |
cpu_operator_cost | 0.0025 | 연산자나 함수 한 번 실행의 CPU 비용 |
여기서 실무적으로 가장 중요한 숫자가 random_page_cost의 기본값 4.0입니다. 이 값은 회전식 디스크에서 무작위 접근이 순차 접근보다 네 배 비싸다는 가정을 담고 있습니다. SSD나 NVMe에서는 이 비율이 훨씬 낮습니다. 기본값을 그대로 두면 플래너가 인덱스 스캔을 실제보다 비싸게 평가해 순차 스캔을 과하게 선호합니다. SSD 환경에서 1.1에서 2.0 사이로 낮추는 것이 널리 쓰이는 조정이지만, 값을 바꾸기 전에 반드시 대표 쿼리 집합으로 전후를 비교하세요. 이 값은 계획 전체에 영향을 주므로 잘못 내리면 다른 쿼리가 망가집니다.
effective_cache_size도 함께 봐야 합니다. 기본값은 4GB이며, 이 값은 메모리를 실제로 할당하지 않고 운영체제 캐시를 포함해 얼마나 많은 데이터가 캐시되어 있을지에 대한 플래너의 가정만 바꿉니다. 실제 서버 메모리보다 훨씬 작게 설정되어 있으면 플래너는 인덱스 스캔이 디스크를 많이 때릴 것이라 보고 순차 스캔으로 기웁니다.
병렬 실행 관련 비용도 같은 방식으로 동작합니다. parallel_setup_cost의 기본값은 1000, parallel_tuple_cost의 기본값은 0.1입니다. 워커를 띄우는 고정 비용이 1000이므로 작은 쿼리는 애초에 병렬 계획 후보에 오르지 않습니다. 병렬 순차 스캔의 최소 테이블 크기는 min_parallel_table_scan_size가 결정하며 기본값은 8MB, 인덱스 쪽은 min_parallel_index_scan_size로 기본값 512kB입니다.
5. 경로 비교 — 왜 이 스캔이고 왜 이 조인인가
플래너는 각 테이블에 대해 가능한 접근 경로를 만들고, 그 경로들을 조합해 조인 경로를 만듭니다. 각 단계에서 비용이 가장 낮은 경로와 "정렬 순서를 유지한다" 같은 유용한 속성을 가진 경로를 남깁니다.
스캔 경로 선택의 핵심은 반환 비율입니다. 인덱스 스캔은 인덱스를 읽고 테이블 행을 무작위로 방문합니다. 반환 비율이 높아지면 무작위 접근 횟수가 많아져 결국 테이블 전체를 순서대로 한 번 읽는 편이 싸집니다. 그래서 Seq Scan은 실패가 아니라 정답인 경우가 많습니다. 작은 테이블이나 대부분의 행을 반환하는 쿼리에서 인덱스를 강제하면 오히려 느려집니다.
중간 지대에는 Bitmap Heap Scan이 있습니다. 인덱스를 먼저 모두 읽어 대상 블록 번호를 비트맵으로 모은 뒤, 블록 번호 순서대로 테이블을 읽습니다. 무작위 접근을 순차에 가깝게 바꾸는 절충안이며, 반환 비율이 애매할 때 자주 등장합니다.
조인 경로는 세 가지 중에서 고릅니다.
- Nested Loop — 바깥 행 하나마다 안쪽을 조회합니다. 바깥 결과가 작고 안쪽에 좋은 인덱스가 있을 때 최고입니다. 바깥 행 수 추정이 틀리면 최악이 됩니다. 실행계획 사고의 단골입니다.
- Hash Join — 한쪽으로 해시 테이블을 만들고 다른 쪽을 훑습니다. 등치 조인에만 쓰이며, 해시 테이블이
work_mem에 들어가면 매우 빠릅니다. 넘치면 디스크로 분할됩니다. - Merge Join — 양쪽을 정렬해 병합합니다. 이미 정렬된 입력(인덱스 스캔)이 있으면 유리하고, 없으면 정렬 비용을 내야 합니다.
work_mem의 기본값은 4MB입니다. 이 값은 커넥션당이 아니라 정렬·해시 연산당 적용된다는 점이 중요합니다. 한 쿼리가 여러 개의 정렬과 해시를 포함하면 그만큼 배수로 쓰입니다. 해시 관련 연산은 hash_mem_multiplier(기본값 2.0)를 곱한 값까지 쓸 수 있습니다.
6. 조인 순서 탐색과 GEQO
테이블 두 개를 조인하는 순서는 두 가지지만, 열 개면 조합이 폭발합니다. 플래너는 동적 계획법으로 조인 순서를 탐색하는데, 테이블 수가 많아지면 이 탐색 자체가 감당할 수 없어집니다.
PostgreSQL은 세 개의 손잡이로 이 문제를 다룹니다. 모두 문서에서 확인한 기본값입니다.
from_collapse_limit— 기본값 8. 서브쿼리를 상위 쿼리로 펼칠지 판단하는 기준입니다. 펼친 결과 FROM 항목 수가 이 값을 넘으면 펼치지 않습니다.join_collapse_limit— 기본값은from_collapse_limit과 같습니다. 명시적JOIN구문을 평평한 목록으로 펼치는 기준입니다. 이 값을 1로 두면 플래너가 조인 순서를 재배치하지 않고 작성한 순서를 그대로 씁니다.geqo_threshold— 기본값 12. FROM 항목 수가 이 값 이상이면 완전 탐색 대신 유전 알고리즘(GEQO) 으로 조인 순서를 찾습니다.
GEQO는 확률적 탐색이므로 같은 쿼리가 실행할 때마다 다른 계획을 낼 수 있습니다. 테이블을 열두 개 넘게 조인하는 리포트 쿼리가 어떤 날은 3초, 어떤 날은 40초 걸린다면 GEQO를 의심해 볼 만합니다. 확인 방법은 geqo를 잠시 끄고 계획 수립 시간과 실행 시간을 비교하는 것입니다.
-- 세션에서만 실험한다
SET geqo = off;
SET join_collapse_limit = 20;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
RESET geqo;
RESET join_collapse_limit;
계획 수립 시간이 몇 초로 폭증하면 완전 탐색이 감당 못 하는 규모라는 뜻이고, 그때는 쿼리를 나누거나 중간 결과를 물리화하는 쪽이 답입니다.
7. 추정이 틀렸을 때 교정하기
3절에서 본 독립성 가정 문제를 PostgreSQL은 확장 통계(extended statistics) 로 해결합니다. CREATE STATISTICS로 컬럼 조합에 대한 통계를 따로 만듭니다. 문서가 지원한다고 명시한 종류는 세 가지입니다.
- dependencies — 컬럼 사이의 함수 종속을 기록합니다. 여러 조건이 사실은 중복일 때 발생하는 심각한 과소 추정을 막습니다.
- ndistinct — 컬럼 조합의 서로 다른 값 개수를 기록합니다.
GROUP BY가 여러 컬럼일 때의 추정을 바로잡습니다. - mcv — 컬럼 조합의 최빈값 목록을 기록합니다. 특정 조합이 유난히 많은 경우를 정확히 반영합니다.
-- 도시와 우편번호의 종속 관계를 플래너에게 알려 준다
CREATE STATISTICS stat_addr_city_postal (dependencies, mcv)
ON city, postal_code FROM addresses;
-- 통계는 ANALYZE를 돌려야 채워진다
ANALYZE addresses;
CREATE STATISTICS는 SHARE UPDATE EXCLUSIVE 잠금을 취하므로 운영 중에 실행해도 읽기와 쓰기를 막지 않습니다. 다만 만든 직후에는 비어 있으므로 반드시 ANALYZE를 돌려야 합니다. 문서의 예제도 항상 CREATE STATISTICS 다음에 ANALYZE를 붙여 놓았습니다.
표현식에 대한 추정이 틀리는 경우도 흔합니다. date_trunc('month', a) = ... 같은 조건은 플래너가 기본 통계로는 전혀 추정할 수 없습니다. 이때도 표현식 통계를 만들 수 있습니다.
CREATE STATISTICS stat_events_month
ON date_trunc('month', occurred_at) FROM events;
ANALYZE events;
교정의 순서를 정리하면 이렇습니다. 먼저 EXPLAIN ANALYZE로 추정 행 수와 실제 행 수의 괴리가 가장 큰 노드를 찾습니다. 그 노드가 단일 컬럼 조건이면 SET STATISTICS로 표본을 늘립니다. 여러 컬럼 조건이면 CREATE STATISTICS를 만듭니다. 표현식이면 표현식 통계나 표현식 인덱스를 만듭니다. 이 세 가지로 해결되지 않으면 그때 비로소 쿼리 구조를 바꿉니다.
8. 계획이 흔들릴 때 — 준비된 문장과 일반 계획
어제까지 빠르던 쿼리가 오늘 갑자기 느려지고, EXPLAIN으로 직접 실행하면 또 빠릅니다. 이 증상의 유력한 용의자가 준비된 문장(prepared statement)의 일반 계획(generic plan) 입니다.
준비된 문장은 파라미터 값을 모른 채 계획을 만들 수 있습니다. 값을 아는 상태로 매번 만드는 계획이 맞춤 계획(custom plan), 값을 모른 채 한 번 만들어 재사용하는 것이 일반 계획입니다. 일반 계획은 계획 수립 비용을 아끼지만, 값 분포가 치우친 컬럼에서는 특정 값에 대해 최악의 계획이 될 수 있습니다.
이 동작은 plan_cache_mode로 제어합니다. 허용 값은 auto(기본값), force_custom_plan, force_generic_plan입니다. 기본값 auto는 몇 번의 맞춤 계획 비용 평균과 일반 계획 비용을 비교해 자동으로 결정합니다.
값을 넣지 않고 일반 계획만 보고 싶다면 PostgreSQL 16 이후 제공되는 GENERIC_PLAN 옵션을 씁니다.
EXPLAIN (GENERIC_PLAN)
SELECT * FROM orders WHERE tenant_id = $1 AND status = $2;
Bitmap Heap Scan on orders (cost=25.41..1290.88 rows=512 width=124)
Recheck Cond: (tenant_id = $1)
Filter: (status = $2)
-> Bitmap Index Scan on idx_orders_tenant (cost=0.00..25.28 rows=1024 width=0)
Index Cond: (tenant_id = $1)
증상이 확인되면 대응은 두 가지입니다. 해당 세션이나 애플리케이션에서 plan_cache_mode = force_custom_plan으로 두어 항상 값을 반영한 계획을 만들게 하거나, 값 분포가 치우친 컬럼에 대해 통계를 강화해 일반 계획의 품질을 높이는 것입니다. 전자는 계획 수립 비용을 매번 지불하므로 초당 수천 건이 실행되는 짧은 쿼리에서는 그 자체가 부담이 될 수 있습니다.
JIT도 비슷한 흔들림의 원인입니다. jit의 기본값은 on이고 jit_above_cost의 기본값은 100000입니다. 추정 비용이 이 값을 넘으면 JIT 컴파일이 켜지는데, 추정이 과대평가된 짧은 쿼리에서 컴파일 시간이 실행 시간보다 커지는 역전이 생길 수 있습니다. EXPLAIN (ANALYZE) 출력의 JIT 섹션에서 컴파일 시간을 확인하세요.
퀴즈: 실력을 확인해 보세요
퀴즈 1: 이 실행계획 조각에서 무엇이 문제인가요?
Nested Loop (cost=0.86..3421.55 rows=12 width=88)
(actual time=0.09..48210.33 rows=184032 loops=1)
-> Index Scan using idx_a on orders o
(cost=0.43..118.20 rows=4 width=40)
(actual time=0.03..92.11 rows=61344 loops=1)
Index Cond: ((tenant_id = 42) AND (status = 'PAID'::text))
-> Index Scan using idx_b on order_items i
(cost=0.43..825.31 rows=3 width=48)
(actual time=0.30..0.78 rows=3 loops=61344)
Index Cond: (order_id = o.id)
정답: 바깥쪽 노드의 행 수 추정이 4행인데 실제는 61,344행입니다. 이 오차 때문에 플래너가 Nested Loop을 골랐고, 안쪽 인덱스 스캔이 61,344번 반복되었습니다.
설명: 안쪽 노드의 loops=61344에 주목하세요. 한 번에 0.78ms인 스캔이 6만 번 반복되면 48초입니다. 문제는 조인 방식이 아니라 그 아래의 추정입니다. tenant_id와 status가 상관되어 있는데 플래너가 독립이라 가정해 선택도를 곱한 결과 1만 5천 배 과소 추정이 발생했습니다. 조치는 CREATE STATISTICS (dependencies, mcv) ON tenant_id, status FROM orders 후 ANALYZE입니다. 추정이 바로잡히면 플래너는 스스로 Hash Join으로 넘어갑니다. enable_nestloop = off로 억지로 막는 것은 증상만 가리는 대응입니다.
퀴즈 2: NVMe 스토리지를 쓰는 서버인데 인덱스가 있는 쿼리도 Seq Scan을 고릅니다. 어떤 파라미터를 의심해야 할까요?
정답: random_page_cost와 effective_cache_size입니다.
설명: random_page_cost의 기본값 4.0은 무작위 접근이 순차 접근보다 네 배 비싸다는 회전 디스크 시대의 가정입니다. NVMe에서는 이 격차가 훨씬 작으므로 플래너가 인덱스 경로를 실제보다 비싸게 평가합니다. effective_cache_size의 기본값 4GB도 함께 봐야 합니다. 이 값은 메모리를 할당하지 않고 플래너의 캐시 가정만 바꾸는데, 메모리가 256GB인 서버에서 4GB로 남아 있으면 인덱스 스캔이 디스크를 많이 때릴 것이라 판단합니다. 다만 두 값 모두 계획 전체에 영향을 주므로, 대표 쿼리 집합으로 전후를 비교한 뒤에 바꿔야 합니다.
퀴즈 3: 열네 개 테이블을 조인하는 리포트 쿼리가 실행할 때마다 응답 시간이 크게 달라집니다. 왜일까요?
정답: geqo_threshold의 기본값 12를 넘었으므로 유전 알고리즘이 조인 순서를 찾고 있고, 이 탐색은 확률적이라 매번 다른 계획이 나올 수 있습니다.
설명: GEQO는 완전 탐색이 감당할 수 없는 규모에서 "충분히 좋은" 순서를 빠르게 찾는 기법이지 최적해를 보장하지 않습니다. 확인은 세션에서 SET geqo = off로 끄고 계획 수립 시간과 실행 시간을 비교하는 것입니다. 완전 탐색이 몇 초 걸린다면 쿼리를 나누거나 중간 결과를 임시 테이블 또는 머티리얼라이즈드 뷰로 물리화해 조인 대상 수 자체를 줄이는 편이 낫습니다. join_collapse_limit을 1로 두어 작성한 순서를 강제하는 방법도 있지만, 이는 사람이 최적 순서를 안다는 전제가 필요합니다.
퀴즈 4: 애플리케이션에서 실행하면 8초, psql에서 같은 SQL을 붙여 넣으면 30ms입니다. 무엇을 의심할까요?
정답: 준비된 문장의 일반 계획입니다.
설명: 대부분의 드라이버는 파라미터 바인딩을 위해 준비된 문장을 씁니다. plan_cache_mode의 기본값 auto는 몇 차례 맞춤 계획을 만들어 본 뒤 일반 계획이 더 싸 보이면 그것으로 굳힙니다. 값 분포가 치우친 컬럼에서는 이 일반 계획이 특정 값에 대해 재앙이 될 수 있습니다. psql에 SQL을 그대로 붙여 넣으면 리터럴 값으로 매번 새 계획을 만들기 때문에 빠릅니다. 확인은 EXPLAIN (GENERIC_PLAN)으로 파라미터를 모르는 상태의 계획을 직접 보는 것이고, 대응은 해당 워크로드에 plan_cache_mode = force_custom_plan을 적용하거나 통계를 강화하는 것입니다.
퀴즈 5: EXPLAIN 없이 실행계획 문제를 예방하려면 어떤 지표를 상시로 봐야 할까요?
정답: 추정 행 수와 실제 행 수의 괴리를 만드는 원인 지표들, 즉 통계의 신선도와 테이블 변경량입니다.
설명: pg_stat_user_tables의 n_mod_since_analyze는 마지막 ANALYZE 이후 변경된 행 수의 추정치입니다. 이 값이 테이블 크기에 비해 크면 통계가 낡았다는 뜻입니다. last_autovacuum과 last_autoanalyze도 함께 봅니다. 대용량 테이블은 autovacuum_analyze_scale_factor의 기본값 0.1(10%) 때문에 analyze가 거의 돌지 않으므로 테이블 단위로 낮춰야 합니다.
ALTER TABLE orders SET (autovacuum_analyze_scale_factor = 0.02);
이 설정은 테이블 저장 파라미터 변경이므로 SHARE UPDATE EXCLUSIVE 잠금만 취합니다.
마치며
실행계획 튜닝을 오래 하다 보면 결론이 단순해집니다. 비용 모델은 대체로 옳고, 틀리는 것은 입력값입니다. 플래너에게 잘못된 통계를 주고 나서 플래너를 탓하는 일이 대부분입니다. enable_nestloop = off 같은 손잡이로 계획을 억지로 비트는 대응이 유혹적인 이유는 즉시 효과가 보이기 때문이지만, 그것은 데이터 분포가 조금만 바뀌어도 다시 무너지는 임시방편입니다.
순서는 항상 같습니다. 추정과 실제의 괴리가 가장 큰 노드를 찾고, 그 괴리의 원인이 표본 부족인지 상관관계인지 표현식인지 구분하고, 해당하는 통계 도구로 교정합니다. 그래도 남는 문제만 쿼리 구조나 파라미터로 다룹니다.
이 글의 SQL은 Postgres 놀이터에서 직접 실행해 볼 수 있습니다.
참고 자료
- PostgreSQL 18, Query Planning: https://www.postgresql.org/docs/18/runtime-config-query.html (2026-08-15 확인)
- PostgreSQL 18, EXPLAIN: https://www.postgresql.org/docs/18/sql-explain.html (2026-08-15 확인)
- PostgreSQL 18, CREATE STATISTICS: https://www.postgresql.org/docs/18/sql-createstatistics.html (2026-08-15 확인)
- PostgreSQL 18, Resource Consumption: https://www.postgresql.org/docs/18/runtime-config-resource.html (2026-08-15 확인)
- PostgreSQL 18, Automatic Vacuuming: https://www.postgresql.org/docs/18/runtime-config-autovacuum.html (2026-08-15 확인)
- PostgreSQL 18, Monitoring Database Activity: https://www.postgresql.org/docs/18/monitoring-stats.html (2026-08-15 확인)
- PostgreSQL 18, ALTER TABLE: https://www.postgresql.org/docs/18/sql-altertable.html (2026-08-15 확인)
이어서 읽기
- 이전 편: PostgreSQL 인덱스 완전 가이드 — 인덱스의 수명 주기
- 다음 편: 트랜잭션 격리 수준 완전 가이드 — 격리 수준의 운영 계약
- EXPLAIN ANALYZE 읽는 법 — 출력을 읽는 순서
- 화산 모델과 벡터화 실행 — 실행기가 계획을 돌리는 방식
- Postgres 놀이터 — 실행계획을 직접 뽑아 보기
- DuckDB 놀이터 — 분석형 엔진의 계획과 비교해 보기
The Complete Guide to SQL Execution Plans: How the Optimizer Chooses a Plan
- Introduction
- 1. From One Statement to a Plan
- 2. Statistics — How the Planner Sees the World
- 3. Selectivity Estimation and the Independence Assumption
- 4. The Cost Model — What the cost Number Is Built From
- 5. Comparing Paths — Why This Scan, Why This Join
- 6. Join-Order Search and GEQO
- 7. Correcting a Bad Estimate
- 8. When Plans Get Unstable — Prepared Statements and Generic Plans
- Quiz: Test Your Understanding
- Closing
- Sources
- Further Reading
Introduction
Most writing about execution plans teaches you how to read EXPLAIN output. This blog's own How to Read EXPLAIN ANALYZE is exactly that piece — it covers which order to read the nodes in, how loops multiplies through a plan tree, and what BUFFERS tells you about where the data actually came from.
This piece looks from the opposite side. Not the person reading the output, but the planner that produced it in the first place. What information does the planner actually have, what does it compute from that information, and when the computed result turns out to be wrong, what can we correct on our own end? This is usually exactly the point where someone who can already read a plan competently still gets stuck when it comes to tuning one. If you don't know why a particular plan came out the way it did, you also don't know what to change to get a different one — you end up guessing at hints and session settings instead of fixing the actual cause underneath.
The reference engine is PostgreSQL 18, and every default value quoted here was confirmed against the PostgreSQL 18 documentation. MySQL's optimizer differs in both its cost model and its statistics structures, so this piece deliberately does not mix the two together. MySQL execution plans deserve, and would need, an entirely separate piece of their own.
1. From One Statement to a Plan
A single line of SQL passes through four stages before it becomes a result.
- Parser — checks the grammar and builds a parse tree. At this stage, PostgreSQL checks little more than whether the tables you referenced actually exist.
- Rewriter — expands views out into their real underlying definitions, and applies any rules. Query a view, and this is the stage where it turns back into the original query against the base tables.
- Planner/optimizer — builds the possible execution paths and calculates a cost for each one, then picks the cheapest. This is the subject of this piece.
- Executor — walks the chosen plan tree and actually reads the data.
One important fact belongs right here at the start. The planner never looks at your data. What the planner looks at is a summary of the data — statistics. When those statistics drift away from reality, even the most sophisticated cost model produces a wrong answer, because a good model fed a bad input still returns a bad output. The overwhelming majority of execution-plan problems are not flaws in the cost model at all; they are a gap between the statistics and the actual state of the table.
2. Statistics — How the Planner Sees the World
ANALYZE pulls a sample from a table, builds statistics from it, and stores those statistics in pg_statistic. The human-readable form of the same data is the pg_stats view.
SELECT attname, null_frac, n_distinct,
most_common_vals, most_common_freqs,
correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('status', 'created_at');
Each column in that view means something specific:
- null_frac — the fraction of rows where the value is NULL. This is where the selectivity of an
IS NULLcondition comes from. - n_distinct — the number of distinct values. A positive number is the count itself; a negative number is a ratio relative to the row count.
-1means every value in the column is unique. - most_common_vals / most_common_freqs — the list of most common values and how frequently each one occurs. This pair is decisive for any column whose value distribution is skewed rather than uniform.
- histogram_bounds — the boundaries that divide the remaining values, excluding the most common ones, into buckets of roughly equal frequency. This is where the selectivity of a range condition comes from.
- correlation — the correlation coefficient between a column's logical value order and its physical row order on disk. The closer this is to 1, the more an index scan's random access pattern collapses into something close to sequential access, which drives the cost down sharply. It also doubles as the metric that determines whether a BRIN index will actually be effective.
The sample size is controlled by default_statistics_target. Per the PostgreSQL 18 documentation, the default value is 100; a larger value produces a longer most-common-values list and more histogram buckets, which makes estimates more accurate but also lengthens both ANALYZE time and planning time. It can also be tuned per column instead of globally.
-- Increase the sample only for a column with a badly skewed distribution
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;
ALTER TABLE ... SET STATISTICS takes only a SHARE UPDATE EXCLUSIVE lock, so it blocks neither reads nor writes — a useful contrast with the many other forms of ALTER TABLE that take a full ACCESS EXCLUSIVE lock instead.
Statistics get refreshed by autovacuum's analyze task. Per the PostgreSQL 18 documentation, the analyze threshold is computed as autovacuum_analyze_threshold (default 50 tuples) plus autovacuum_analyze_scale_factor (default 0.1, i.e., 10% of the table) multiplied by the table's row count. On a table with 100 million rows, that means 10 million rows have to change before an analyze fires. This is exactly why statistics go stale on very large tables, and lowering the scale factor on a per-table basis is the standard fix.
3. Selectivity Estimation and the Independence Assumption
The value the planner computes from statistics is selectivity: what fraction of all rows a condition lets through. Multiply that fraction by the table's row count and you get the estimated row count that flows into every cost calculation downstream.
The selectivity of a single condition is comparatively accurate. If status = 'PAID' shows up in the most-common-values list, the planner uses that value's recorded frequency directly; if it doesn't appear there, the planner distributes the remaining probability evenly across whatever values are left.
The trouble starts once there are two or more conditions. By default, the planner assumes the conditions are statistically independent of each other and simply multiplies their individual selectivities together. The instant that assumption breaks down, the estimate collapses along with it.
-- City and postal code are, in practice, functionally dependent on each other
SELECT * FROM addresses
WHERE city = '서울특별시' AND postal_code = '06236';
If city has a selectivity of 0.2 and postal_code has a selectivity of 0.0001, the planner multiplies them and computes 0.00002. But in reality, once the postal code is fixed, the city is automatically determined along with it — the two are not independent at all — so the true selectivity is close to 0.0001 on its own. The planner ends up expecting a fifth as many rows as will actually come back, a five-fold underestimate, and that error doesn't stay contained where it started. It rides upward through every join above it, compounding like a snowball as it climbs.
This is the single most common reason execution plans go wrong. On the surface it looks like "the planner picked a Nested Loop, and it turned out slow because there were more rows than expected," but the real root cause was never the choice of join method at all — it was a row-count estimate that had already gone bad several steps further down the tree. Section 7 covers how to correct it.
4. The Cost Model — What the cost Number Is Built From
The cost value that EXPLAIN shows you is not a unit of time. It is a relative, abstract unit, pegged so that one sequential page read costs exactly 1.0. The planner assembles this number out of five cost constants, all of which default to the values documented for PostgreSQL 18.
| Parameter | Default | Meaning |
|---|---|---|
seq_page_cost | 1.0 | the cost of reading one disk page sequentially |
random_page_cost | 4.0 | the cost of reading one disk page at a random location |
cpu_tuple_cost | 0.01 | the CPU cost of processing a single row |
cpu_index_tuple_cost | 0.005 | the CPU cost of processing a single index entry |
cpu_operator_cost | 0.0025 | the CPU cost of evaluating one operator or function call |
The single most practically important number here is random_page_cost's default of 4.0. That value encodes an assumption inherited from the era of spinning disks: that a random access costs four times what a sequential access costs. On SSDs and NVMe storage, that ratio is nowhere near as large. Leave the default in place on modern storage, and the planner will rate index scans as more expensive than they really are, and lean toward sequential scans far more often than it should. Lowering it to somewhere between 1.1 and 2.0 on SSD-backed storage is a widely used adjustment, but always compare plans before and after across a representative set of queries before you change it — this single value influences the entire plan space, and getting it wrong in one direction can quietly break other, unrelated queries that were previously fine.
effective_cache_size deserves the same scrutiny. Its default is 4GB, and — this part is easy to misunderstand — the value never actually allocates any memory. It only changes the planner's assumption about how much data, including whatever the operating system itself is caching, is likely to already be sitting in memory. If it's left set far smaller than the server's real memory, the planner assumes an index scan will hit disk constantly and leans toward a sequential scan instead, even on a machine with plenty of RAM to spare.
The costs around parallel execution work the same way. parallel_setup_cost defaults to 1000, and parallel_tuple_cost defaults to 0.1. Because launching a worker carries a fixed cost of 1000 before it does anything useful at all, small queries never even become candidates for a parallel plan in the first place. The minimum table size for a parallel sequential scan is governed by min_parallel_table_scan_size, which defaults to 8MB; on the index side, the equivalent is min_parallel_index_scan_size, defaulting to 512kB.
5. Comparing Paths — Why This Scan, Why This Join
For each table, the planner builds every access path it can, and then combines those paths together into join paths for the query as a whole. At each step, it keeps the cheapest path, plus any other path that carries a genuinely useful property — "returns rows already sorted in the order I need," for instance — even when that alternate path costs a little more on its own.
For scan paths, the deciding factor is the return ratio: what fraction of the table's rows the condition actually returns. An index scan reads the index and then visits the matching table rows at effectively random locations. As the return ratio climbs, the number of random accesses climbs with it, and past a certain point it becomes cheaper to just read the entire table once, in physical order, instead. That's why a Seq Scan is very often the correct answer, not a planner failure — on a small table, or on a query that returns most of the table's rows anyway, forcing an index only makes things slower.
In the middle ground sits the Bitmap Heap Scan. It reads the whole index first, collects the matching block numbers into an in-memory bitmap, and then reads the table in block-number order rather than in index order. It's a compromise that turns what would have been random access into something close to sequential access, and it shows up constantly whenever the return ratio sits somewhere in between — too high for a plain index scan to stay cheap, too low for a full sequential scan to make sense.
For join paths, the planner chooses among three strategies.
- Nested Loop — probes the inner side once for every single row on the outer side. This is the best possible choice when the outer result is small and the inner side has a good index to probe with. It becomes the worst possible choice when the outer row-count estimate is wrong, because the cost of being wrong gets multiplied by every single outer-loop iteration. This is the single most common culprit behind execution-plan incidents.
- Hash Join — builds an in-memory hash table from one side and scans the other side against it. It only works for equality joins, but when the hash table fits inside
work_mem, it is extremely fast. When it doesn't fit, it spills to disk in batches instead. - Merge Join — sorts both sides and merges them together in lockstep. This is favorable when an input is already sorted, typically because it came from an index scan; otherwise, the plan has to pay the cost of an explicit sort first.
work_mem defaults to 4MB. The critical detail is that this limit applies per sort or hash operation, not per connection. A single query that contains several sorts and hashes can use that many multiples of work_mem at once, all within one connection. Hash-related operations get some additional headroom on top of that: they can use up to work_mem multiplied by hash_mem_multiplier, which defaults to 2.0.
6. Join-Order Search and GEQO
There are only two possible orders for joining two tables, but that number explodes combinatorially once you get to ten. The planner searches the space of join orders using dynamic programming, and once the table count climbs high enough, that exhaustive search itself becomes too expensive to run.
PostgreSQL manages this with three levers, all documented with the following defaults.
from_collapse_limit— defaults to 8. This governs whether a subquery gets flattened up into the parent query. If flattening it would push the number ofFROMitems past this value, PostgreSQL leaves it as a separate subquery instead.join_collapse_limit— defaults to the same value asfrom_collapse_limit. This governs whether explicitJOINsyntax gets flattened into a single flat list of tables that the planner is free to reorder. Set this to 1, and the planner stops reordering joins altogether, executing them in exactly the order you wrote.geqo_threshold— defaults to 12. Once the number ofFROMitems reaches this value, the planner abandons exhaustive search entirely and switches to a genetic algorithm (GEQO) to find a join order instead.
Because GEQO is a probabilistic search, the same query can produce a different plan on every single run. If a reporting query joining more than a dozen tables takes 3 seconds one day and 40 seconds the next, with nothing else about the system having changed, GEQO is a reasonable first suspect. The way to confirm it is to turn geqo off temporarily and compare both the planning time and the execution time with it off.
-- Experiment within this session only
SET geqo = off;
SET join_collapse_limit = 20;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
RESET geqo;
RESET join_collapse_limit;
If planning time explodes to several seconds with GEQO off, that confirms the join really is too large for exhaustive search to handle, and the right fix at that point is to break the query apart or materialize an intermediate result, rather than trying to force GEQO into behaving more predictably than it can.
7. Correcting a Bad Estimate
PostgreSQL solves the independence-assumption problem from Section 3 with extended statistics. CREATE STATISTICS builds statistics on a combination of columns, separately from the per-column statistics ANALYZE collects automatically. The documentation names three kinds it supports:
- dependencies — records functional dependencies between columns. This is what prevents the severe underestimate that happens when several conditions turn out to be effectively redundant with each other, the way city and postal code are.
- ndistinct — records the number of distinct values across a combination of columns together. This corrects estimates for a
GROUP BYthat spans multiple columns, which would otherwise be estimated as though each column varied completely independently of the others. - mcv — records a most-common-values list for a combination of columns. This accurately captures cases where one particular combination of values shows up far more often than the individual column frequencies alone would suggest.
-- Tell the planner about the dependency between city and postal code
CREATE STATISTICS stat_addr_city_postal (dependencies, mcv)
ON city, postal_code FROM addresses;
-- The statistics stay empty until ANALYZE runs
ANALYZE addresses;
CREATE STATISTICS takes only a SHARE UPDATE EXCLUSIVE lock, so running it in production blocks neither reads nor writes. But it's empty the instant it's created, so ANALYZE has to run afterward without fail before it does anything useful at all. Every example in the documentation itself pairs CREATE STATISTICS with an ANALYZE right after it, for exactly this reason.
Bad estimates on expressions are just as common. A condition like date_trunc('month', a) = ... is something the planner cannot estimate at all from ordinary column statistics — there is simply no statistic that describes the distribution of a computed value like that. Expression statistics solve this the same way extended statistics do.
CREATE STATISTICS stat_events_month
ON date_trunc('month', occurred_at) FROM events;
ANALYZE events;
The correction process, laid out in order, looks like this. First, use EXPLAIN ANALYZE to find the node with the largest gap between the estimated row count and the actual one. If that node's condition is a single column, widen the sample with SET STATISTICS. If it's a multi-column condition, build extended statistics with CREATE STATISTICS. If it's an expression, build expression statistics or an expression index. Only once all three of these have been tried and the problem still remains do you finally start restructuring the query itself.
8. When Plans Get Unstable — Prepared Statements and Generic Plans
A query that was fast yesterday suddenly turns slow today, and running the exact same SQL by hand through EXPLAIN shows it fast again. The prime suspect for this specific symptom is the generic plan of a prepared statement.
A prepared statement can build a plan without ever knowing the actual parameter values. A plan built fresh each time, with the real values already in hand, is called a custom plan. A plan built once without knowing the values, and then reused across many later executions, is called a generic plan. A generic plan saves the cost of re-planning on every execution, but on a column with a skewed value distribution, that same reused plan can turn out to be the worst possible plan for one particular value that shows up later.
This behavior is controlled by plan_cache_mode. The allowed values are auto (the default), force_custom_plan, and force_generic_plan. Under the default auto, PostgreSQL builds a handful of custom plans first, averages their cost, compares that average against the generic plan's cost, and decides automatically from there which kind to keep using going forward.
If you want to see the generic plan directly, without supplying any parameter values at all, PostgreSQL 16 and later offers the GENERIC_PLAN option for exactly this.
EXPLAIN (GENERIC_PLAN)
SELECT * FROM orders WHERE tenant_id = $1 AND status = $2;
Bitmap Heap Scan on orders (cost=25.41..1290.88 rows=512 width=124)
Recheck Cond: (tenant_id = $1)
Filter: (status = $2)
-> Bitmap Index Scan on idx_orders_tenant (cost=0.00..25.28 rows=1024 width=0)
Index Cond: (tenant_id = $1)
Once you've confirmed this is actually the symptom, there are two ways to respond. Either set plan_cache_mode = force_custom_plan for that session or application, so every execution always builds a plan around the real values, or strengthen the statistics on whatever column has the skewed distribution, so the generic plan itself gets good enough that the distinction stops mattering. The first option pays a re-planning cost on every single execution, which can itself become a real burden for short queries running thousands of times per second.
JIT compilation is a similar source of instability. jit defaults to on, and jit_above_cost defaults to 100000. Once the estimated cost of a query crosses that threshold, JIT compilation kicks in — but on a short query whose cost the planner happened to overestimate, the time spent compiling can end up larger than the time spent actually executing, which is a real reversal, not just a hypothetical one. Check the JIT section of EXPLAIN (ANALYZE) output to see the compilation time directly.
Quiz: Test Your Understanding
Quiz 1: What's wrong with this execution plan fragment?
Nested Loop (cost=0.86..3421.55 rows=12 width=88)
(actual time=0.09..48210.33 rows=184032 loops=1)
-> Index Scan using idx_a on orders o
(cost=0.43..118.20 rows=4 width=40)
(actual time=0.03..92.11 rows=61344 loops=1)
Index Cond: ((tenant_id = 42) AND (status = 'PAID'::text))
-> Index Scan using idx_b on order_items i
(cost=0.43..825.31 rows=3 width=48)
(actual time=0.30..0.78 rows=3 loops=61344)
Index Cond: (order_id = o.id)
Answer: The outer node's row-count estimate is 4 rows, but the actual result is 61,344 rows. That error is what led the planner to choose a Nested Loop, and it's why the inner index scan ended up repeating 61,344 times.
Explanation: Notice loops=61344 on the inner node. A scan that takes 0.78ms on its own, repeated sixty thousand times over, adds up to 48 seconds. The problem here was never the join method — it's the estimate sitting underneath it. tenant_id and status are correlated in this data, but the planner assumed they were independent and multiplied their selectivities together anyway, which produced an underestimate off by a factor of roughly fifteen thousand. The fix is CREATE STATISTICS (dependencies, mcv) ON tenant_id, status FROM orders, followed by ANALYZE. Once the estimate is corrected, the planner switches over to a Hash Join entirely on its own. Forcing the issue with enable_nestloop = off only papers over the symptom and leaves the underlying estimate exactly as wrong as before.
Quiz 2: A server running NVMe storage still picks a Seq Scan even for queries that have a matching index available. Which parameters should you suspect?
Answer: random_page_cost and effective_cache_size.
Explanation: random_page_cost's default of 4.0 is an assumption inherited from the era of spinning disks, where random access really did cost roughly four times what sequential access cost. On NVMe, that gap is far smaller, so leaving the default in place makes the planner rate index paths as more expensive than they actually are. effective_cache_size's default of 4GB deserves the same scrutiny. That value never allocates memory — it only changes the planner's assumption about how much is cached — so on a server with 256GB of RAM, leaving it at 4GB makes the planner assume an index scan will hit disk constantly, when in practice almost everything is already sitting in memory. That said, both values influence the entire plan, not just the one query you happen to be looking at, so compare plans before and after across a representative set of queries before changing either one.
Quiz 3: A reporting query that joins fourteen tables has response times that vary wildly from one run to the next. Why?
Answer: The join spans more tables than geqo_threshold's default of 12, so a genetic algorithm is searching for the join order instead of an exhaustive search, and because that search is probabilistic, it can produce a different plan every time it runs.
Explanation: GEQO is a technique for finding a "good enough" join order quickly, on a scale where exhaustive search would be too expensive to run at all — it makes no promise of finding the actual optimum, and run-to-run variance is an expected side effect of how it works, not a bug to be fixed. To confirm it, turn SET geqo = off for the session and compare both planning time and execution time with it off. If exhaustive search then takes several seconds, the better fix is to reduce the number of tables actually being joined together — splitting the query up, or materializing an intermediate result into a temporary table or a materialized view — rather than trying to tame GEQO's randomness directly. Setting join_collapse_limit to 1 to force the order you wrote is also an option, but it only helps if a human already knows the optimal order, which is not a safe assumption to make about a fourteen-table join.
Quiz 4: The same query takes 8 seconds when the application runs it, but 30ms when you paste the exact same SQL into psql. What should you suspect?
Answer: The generic plan of a prepared statement.
Explanation: Most drivers use prepared statements to bind parameters, as a matter of course, without anyone choosing that explicitly. plan_cache_mode's default of auto builds a handful of custom plans first, and once the generic plan starts looking cheaper on average, it locks in on that generic plan going forward. On a column with a skewed value distribution, that generic plan can turn out to be disastrous for one particular value that comes up later. Pasting the SQL directly into psql is fast because each execution there builds a brand-new plan around the literal values you typed, with no generic plan involved at all. The way to confirm this is EXPLAIN (GENERIC_PLAN), which shows you the plan built without knowing any parameter values, directly. The response is either applying plan_cache_mode = force_custom_plan to that specific workload, or strengthening statistics on the skewed column so the generic plan itself becomes good enough that the gap disappears on its own.
Quiz 5: Without running EXPLAIN at all, which metrics should you keep an eye on continuously to prevent execution-plan problems before they happen?
Answer: The metrics that drive the gap between estimated and actual row counts in the first place — namely, how fresh the statistics are, and how much the table has changed since they were last collected.
Explanation: pg_stat_user_tables.n_mod_since_analyze is an estimate of how many rows have changed since the last ANALYZE. If that number is large relative to the table's size, the statistics are stale. Watch last_autovacuum and last_autoanalyze alongside it. On very large tables, autovacuum_analyze_scale_factor's default of 0.1 (10%) means analyze almost never fires on its own, so it needs to be lowered on a per-table basis instead of left at the global default.
ALTER TABLE orders SET (autovacuum_analyze_scale_factor = 0.02);
This setting changes a table storage parameter, so it takes only a SHARE UPDATE EXCLUSIVE lock.
Closing
Spend long enough tuning execution plans and the conclusion gets simple. The cost model is, for the most part, right — what's wrong is almost always the input. Most of the time, someone hands the planner bad statistics and then blames the planner for the plan it built from them. Twisting a plan into shape with a blunt lever like enable_nestloop = off is tempting precisely because the effect is immediate and visible, but it's a stopgap that collapses again the moment the data distribution shifts even slightly, because it never touched the actual cause underneath.
The order is always the same. Find the node with the largest gap between estimate and reality, work out whether that gap comes from too small a sample, an unmodeled correlation, or an un-estimable expression, and correct it with whichever statistics tool actually addresses that specific cause. Only whatever problem still remains after that gets handled through query structure or session parameters — never before.
You can run every piece of SQL from this piece yourself in the Postgres Playground.
Sources
- PostgreSQL 18, Query Planning: https://www.postgresql.org/docs/18/runtime-config-query.html (retrieved 2026-08-15)
- PostgreSQL 18, EXPLAIN: https://www.postgresql.org/docs/18/sql-explain.html (retrieved 2026-08-15)
- PostgreSQL 18, CREATE STATISTICS: https://www.postgresql.org/docs/18/sql-createstatistics.html (retrieved 2026-08-15)
- PostgreSQL 18, Resource Consumption: https://www.postgresql.org/docs/18/runtime-config-resource.html (retrieved 2026-08-15)
- PostgreSQL 18, Automatic Vacuuming: https://www.postgresql.org/docs/18/runtime-config-autovacuum.html (retrieved 2026-08-15)
- PostgreSQL 18, Monitoring Database Activity: https://www.postgresql.org/docs/18/monitoring-stats.html (retrieved 2026-08-15)
- PostgreSQL 18, ALTER TABLE: https://www.postgresql.org/docs/18/sql-altertable.html (retrieved 2026-08-15)
Further Reading
- Previous: The Complete Guide to PostgreSQL Indexes — the index lifecycle
- Next: The Complete Guide to Transaction Isolation Levels — the operational contract behind isolation levels
- How to Read EXPLAIN ANALYZE — the order to read the output in
- The Volcano Model and Vectorized Execution — how the executor actually runs a plan
- Postgres Playground — pull your own execution plans directly
- DuckDB Playground — compare against an analytical engine's plans