Skip to content

Split View: PostgreSQL 인덱스 완전 가이드: 설계부터 폐기까지 인덱스의 수명 주기

|

PostgreSQL 인덱스 완전 가이드: 설계부터 폐기까지 인덱스의 수명 주기

들어가며

인덱스에 관한 글은 대부분 두 종류입니다. 하나는 B-tree가 무엇인지 설명하는 자료구조 이야기고, 다른 하나는 "만든 인덱스를 왜 안 타는가"를 다루는 옵티마이저 이야기입니다. 이 블로그에도 두 종류 모두 있습니다. PostgreSQL 고급 인덱싱이 전자에 가깝고, 인덱스를 만들었는데 안 타는 이유가 후자입니다.

이 글은 세 번째 관점을 잡습니다. 인덱스를 수명 주기를 가진 운영 자산으로 다루는 관점입니다. 인덱스는 만들어지고, 검증되고, 늙고, 결국 지워집니다. 현장에서 사고가 나는 지점은 대개 "어떤 자료구조인가"가 아니라 "언제 어떻게 만들고 언제 어떻게 지우는가"입니다. 운영 중인 테이블에 인덱스를 만들다가 쓰기를 몇 분간 막아 버리거나, 아무도 쓰지 않는 인덱스 열두 개가 매 INSERT마다 비용을 청구하고 있는 상황 말입니다.

기준 엔진은 PostgreSQL 18 입니다. 파라미터 기본값, 잠금 등급, EXPLAIN 동작은 모두 PostgreSQL 18 문서를 읽고 적었으며, 버전에 따라 달라지는 항목은 그때마다 표시했습니다. MySQL의 인덱스는 클러스터형 인덱스라는 전혀 다른 전제 위에 서 있으므로 이 글에서 섞지 않습니다.

1. 인덱스의 수명 주기라는 관점

인덱스를 하나 만들면 그때부터 데이터베이스는 세 가지 비용을 계속 지불합니다.

  • 쓰기 비용 — 해당 테이블의 INSERT, UPDATE, DELETE마다 인덱스 엔트리를 갱신해야 합니다. 인덱스가 열 개면 열 번입니다.
  • 공간 비용 — 인덱스도 디스크와 버퍼 캐시를 차지합니다. 캐시에 인덱스가 자리를 차지하면 그만큼 테이블 데이터가 밀려납니다.
  • 유지보수 비용 — VACUUM은 테이블뿐 아니라 인덱스도 청소해야 합니다. 인덱스가 많으면 VACUUM이 길어집니다.

읽기 이득은 눈에 잘 보이지만 이 세 가지 비용은 잘 보이지 않습니다. 그래서 인덱스는 계속 늘어나기만 하고 줄어들지 않습니다. 수명 주기 관점은 이 비대칭을 교정하려는 시도입니다. 인덱스마다 "왜 만들었는가"와 "언제 지울 것인가"를 함께 관리하자는 것입니다.

수명 주기는 여섯 단계로 나눌 수 있습니다. 설계, 방식 선택, 생성, 검증, 운영, 폐기. 아래 절들은 이 순서를 그대로 따릅니다.

2. 설계 — 어떤 컬럼을 어떤 순서로

복합 인덱스에서 컬럼 순서는 성능을 몇 배가 아니라 몇 자릿수 단위로 바꿉니다. 순서를 정하는 규칙은 간단합니다.

등치 조건에 쓰이는 컬럼을 먼저, 범위 조건에 쓰이는 컬럼을 나중에 둡니다. B-tree 인덱스는 선행 컬럼부터 순서대로 정렬되어 있으므로, 범위 조건이 한 번 등장하면 그 뒤 컬럼은 더 이상 검색 조건으로 좁히는 데 쓰이지 못하고 필터로만 남습니다.

-- 자주 실행되는 쿼리
SELECT id, total_amount
FROM orders
WHERE tenant_id = 42
  AND status = 'PAID'
  AND created_at >= now() - interval '7 days'
ORDER BY created_at DESC
LIMIT 50;

-- 좋은 순서: 등치(tenant_id, status) 다음에 범위/정렬(created_at)
CREATE INDEX idx_orders_tenant_status_created
  ON orders (tenant_id, status, created_at DESC);

-- 나쁜 순서: 범위가 앞에 오면 뒤의 등치 조건을 인덱스로 좁히지 못함
CREATE INDEX idx_orders_created_tenant_status
  ON orders (created_at DESC, tenant_id, status);

정렬까지 인덱스에 태우려면 ORDER BY 방향을 인덱스 정의에 반영해야 합니다. 위 예에서 created_at DESC로 만들어 두면 정렬 연산 자체가 사라집니다. PostgreSQL은 인덱스를 역방향으로도 읽을 수 있으므로 단일 컬럼 정렬이면 방향이 크게 중요하지 않지만, 복합 정렬(a ASC, b DESC)에서는 인덱스 정의가 정확히 맞아야 정렬을 생략합니다.

커버링 인덱스와 INCLUDE

인덱스만 읽고 테이블을 방문하지 않는 실행 방식을 Index Only Scan이라고 합니다. 조회하는 컬럼이 모두 인덱스에 있으면 가능합니다. 검색 조건에는 쓰이지 않지만 결과로 필요한 컬럼은 INCLUDE 절에 넣는 편이 낫습니다.

CREATE INDEX idx_orders_cover
  ON orders (tenant_id, status)
  INCLUDE (total_amount, created_at);

PostgreSQL 문서는 INCLUDE로 지정한 비키 컬럼에 대해 "비키 컬럼은 인덱스 스캔의 검색 조건에 사용될 수 없고, 유일성이나 배제 제약을 판정할 때도 무시된다"라고 명시합니다. 대신 인덱스 엔트리에서 값을 바로 꺼낼 수 있으므로 Index Only Scan이 가능해집니다. INCLUDE는 B-tree, GiST, SP-GiST에서만 지원되고, 비키 컬럼이 있는 B-tree 인덱스는 중복 제거(deduplication)가 꺼집니다.

주의할 점이 하나 있습니다. Index Only Scan이 실제로 테이블을 건너뛰려면 가시성 맵(visibility map)에서 해당 페이지가 전체 가시(all-visible)로 표시되어 있어야 합니다. VACUUM이 돌지 않은 갓 갱신된 테이블에서는 Index Only Scan이라고 표시되어도 Heap Fetches 값이 크게 나옵니다. 실행 계획에서 이 숫자를 반드시 확인하세요.

3. 방식 선택 — 여섯 가지 중 무엇을

PostgreSQL은 여섯 가지 인덱스 방식을 제공합니다. btree, hash, gist, spgist, gin, brin이며 USING 절을 생략하면 B-tree입니다. 자료구조 설명보다 판단 기준이 중요하므로 표로 정리합니다.

방식처리 가능한 연산자선택하는 상황
btree<, <=, =, >=, >, BETWEEN, IN, IS NULL기본값. 정렬 순서 반환이 필요할 때
hash=등치 검색만 하고 값이 매우 길 때
gist기하/범위 타입 연산자, 최근접 이웃 검색범위 타입 배제 제약, 공간 데이터
spgist비균형 분할 구조에 맞는 연산자쿼드트리, 라딕스 트리 형태의 데이터
gin배열, 전문 검색, jsonb 포함 연산자한 행이 여러 값을 가질 때
brin선형 순서 타입의 <, <=, =, >=, >물리 순서와 값 순서의 상관관계가 높은 거대 테이블

문서가 명시하는 B-tree의 추가 능력 두 가지를 기억하면 실무에서 자주 씁니다. 첫째, LIKE 'foo%'처럼 앞이 고정된 패턴은 B-tree가 처리합니다. LIKE '%foo'는 처리하지 못합니다. 둘째, B-tree만이 정렬된 순서로 데이터를 돌려줄 수 있습니다.

BRIN은 오해가 많은 방식입니다. BRIN은 연속된 블록 범위의 요약(최솟값과 최댓값)만 저장하므로 인덱스 크기가 극단적으로 작습니다. 다만 문서가 강조하듯 컬럼 값이 물리적 행 순서와 잘 상관되어 있을 때만 효과가 있습니다. 시간 순으로만 쌓이는 로그 테이블의 created_at에는 훌륭하고, 무작위로 갱신되는 상태 컬럼에는 무용지물입니다.

4. 부분 인덱스와 표현식 인덱스

인덱스를 작게 만드는 두 가지 도구입니다. 실무에서 가장 저평가된 기능이기도 합니다.

부분 인덱스WHERE 절로 색인 대상 행을 제한합니다. 전체 행의 1%만 조회 대상이라면 인덱스도 1%만 만들면 됩니다.

-- 미처리 작업만 조회하는 큐 테이블
CREATE INDEX idx_jobs_pending
  ON jobs (created_at)
  WHERE status = 'PENDING';

-- 소프트 삭제된 행을 제외한 유일 제약
CREATE UNIQUE INDEX idx_users_email_active
  ON users (lower(email))
  WHERE deleted_at IS NULL;

두 번째 예는 부분 인덱스와 표현식 인덱스를 함께 쓴 것으로, 소프트 삭제를 쓰는 스키마에서 "살아 있는 행끼리만 이메일이 유일해야 한다"는 요건을 제약으로 표현하는 정석입니다. 애플리케이션 코드로 검사하면 동시성 구멍이 생기지만 유일 인덱스는 데이터베이스가 보장합니다.

표현식 인덱스는 컬럼이 아니라 식에 인덱스를 겁니다. 주의할 점은 쿼리의 식과 인덱스의 식이 정확히 같아야 한다는 것입니다. lower(email)로 인덱스를 만들었다면 쿼리도 lower(email) = ... 이어야 하고, email ILIKE ...는 이 인덱스를 쓰지 못합니다.

표현식 인덱스는 통계에도 영향을 줍니다. PostgreSQL은 표현식 인덱스에 대해 별도 통계를 수집하므로, 인덱스를 만드는 것만으로 행 수 추정이 정확해지는 부수 효과가 있습니다. 인덱스 없이 통계만 원한다면 CREATE STATISTICS로 표현식 통계만 따로 만들 수도 있습니다.

5. 생성 — CONCURRENTLY와 실패 복구

여기가 운영 사고가 가장 많이 나는 지점입니다.

경고: 일반 CREATE INDEX는 대상 테이블의 쓰기를 완료될 때까지 막습니다. PostgreSQL 문서는 일반 CREATE INDEX가 "테이블에 대한 쓰기(삽입, 갱신, 삭제)를 완료될 때까지 잠근다"라고 명시합니다. 읽기는 허용되지만 쓰기는 대기합니다. 수억 행 테이블에서 이 명령은 수십 분 동안 서비스의 쓰기 경로를 정지시킵니다. 운영 중인 테이블에는 반드시 CONCURRENTLY를 붙이세요.

-- 운영 테이블에는 이것만 쓴다
CREATE INDEX CONCURRENTLY idx_orders_tenant_status_created
  ON orders (tenant_id, status, created_at DESC);

CONCURRENTLY는 삽입, 갱신, 삭제를 막는 잠금을 취하지 않습니다. 대신 대가가 있습니다. 문서에 따르면 두 번의 테이블 스캔을 서로 다른 두 트랜잭션에서 수행하고, 각 스캔 전에 인덱스를 수정하거나 사용할 수 있는 기존 트랜잭션이 모두 끝나기를 기다립니다. 두 번째 스캔 이후에는 그 스캔보다 앞선 스냅샷을 가진 트랜잭션이 끝나기를 또 기다립니다. 그래서 오래 열려 있는 트랜잭션 하나가 인덱스 생성 전체를 무한정 지연시킬 수 있습니다. 인덱스를 만들기 전에 pg_stat_activity에서 장수 트랜잭션을 확인하는 습관을 들이세요.

또한 CREATE INDEX CONCURRENTLY는 트랜잭션 블록 안에서 실행할 수 없습니다. 마이그레이션 도구가 모든 마이그레이션을 하나의 트랜잭션으로 감싸는 설정이라면 이 명령은 실패합니다. 도구별로 트랜잭션을 끄는 옵션을 찾아 두어야 합니다.

실패했을 때

문제가 생기면 명령은 실패하지만 무효(invalid) 인덱스가 남습니다. 문서 표현대로 이 인덱스는 "불완전할 수 있으므로 질의에는 무시되지만, 갱신 오버헤드는 계속 발생시킵니다." 즉 비용만 내고 이득은 없는 최악의 상태입니다.

-- 무효 인덱스 찾기
SELECT c.relname AS index_name, i.indisvalid, i.indisready
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid;

-- 복구: 지우고 다시 만들거나
DROP INDEX CONCURRENTLY idx_orders_tenant_status_created;
-- 또는 재구축
REINDEX INDEX CONCURRENTLY idx_orders_tenant_status_created;

인덱스 생성 작업을 끝냈다고 보고하기 전에 위 조회를 한 번 돌리는 것을 절차에 넣으세요. 무효 인덱스는 조용히 남습니다.

파티션 테이블의 예외

문서는 "파티션된 테이블의 인덱스 동시 생성은 현재 지원되지 않는다"라고 못박습니다. 대신 권장하는 우회 방법이 있습니다. 각 파티션에 개별적으로 CONCURRENTLY로 인덱스를 만든 다음, 마지막에 부모 파티션 테이블에 비동시적으로 인덱스를 만들면 쓰기가 잠기는 시간을 줄일 수 있습니다. 부모에 인덱스를 만들 때 이미 이름이 맞는 인덱스가 각 파티션에 있으면 그것을 붙여서 씁니다.

6. 검증 — 정말 쓰이고 있는가

만든 인덱스가 쓰이는지 확인하는 방법은 두 층위입니다.

쿼리 단위로는 EXPLAIN을 봅니다. PostgreSQL 18에서는 ANALYZE를 쓰면 버퍼 정보가 자동으로 포함됩니다. 문서에 "Buffers information is automatically included when ANALYZE is used"라고 명시되어 있습니다. 17 이하에서는 BUFFERS를 직접 붙여야 했습니다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total_amount
FROM orders
WHERE tenant_id = 42 AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 50;
 Limit  (cost=0.56..42.18 rows=50 width=20)
        (actual time=0.041..0.180 rows=50 loops=1)
   Buffers: shared hit=54
   ->  Index Only Scan Backward using idx_orders_cover on orders
         (cost=0.56..8321.44 rows=10004 width=20)
         (actual time=0.039..0.171 rows=50 loops=1)
         Index Cond: ((tenant_id = 42) AND (status = 'PAID'::text))
         Heap Fetches: 0
         Buffers: shared hit=54
 Planning Time: 0.212 ms
 Execution Time: 0.221 ms

여기서 볼 것은 세 가지입니다. Index Cond에 조건이 들어갔는지(들어가지 않고 Filter에 있으면 인덱스로 좁히지 못한 것입니다), Heap Fetches가 0인지, 그리고 rows 추정치와 실제값의 차이입니다.

경고: EXPLAIN ANALYZE는 문장을 실제로 실행합니다. 문서가 명시하듯 SELECT의 출력은 버려지지만 다른 부수 효과는 그대로 일어납니다. INSERT, UPDATE, DELETE, MERGE를 분석할 때는 BEGIN / EXPLAIN ANALYZE ... / ROLLBACK으로 감싸세요.

워크로드 단위로는 통계 뷰를 봅니다. pg_stat_user_indexesidx_scan은 해당 인덱스로 시작된 스캔 횟수이고, last_idx_scan은 마지막 스캔 시각입니다.

SELECT s.schemaname, s.relname, s.indexrelname,
       s.idx_scan, s.last_idx_scan,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE NOT i.indisunique
ORDER BY s.idx_scan ASC, pg_relation_size(s.indexrelid) DESC;

이 숫자를 읽을 때 두 가지를 조심해야 합니다. 첫째, 통계는 마지막 초기화 이후 누적값입니다. 언제 초기화했는지 모르면 0의 의미도 모릅니다. 둘째, 월 1회 배치나 분기 마감 보고서만 쓰는 인덱스는 일주일 관찰로는 0으로 보입니다. 최소 한 번의 전체 업무 주기를 관찰하세요.

7. 운영 — 부풀어 오른 인덱스 다루기

PostgreSQL의 MVCC에서는 갱신이 새 행 버전을 만들고, 인덱스도 그에 맞춰 엔트리를 추가합니다. 죽은 엔트리는 VACUUM이 정리하지만, 정리된 공간이 항상 재사용 가능한 형태로 남지는 않습니다. 시간이 지나면 인덱스는 논리적 데이터량보다 커집니다. 이것이 인덱스 bloat입니다.

증상은 조용합니다. 쿼리가 갑자기 느려지지 않고 조금씩 느려집니다. 인덱스가 캐시에 덜 들어가고, 스캔이 더 많은 페이지를 읽습니다.

대응은 재구축입니다.

경고: REINDEXCONCURRENTLY 없이 실행하면 대상 테이블에 ACCESS EXCLUSIVE 잠금을 겁니다. 이 잠금은 모든 잠금 모드와 충돌하므로 읽기까지 전부 막힙니다. 같은 이유로 VACUUM FULLCLUSTER도 운영 중에 함부로 쓰면 안 됩니다. PostgreSQL 문서 자체가 "관리자는 일반 VACUUM을 쓰고 VACUUM FULL은 피하도록 노력해야 한다"라고 권고합니다. 안전한 대안은 REINDEX INDEX CONCURRENTLY 입니다. 이 명령은 SHARE UPDATE EXCLUSIVE 잠금만 취하므로 읽기와 쓰기를 막지 않습니다.

-- 안전: 읽기와 쓰기를 막지 않음
REINDEX INDEX CONCURRENTLY idx_orders_tenant_status_created;

-- 테이블의 모든 인덱스를 동시 재구축
REINDEX TABLE CONCURRENTLY orders;

REINDEX ... CONCURRENTLY 역시 실패하면 무효 인덱스를 남길 수 있습니다. 이름 끝에 _ccnew가 붙은 잔여 인덱스가 있으면 정리해야 합니다.

재구축 주기를 정할 때는 갱신 패턴을 보세요. append-only에 가까운 테이블은 인덱스가 잘 부풀지 않습니다. 같은 행을 반복해서 갱신하는 상태 테이블, 큐 테이블은 빠르게 부풉니다. 큐 테이블은 부분 인덱스와 함께 쓰면 bloat 영향이 크게 줄어듭니다.

8. 폐기 — 되돌릴 수 있게 지우기

쓰이지 않는 인덱스는 지워야 합니다. 다만 "쓰이지 않는다"는 판단이 틀릴 수 있으므로 되돌릴 수 있는 절차로 진행합니다.

먼저 중복 인덱스를 찾습니다. (a)(a, b)가 둘 다 있으면 앞의 것은 대개 불필요합니다. B-tree는 선행 컬럼만 쓰는 조건도 처리할 수 있기 때문입니다. 다만 (a)가 유일 인덱스거나 (a)만으로 Index Only Scan이 성립하는 경우는 예외입니다.

폐기 절차는 세 단계입니다.

1단계 — 후보 선정. 6절의 조회로 idx_scan이 0이거나 극단적으로 낮은 인덱스를 뽑습니다. 유일 제약을 뒷받침하는 인덱스와 외래 키의 참조 측 인덱스는 제외합니다. 외래 키가 걸린 자식 테이블에 인덱스가 없으면 부모 행 삭제 시 전체 스캔이 발생합니다.

2단계 — 비활성화 실험. 지우기 전에 먼저 옵티마이저가 무시하게 만듭니다. PostgreSQL에는 인덱스를 끄는 공식 명령이 없지만, 시스템 카탈로그를 직접 고쳐 유효하지 않다고 표시하는 방법이 알려져 있습니다. 다만 카탈로그 직접 수정은 위험하므로, 더 안전한 방법은 세션 단위로 enable_indexscanenable_bitmapscan을 끄고 대표 쿼리의 계획과 실행 시간을 비교하는 것입니다. 이 두 파라미터의 기본값은 모두 on 입니다.

-- 세션에서만 인덱스 경로를 배제해 최악의 경우를 측정
SET enable_indexscan = off;
SET enable_bitmapscan = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
RESET enable_indexscan;
RESET enable_bitmapscan;

3단계 — 삭제. 삭제도 동시 모드로 합니다.

DROP INDEX CONCURRENTLY idx_orders_legacy_status;

그리고 반드시 재생성 스크립트를 함께 커밋해 두세요. 인덱스 삭제의 롤백은 재생성이고, 재생성에는 시간이 걸립니다. 장애 상황에서 정의를 기억으로 복원하려 하면 실수합니다. pg_get_indexdef()로 정의를 미리 떠 두는 것이 좋습니다.

SELECT indexrelid::regclass AS index_name,
       pg_get_indexdef(indexrelid) AS definition
FROM pg_index
WHERE indrelid = 'orders'::regclass;

퀴즈: 실력을 확인해 보세요

퀴즈 1: 아래 실행 계획에서 무엇이 문제인가요?
Index Only Scan using idx_orders_cover on orders
  (actual time=0.05..820.4 rows=48000 loops=1)
  Index Cond: (tenant_id = 42)
  Heap Fetches: 47912
  Buffers: shared hit=1204 read=46180

정답: Heap Fetches가 거의 반환 행 수와 같습니다. 이름은 Index Only Scan이지만 실제로는 거의 모든 행에서 테이블을 방문하고 있습니다.

설명: Index Only Scan이 테이블 접근을 생략하려면 가시성 맵에서 해당 페이지가 전체 가시로 표시되어 있어야 합니다. VACUUM이 최근에 돌지 않았거나 테이블 갱신이 잦으면 이 조건이 깨지고, 인덱스를 읽은 뒤 다시 테이블을 읽는 최악의 경로가 됩니다. read=46180은 그 대부분이 캐시 미스였다는 뜻입니다. 대응은 해당 테이블에 VACUUM을 돌려 가시성 맵을 갱신하고, autovacuum이 이 테이블에 충분히 자주 도는지 pg_stat_user_tables.last_autovacuum으로 확인하는 것입니다.

퀴즈 2: 운영 중인 5억 행 테이블에 인덱스를 추가하려 합니다. CREATE INDEX CONCURRENTLY를 실행했는데 두 시간째 끝나지 않습니다. 무엇을 먼저 확인해야 할까요?

정답: 오래 열려 있는 트랜잭션이 있는지 확인합니다.

설명: CREATE INDEX CONCURRENTLY는 두 번의 테이블 스캔을 수행하고, 각 스캔 전후로 관련 트랜잭션이 종료되기를 기다립니다. 특히 두 번째 스캔 이후에는 그 스캔보다 앞선 스냅샷을 가진 트랜잭션이 끝날 때까지 대기합니다. 따라서 몇 시간째 열려 있는 분석 쿼리나 idle in transaction 상태의 커넥션 하나가 인덱스 생성을 무한정 붙잡을 수 있습니다.

SELECT pid, state, now() - xact_start AS xact_age, left(query, 60)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

근본 대응은 idle_in_transaction_session_timeout을 설정해 두는 것입니다. PostgreSQL 18에서 이 값의 기본값은 0(비활성)입니다.

퀴즈 3: 소프트 삭제(deleted_at)를 쓰는 users 테이블에서 "살아 있는 사용자끼리만 이메일이 유일"해야 합니다. 어떻게 구현해야 할까요?

정답: 조건부 유일 인덱스를 만듭니다.

CREATE UNIQUE INDEX CONCURRENTLY idx_users_email_alive
  ON users (lower(email))
  WHERE deleted_at IS NULL;

설명: 애플리케이션에서 "먼저 SELECT로 확인하고 없으면 INSERT"를 하면 두 요청이 동시에 들어왔을 때 둘 다 통과합니다. 유일 인덱스는 데이터베이스가 원자적으로 보장하므로 이 경합이 원천적으로 사라집니다. lower()로 감싼 이유는 대소문자를 구분하지 않는 이메일 동등성을 표현하기 위해서이고, 이때 조회 쿼리도 lower(email) 을 왼쪽에 그대로 둔 형태여야 인덱스를 씁니다. 운영 테이블이므로 CONCURRENTLY를 붙였습니다.

퀴즈 4: idx_scan이 0인 인덱스를 발견했습니다. 바로 지워도 될까요?

정답: 아닙니다. 최소한 세 가지를 먼저 확인해야 합니다.

설명: 첫째, 통계가 언제 초기화되었는지 확인합니다. 어제 pg_stat_reset()이 돌았다면 0은 아무 의미가 없습니다. 둘째, 관찰 기간이 전체 업무 주기를 덮는지 확인합니다. 월말 정산 배치만 쓰는 인덱스는 월중 관찰로는 보이지 않습니다. 셋째, 유일 제약을 뒷받침하는 인덱스인지, 외래 키의 참조 측 인덱스인지 확인합니다. 유일 인덱스는 스캔이 0이어도 제약을 강제하는 역할을 하므로 지우면 데이터 무결성이 깨집니다. 외래 키 자식 측 인덱스는 부모 행 삭제나 갱신 때만 쓰이므로 평소 idx_scan이 낮지만, 지우면 부모 삭제가 전체 스캔으로 바뀝니다.

퀴즈 5: 아래 두 인덱스가 같은 테이블에 있습니다. 하나를 지운다면 어느 쪽이고, 예외는 무엇인가요?
CREATE INDEX idx_a ON events (tenant_id);
CREATE INDEX idx_b ON events (tenant_id, occurred_at);

정답: 보통은 idx_a를 지웁니다. 다만 세 가지 예외가 있습니다.

설명: B-tree 복합 인덱스는 선행 컬럼만 쓰는 조건도 처리할 수 있으므로 idx_bidx_a의 역할을 대부분 흡수합니다. 예외는 다음과 같습니다. 첫째, idx_a가 유일 인덱스라면 제약을 담당하므로 지울 수 없습니다. 둘째, tenant_id만 읽는 쿼리가 매우 잦다면 더 작은 idx_a가 캐시 효율에서 유리할 수 있습니다. 인덱스가 작을수록 버퍼 캐시에 더 잘 들어갑니다. 셋째, idx_a가 부분 인덱스라면 대상 행 집합이 다르므로 대체 관계가 성립하지 않습니다. 판단이 서지 않으면 8절의 3단계 절차대로 재생성 스크립트를 남기고 DROP INDEX CONCURRENTLY로 지운 뒤 지표를 관찰하세요.

마치며

인덱스 문제의 대부분은 지식 부족이 아니라 절차 부재에서 옵니다. B-tree가 무엇인지 아는 사람은 많지만, 운영 테이블에 인덱스를 만들 때 CONCURRENTLY를 붙이고 장수 트랜잭션을 먼저 확인하고 끝나고 무효 인덱스를 조회하는 절차를 가진 팀은 적습니다.

세 가지만 팀 규칙으로 만들어도 사고의 대부분이 사라집니다. 첫째, 운영 테이블의 인덱스 생성·삭제·재구축은 항상 CONCURRENTLY로 한다. 둘째, 인덱스를 만들 때 왜 만드는지와 어떤 쿼리를 위한 것인지를 마이그레이션 파일 주석에 남긴다. 셋째, 분기마다 한 번 idx_scan을 조회해 폐기 후보를 검토한다.

여기 나온 SQL은 이 사이트의 Postgres 놀이터에서 그대로 실행해 볼 수 있습니다.

참고 자료

이어서 읽기

The Complete Guide to PostgreSQL Indexes: The Index Lifecycle from Design to Retirement

Introduction

Most writing about indexes falls into one of two camps. One explains data structures — what a B-tree actually is and how it is laid out on disk. The other is optimizer talk, the kind of piece that walks through "why won't my index get used, even though I built it." This blog already has examples of both. The Complete Guide to Advanced PostgreSQL Indexing leans toward the first camp, and Why the Index You Built Isn't Being Used leans toward the second.

This piece takes a third angle. It treats the index as an operational asset with a lifecycle, the same way you would treat a server, a queue, or a cron job. An index gets designed, gets created, gets verified, ages in production as the data and the workload shift underneath it, and eventually gets dropped. In practice, the incidents that actually wake someone up rarely come from "what data structure is this." They come from "when and how do you create it, and when and how do you drop it" — moments like building an index on a live table and accidentally blocking writes for several minutes, or a dozen indexes nobody ever queries, each one quietly charging a fee on every single INSERT, UPDATE, and DELETE that touches the table.

The reference engine throughout is PostgreSQL 18. Every default parameter value, lock level, and EXPLAIN behavior quoted here was read directly from the PostgreSQL 18 documentation, and anywhere the behavior differs across versions, that difference is called out explicitly rather than glossed over. MySQL's indexes stand on an entirely different premise — the clustered index, where the table itself is physically organized as an index on the primary key — so this piece deliberately does not mix the two engines together. Readers coming from MySQL should treat every claim here as PostgreSQL-specific.

1. The Index as a Lifecycle

The moment you create an index, the database starts paying three ongoing costs, and it keeps paying them for as long as that index exists.

  • Write cost — every INSERT, UPDATE, and DELETE on the table has to update the index entry too, on top of the base table write. Ten indexes on a table means ten extra updates for every single row change.
  • Space cost — an index takes up disk and buffer-cache space just like table data does. Every page an index holds in cache is a page of table data pushed out, which means other queries now have to go to disk more often than they otherwise would.
  • Maintenance costVACUUM has to clean indexes as well as the table itself. More indexes means longer VACUUM runs, and a VACUUM that takes longer competes for I/O with the rest of the workload for that much longer.

The read benefit of an index is easy to see — a query gets faster, and everyone notices. These three costs are not; they show up as a slow, cumulative drag rather than a single visible event. That asymmetry is exactly why indexes only ever accumulate on a typical production database and almost never shrink on their own — nobody feels the pain of removing one, so nobody does. The lifecycle view is an attempt to correct that asymmetry deliberately, by managing, for every index that exists, both "why was this built" and "when will this be dropped," as a single ongoing concern rather than a one-time decision made at creation time.

The lifecycle breaks into six stages: design, method selection, creation, verification, operations, and retirement. The sections below follow that order exactly, and each one is meant to stand on its own as a reference you can jump back into.

2. Design — Which Columns, in What Order

In a composite index, column order changes performance not by a multiple but by an order of magnitude. The rule for choosing that order is simple.

Put columns used in equality conditions first, and columns used in range conditions after. A B-tree index is sorted in order starting from its leading column, so once a range condition appears, every column after it can no longer be used to narrow the search — it survives only as a filter.

-- A frequently executed query
SELECT id, total_amount
FROM orders
WHERE tenant_id = 42
  AND status = 'PAID'
  AND created_at >= now() - interval '7 days'
ORDER BY created_at DESC
LIMIT 50;

-- Good order: equality (tenant_id, status), then range/sort (created_at)
CREATE INDEX idx_orders_tenant_status_created
  ON orders (tenant_id, status, created_at DESC);

-- Bad order: a leading range column means the equality conditions behind it
-- can no longer narrow the index scan
CREATE INDEX idx_orders_created_tenant_status
  ON orders (created_at DESC, tenant_id, status);

To get the sort itself onto the index, the ORDER BY direction has to be reflected in the index definition. In the example above, defining the index with created_at DESC makes the sort operation disappear entirely. PostgreSQL can read an index backward, so for a single-column sort the direction rarely matters much — but for a composite sort (a ASC, b DESC), the index definition has to match exactly for the sort to be skipped.

Covering Indexes and INCLUDE

An execution style that reads only the index and never visits the table is called an Index Only Scan. It's possible when every column the query needs is already in the index. For columns you need in the result but never search on, INCLUDE is the better place to put them.

CREATE INDEX idx_orders_cover
  ON orders (tenant_id, status)
  INCLUDE (total_amount, created_at);

The PostgreSQL documentation is explicit about INCLUDE-listed non-key columns: they "cannot be used in an index scan search qualification, and they are not considered when checking for uniqueness or exclusion constraints." In exchange, their values can be read straight out of the index entry without a second lookup, which is exactly what makes an Index Only Scan possible in the first place. INCLUDE is only supported on B-tree, GiST, and SP-GiST, and a B-tree index that carries non-key columns has deduplication turned off for the whole index, which is a real trade-off if the key columns themselves are highly repetitive.

One caveat, and it trips people up constantly. For an Index Only Scan to actually skip the table, the visibility map has to mark the relevant page as all-visible, meaning every row on that page is guaranteed visible to every transaction without a further check. On a table that was just updated and hasn't been vacuumed yet, the plan can say Index Only Scan and still report a large Heap Fetches count, because the visibility map hasn't caught up. Always check that number in the plan rather than trusting the scan's name alone.

3. Choosing a Method — Which of the Six

PostgreSQL ships six index methods: btree, hash, gist, spgist, gin, and brin — omit the USING clause and you get B-tree. The decision criteria matter more than the data-structure explanation, so here's a table.

MethodOperators it handlesWhen to pick it
btree<, <=, =, >=, >, BETWEEN, IN, IS NULLThe default. When you need results back in sorted order
hash= onlyEquality lookups only, on very long values
gistgeometric/range type operators, nearest-neighbor searchRange-type exclusion constraints, spatial data
spgistoperators suited to unbalanced partitioned structuresQuad-tree or radix-tree shaped data
ginarray, full-text search, jsonb containment operatorsWhen one row holds many values
brin<, <=, =, >=, > on linearly ordered typesHuge tables where physical order correlates tightly with value order

Two extra B-tree capabilities called out in the documentation are worth remembering, because they come up constantly in practice and people often assume otherwise. First, B-tree handles anchored patterns like LIKE 'foo%', where the wildcard sits at the end. It cannot handle LIKE '%foo', where the wildcard sits at the front, because a leading wildcard breaks the sorted-prefix property the index relies on. Second, only B-tree can hand back data already in sorted order, which is exactly why it is the only method that can make an ORDER BY disappear from a plan.

BRIN is widely misunderstood, and the misunderstanding usually runs in the "too good to be true" direction. Because it stores only a summary — a minimum and a maximum value — per contiguous range of blocks rather than an entry per row, its index size is extremely small, often a tiny fraction of the equivalent B-tree. But as the documentation stresses, it only pays off when the column's values correlate well with physical row order on disk. It is excellent for created_at on a log table that only ever appends new rows in time order, where physical order and value order are nearly the same thing, and it is essentially useless on a status column that gets updated in place in random order, where that correlation never holds.

4. Partial Indexes and Expression Indexes

Two tools for keeping an index small — and, in practice, the most underrated features in the toolbox.

A partial index restricts which rows get indexed with a WHERE clause. If only 1% of all rows are ever queried, the index only needs to cover that 1%.

-- A queue table where only unprocessed jobs matter
CREATE INDEX idx_jobs_pending
  ON jobs (created_at)
  WHERE status = 'PENDING';

-- A uniqueness constraint that excludes soft-deleted rows
CREATE UNIQUE INDEX idx_users_email_active
  ON users (lower(email))
  WHERE deleted_at IS NULL;

The second example combines a partial index with an expression index, and together they form the standard way to express "email must be unique, but only among the still-alive rows" in a schema that uses soft deletes instead of hard deletes. Checking this in application code — a SELECT to confirm nothing already has that email, followed by an INSERT — leaves a concurrency hole that two simultaneous requests can slip through together; a unique index makes the database itself guarantee it atomically, with no window for a race.

An expression index builds the index on the result of an expression instead of on a plain column value. The catch, and it is an unforgiving one, is that the expression written in the query has to match the expression baked into the index exactly, character for character as far as the planner is concerned. Build the index on lower(email) and the query needs to filter on lower(email) = ... for the planner to recognize the match; something that looks equivalent to a human, like email ILIKE ..., cannot use that index at all.

Expression indexes also affect statistics in a way that is easy to miss. PostgreSQL collects separate statistics for expression indexes, so simply creating one has the side effect of making row-count estimates for that expression more accurate, even in plans that end up not using the index itself. If you want the statistics without paying for the index, CREATE STATISTICS can build expression statistics on their own, decoupled from any index.

5. Creation — CONCURRENTLY and Recovering from Failure

This is where the most operational incidents happen.

Warning: a plain CREATE INDEX blocks writes to the target table until it finishes. The PostgreSQL documentation states that a plain CREATE INDEX "locks out writes (but not reads) on the table until it's done." Reads are allowed; writes wait. On a table with hundreds of millions of rows, this command can freeze the service's write path for tens of minutes. Any index built on a live table must use CONCURRENTLY.

-- This is the only form to use against a live table
CREATE INDEX CONCURRENTLY idx_orders_tenant_status_created
  ON orders (tenant_id, status, created_at DESC);

CONCURRENTLY avoids taking the lock that blocks inserts, updates, and deletes, which is the whole point of using it on a table that is serving live traffic. The trade-off is real, though: per the documentation, it performs two full table scans in two separate transactions, and before each scan it waits for every existing transaction that could modify or use the index to finish first. After the second scan, it waits again — this time for every transaction holding a snapshot from before that scan to finish. So a single long-running transaction can stall the entire index build indefinitely, potentially for hours, with no error and no obvious sign of what is blocking it. Make it a habit to check pg_stat_activity for long-running transactions before you kick off an index build, not after you notice it has been running too long.

Also, CREATE INDEX CONCURRENTLY cannot run inside a transaction block, by design — PostgreSQL will simply reject it. If your migration tool wraps every migration in a single transaction, as many do by default, this command will fail there every time. You need to find that specific tool's option for disabling the wrapping transaction for this one migration.

When It Fails

If something goes wrong partway through — a deadlock, a uniqueness violation discovered mid-build, an operator killing the wrong session — the command fails but leaves an invalid index behind rather than cleaning up after itself the way a normal CREATE INDEX would. In the documentation's words, this index "will be ignored for querying purposes because it might be incomplete," yet it keeps paying update overhead on every write forever, exactly as if it were a fully valid index. That is the worst possible state a database object can be in: all of the cost, none of the benefit, and nothing about the schema tells you it happened.

-- Find invalid indexes
SELECT c.relname AS index_name, i.indisvalid, i.indisready
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid;

-- Recovery: drop and rebuild
DROP INDEX CONCURRENTLY idx_orders_tenant_status_created;
-- or reindex in place
REINDEX INDEX CONCURRENTLY idx_orders_tenant_status_created;

Make running that query part of your process before you report an index build as done. Invalid indexes stay behind quietly.

The Exception for Partitioned Tables

The documentation is blunt: "concurrent build is not currently supported for indexes on partitioned tables." There is a recommended workaround. Build the index CONCURRENTLY on each partition individually, and finally build it non-concurrently on the parent partitioned table — this shortens the write-blocking window. When you build the index on the parent, if a correctly named index already exists on each partition, it gets attached rather than rebuilt.

6. Verification — Is It Actually Being Used

There are two levels to confirming an index you built is actually being used.

At the query level, look at EXPLAIN. In PostgreSQL 18, buffer information is included automatically whenever you use ANALYZE. The documentation states it plainly: "Buffers information is automatically included when ANALYZE is used." On 17 and earlier, you had to add BUFFERS explicitly.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total_amount
FROM orders
WHERE tenant_id = 42 AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 50;
 Limit  (cost=0.56..42.18 rows=50 width=20)
        (actual time=0.041..0.180 rows=50 loops=1)
   Buffers: shared hit=54
   ->  Index Only Scan Backward using idx_orders_cover on orders
         (cost=0.56..8321.44 rows=10004 width=20)
         (actual time=0.039..0.171 rows=50 loops=1)
         Index Cond: ((tenant_id = 42) AND (status = 'PAID'::text))
         Heap Fetches: 0
         Buffers: shared hit=54
 Planning Time: 0.212 ms
 Execution Time: 0.221 ms

There are three things to check here: whether the condition landed in Index Cond (if it's sitting in Filter instead, the index did not narrow anything), whether Heap Fetches is zero, and how far the rows estimate is from the actual value.

Warning: EXPLAIN ANALYZE actually executes the statement. As the documentation states, the output of a SELECT is discarded, but every other side effect still happens. When analyzing INSERT, UPDATE, DELETE, or MERGE, wrap it in BEGIN / EXPLAIN ANALYZE ... / ROLLBACK.

At the workload level, look at the statistics views. In pg_stat_user_indexes, idx_scan is the number of scans that started on that index, and last_idx_scan is the timestamp of the most recent one.

SELECT s.schemaname, s.relname, s.indexrelname,
       s.idx_scan, s.last_idx_scan,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE NOT i.indisunique
ORDER BY s.idx_scan ASC, pg_relation_size(s.indexrelid) DESC;

Two things to watch when reading these numbers. First, the statistics are cumulative since the last reset — if you don't know when that was, a zero doesn't tell you anything. Second, an index that only backs a monthly batch job or a quarterly close report will read as zero over a week of observation. Watch for at least one full business cycle.

7. Operations — Handling a Bloated Index

Under PostgreSQL's MVCC, an update never modifies a row in place — it creates a new row version and leaves the old one for VACUUM to reclaim later, and the index has to add a new entry to match that new version. VACUUM cleans up the dead entries once they are no longer visible to any transaction, but the space it reclaims inside an index page doesn't always come back in a form that later insertions can actually reuse efficiently. Over time, an index gradually grows larger than the logical amount of data it represents. That's index bloat, and it is a one-way ratchet unless something actively reverses it.

The symptom is quiet, which is exactly what makes it dangerous. Queries don't suddenly get slow on some obvious date — they get slow gradually, a few milliseconds at a time, until months later someone notices the whole system feels sluggish. Less of the index fits in cache as it bloats, so more of every scan has to go to disk, and scans read more pages than the row count alone would suggest.

The fix is rebuilding the index from scratch, which throws away the bloat and starts clean.

Warning: running REINDEX without CONCURRENTLY takes an ACCESS EXCLUSIVE lock on the target table. That lock conflicts with every other lock mode, so it blocks reads too. VACUUM FULL and CLUSTER carry the same risk and should not be run casually on a live system for the same reason. The PostgreSQL documentation itself recommends that "administrators should try to avoid VACUUM FULL" in favor of routine VACUUM. The safe alternative is REINDEX INDEX CONCURRENTLY. It takes only a SHARE UPDATE EXCLUSIVE lock, so it blocks neither reads nor writes.

-- Safe: blocks neither reads nor writes
REINDEX INDEX CONCURRENTLY idx_orders_tenant_status_created;

-- Rebuild every index on a table concurrently
REINDEX TABLE CONCURRENTLY orders;

REINDEX ... CONCURRENTLY can also leave an invalid index behind on failure. If you see a leftover index with _ccnew appended to its name, clean it up.

When you're deciding on a rebuild cadence, look at the update pattern. A near append-only table barely bloats. A status table or a queue table that repeatedly updates the same rows bloats fast. Pairing a queue table with a partial index sharply cuts its exposure to bloat.

8. Retirement — Dropping an Index Reversibly

An unused index should be dropped, because every index that isn't earning its keep is pure cost with no offsetting benefit. But because "unused" can turn out to be a wrong call — based on incomplete observation, a rare but critical query, or a constraint nobody remembered — the process should stay reversible at every step rather than being a single irreversible command.

Start by finding duplicate indexes, which are often the easiest wins. If both (a) and (a, b) exist on the same table, the first is usually unnecessary, because a B-tree composite index can serve any condition that only touches its leading column just as well as a dedicated single-column index would. The exceptions are when (a) is itself a unique index enforcing a constraint, or when (a) alone — being smaller — is enough to support an Index Only Scan that (a, b) would serve less efficiently.

The retirement process has three steps.

Step 1 — Nominate candidates. Use the query from Section 6 to pull indexes where idx_scan is zero or extremely low. Exclude indexes backing unique constraints and the referencing-side indexes for foreign keys. If a child table with a foreign key has no index, deleting a parent row triggers a full scan.

Step 2 — Test by disabling. Before dropping anything, make the optimizer ignore it first. PostgreSQL has no official command to switch an index off, and while there is a known trick of editing the system catalog directly to mark it invalid, editing the catalog directly is risky. The safer path is turning off enable_indexscan and enable_bitmapscan for a single session and comparing the plan and runtime of your representative queries. Both parameters default to on.

-- Exclude index paths for just this session, to measure the worst case
SET enable_indexscan = off;
SET enable_bitmapscan = off;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
RESET enable_indexscan;
RESET enable_bitmapscan;

Step 3 — Drop it. Drop it concurrently too.

DROP INDEX CONCURRENTLY idx_orders_legacy_status;

And always commit a recreation script alongside the drop. The rollback for dropping an index is recreating it, and recreating it takes time. Trying to reconstruct the definition from memory during an incident is where mistakes happen. Capture the definition ahead of time with pg_get_indexdef().

SELECT indexrelid::regclass AS index_name,
       pg_get_indexdef(indexrelid) AS definition
FROM pg_index
WHERE indrelid = 'orders'::regclass;

Quiz: Test Your Understanding

Quiz 1: What's wrong with the execution plan below?
Index Only Scan using idx_orders_cover on orders
  (actual time=0.05..820.4 rows=48000 loops=1)
  Index Cond: (tenant_id = 42)
  Heap Fetches: 47912
  Buffers: shared hit=1204 read=46180

Answer: Heap Fetches is nearly equal to the number of rows returned. It's labeled Index Only Scan, but in practice it's visiting the table for almost every row.

Explanation: For an Index Only Scan to skip the table, the visibility map has to mark the relevant page as all-visible. If VACUUM hasn't run recently, or the table updates frequently, that condition breaks, and you get the worst-case path: read the index, then go read the table anyway. read=46180 means most of that was a cache miss. The fix is running VACUUM on the table to refresh the visibility map, and checking pg_stat_user_tables.last_autovacuum to confirm autovacuum is running against this table often enough.

Quiz 2: You're adding an index to a live 500-million-row table. You ran CREATE INDEX CONCURRENTLY and it still hasn't finished two hours later. What should you check first?

Answer: Whether a long-running transaction is open.

Explanation: CREATE INDEX CONCURRENTLY performs two table scans, and before and after each one it waits for related transactions to end. In particular, after the second scan it waits for every transaction holding a snapshot older than that scan to finish. So a single analytics query that's been open for hours, or one connection sitting idle in transaction, can hold the index build hostage indefinitely.

SELECT pid, state, now() - xact_start AS xact_age, left(query, 60)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

The durable fix is setting idle_in_transaction_session_timeout. In PostgreSQL 18, this value defaults to 0 (disabled).

Quiz 3: A users table uses soft deletes (deleted_at), and email needs to be unique only among still-alive users. How do you implement that?

Answer: Build a conditional unique index.

CREATE UNIQUE INDEX CONCURRENTLY idx_users_email_alive
  ON users (lower(email))
  WHERE deleted_at IS NULL;

Explanation: If the application does "SELECT first to check, then INSERT if nothing's there," two concurrent requests can both pass the check. A unique index has the database enforce this atomically, which removes the race condition at the source. lower() is there to express case-insensitive email equality, and for the index to be used, the lookup query has to keep lower(email) on the left side the same way. CONCURRENTLY is included because this runs against a live table.

Quiz 4: You found an index with idx_scan at 0. Is it safe to drop right away?

Answer: No. At least three things need checking first.

Explanation: First, check when the statistics were last reset — if pg_stat_reset() ran yesterday, a zero means nothing. Second, confirm the observation window covers a full business cycle; an index that only backs a month-end settlement batch won't show activity if you only watched it for a few weeks mid-month. Third, check whether it's backing a unique constraint or is the referencing-side index for a foreign key. A unique index still enforces its constraint even with zero scans, so dropping it breaks data integrity. A foreign-key child-side index is only used when the parent row is deleted or updated, so its idx_scan runs low in normal times — but dropping it turns a parent deletion into a full scan.

Quiz 5: The two indexes below exist on the same table. If you drop one, which one, and what's the exception?
CREATE INDEX idx_a ON events (tenant_id);
CREATE INDEX idx_b ON events (tenant_id, occurred_at);

Answer: Normally you'd drop idx_a. But there are three exceptions.

Explanation: A composite B-tree index can serve any condition that only touches its leading column, so idx_b absorbs most of what idx_a does. The exceptions: first, if idx_a is a unique index, it's enforcing a constraint and can't be dropped. Second, if queries that only read tenant_id are extremely frequent, the smaller idx_a may win on cache efficiency — the smaller the index, the more of it fits in the buffer cache. Third, if idx_a is a partial index, its row set is different, so the substitution doesn't hold. When you can't tell, follow the three-step process from Section 8: keep a recreation script, drop it with DROP INDEX CONCURRENTLY, and watch the metrics afterward.

Closing

Most index problems come not from a lack of knowledge but from a lack of process. Plenty of engineers know what a B-tree is and can draw one on a whiteboard; far fewer teams have an actual written process for using CONCURRENTLY when building an index on a live table, checking for long-running transactions before they start, and querying for invalid indexes afterward as a routine last step rather than something they only think to do after an incident.

Three rules alone, made into team policy and enforced in code review, eliminate most of these incidents. First: creating, dropping, or rebuilding an index on any live table always uses CONCURRENTLY, with no exceptions carved out for "just this once." Second: whenever you create an index, record why you built it and which query it exists to serve, right there in the migration file's comments, so the next person doesn't have to guess. Third: query idx_scan on a fixed cadence — once a quarter is enough for most teams — and actually review the retirement candidates it surfaces instead of letting the list grow forever.

You can run every piece of SQL in this piece yourself in this site's Postgres Playground.

Sources

Further Reading