Split View: 데이터베이스: PostgreSQL 18 업그레이드 및 튜닝 2026
데이터베이스: PostgreSQL 18 업그레이드 및 튜닝 2026

- PostgreSQL 18이 바꾸는 것들
- 업그레이드 전 호환성 점검
- pg_upgrade 실전 절차
- PG 18 신규 기능 활용: Virtual Generated Columns
- PG 18 신규 기능 활용: UUIDv7
- PG 18 신규 기능 활용: RETURNING 절의 OLD/NEW
- PG 18 성능 튜닝: AIO 서브시스템 설정
- 업그레이드 트러블슈팅
- 업그레이드 체크리스트
- 퀴즈
- References
PostgreSQL 18이 바꾸는 것들
PostgreSQL 18은 2025년 9월 25일에 정식 릴리스되었다. 이 버전에서 가장 주목할 변경은 세 가지다.
첫째, 비동기 I/O(AIO) 서브시스템이 도입되었다. PostgreSQL이 전통적으로 사용하던 동기 I/O 모델을 벗어나, 스토리지 읽기에서 최대 3배의 성능 향상을 보인다. 이 변경은 PG 17의 Read Stream API 위에 구축되었으며, 향후 버전에서 더 확장될 아키텍처적 전환이다.
둘째, pg_upgrade 시 planner 통계가 보존된다. PG 17까지는 메이저 업그레이드 후 반드시 ANALYZE를 전체 데이터베이스에 대해 실행해야 했다. 대규모 DB에서 이 작업이 수 시간 걸리는 경우도 있었다. PG 18에서는 업그레이드 직후부터 기존 통계를 사용하여 최적 쿼리 플랜을 선택할 수 있다.
셋째, Virtual Generated Columns이 기본값이 되었다. STORED generated column과 달리 디스크 공간을 사용하지 않고 조회 시점에 값을 계산한다.
업그레이드 전 호환성 점검
업그레이드를 시작하기 전에 반드시 확인해야 할 비호환 변경사항이 있다.
MD5 인증 폐기 경고
PG 18에서 MD5 비밀번호 인증이 deprecated 되었다. 기존에 MD5를 사용하던 환경은 SCRAM-SHA-256으로 마이그레이션해야 한다.
-- 현재 인증 방식 확인
SELECT usename, passwd IS NOT NULL AS has_password,
CASE
WHEN passwd LIKE 'md5%' THEN 'MD5'
WHEN passwd LIKE 'SCRAM-SHA-256$%' THEN 'SCRAM-SHA-256'
ELSE 'unknown'
END AS auth_method
FROM pg_shadow
WHERE passwd IS NOT NULL;
-- SCRAM-SHA-256으로 마이그레이션
SET password_encryption = 'scram-sha-256';
ALTER USER myapp_user PASSWORD 'new_secure_password';
-- pg_hba.conf에서 md5 -> scram-sha-256 변경
-- host all all 10.0.0.0/8 scram-sha-256
확장 호환성 확인
# 현재 설치된 확장 목록과 PG 18 호환 여부 확인
psql -U postgres -d mydb -c "
SELECT e.extname, e.extversion,
a.default_version AS available_version
FROM pg_extension e
LEFT JOIN pg_available_extensions a ON a.name = e.extname
ORDER BY e.extname;
"
# 주요 확장 호환성 (2026년 3월 기준)
# pg_stat_statements: 1.11 -> 호환
# PostGIS: 3.5+ -> 호환
# pgvector: 0.8+ -> 호환
# pg_cron: 1.6+ -> 호환
# hypopg: 1.4+ -> 호환
# timescaledb: 2.17+ -> PG 18 지원 확인 필요
pg_upgrade 실전 절차
1단계: 업그레이드 환경 준비
# PG 18 바이너리 설치 (Ubuntu/Debian)
sudo apt-get install postgresql-18
# 디렉토리 구조 확인
# /usr/lib/postgresql/17/bin/ <- 기존 PG 17
# /usr/lib/postgresql/18/bin/ <- 신규 PG 18
# /var/lib/postgresql/17/main/ <- 기존 데이터
# /var/lib/postgresql/18/main/ <- 신규 데이터 (pg_upgrade가 생성)
# PG 17 정상 종료
sudo systemctl stop postgresql@17-main
# 신규 PG 18 클러스터 초기화
sudo -u postgres /usr/lib/postgresql/18/bin/initdb \
-D /var/lib/postgresql/18/main \
--encoding=UTF8 \
--locale=ko_KR.UTF-8 \
--data-checksums
2단계: 호환성 사전 검증 (--check)
# 반드시 --check 모드로 먼저 실행
sudo -u postgres /usr/lib/postgresql/18/bin/pg_upgrade \
--old-datadir=/var/lib/postgresql/17/main \
--new-datadir=/var/lib/postgresql/18/main \
--old-bindir=/usr/lib/postgresql/17/bin \
--new-bindir=/usr/lib/postgresql/18/bin \
--check
# 정상 출력 예시:
# Performing Consistency Checks
# -----------------------------
# Checking cluster versions ok
# Checking database user is the install user ok
# Checking database connection settings ok
# Checking for prepared transactions ok
# Checking for system-defined composite types ok
# Checking for reg* data types in user tables ok
# Checking for contrib/isn with bigint-passing mismatch ok
# Checking for user-defined encoding conversions ok
# Checking for user-defined postfix operators ok
# Checking for incompatible polymorphic functions ok
# *Clusters are compatible*
3단계: 실제 업그레이드 실행
# --link 옵션으로 하드링크 사용 (데이터 복사 없이 수초 내 완료)
# 주의: --link 사용 시 이전 클러스터로 롤백 불가
sudo -u postgres /usr/lib/postgresql/18/bin/pg_upgrade \
--old-datadir=/var/lib/postgresql/17/main \
--new-datadir=/var/lib/postgresql/18/main \
--old-bindir=/usr/lib/postgresql/17/bin \
--new-bindir=/usr/lib/postgresql/18/bin \
--link \
--jobs=4
# 출력 예시:
# Performing Upgrade
# ------------------
# ...
# Setting next OID for new cluster ok
# Sync data directory to disk ok
# Creating script to delete old cluster ok
# Checking for extension updates ok
#
# Upgrade Complete
# ----------------
# Optimizer statistics are carried over. <- PG 18 신기능!
PG 18의 핵심 개선: "Optimizer statistics are carried over" 메시지가 표시된다. 이전 버전에서는 이 단계 후 반드시 vacuumdb --analyze-in-stages --all을 실행해야 했지만, PG 18에서는 선택사항이다.
4단계: 업그레이드 후 검증
# PG 18 시작
sudo systemctl start postgresql@18-main
# 버전 확인
psql -U postgres -c "SELECT version();"
# PostgreSQL 18.0 on x86_64-pc-linux-gnu, compiled by gcc ...
# 확장 업데이트
psql -U postgres -d mydb -c "ALTER EXTENSION pg_stat_statements UPDATE;"
psql -U postgres -d mydb -c "ALTER EXTENSION postgis UPDATE;"
# 통계 상태 확인 (PG 18: 이미 존재해야 함)
psql -U postgres -d mydb -c "
SELECT schemaname, relname, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC
LIMIT 10;
"
PG 18 신규 기능 활용: Virtual Generated Columns
PG 18에서 generated column의 기본 저장 방식이 VIRTUAL로 변경되었다. 디스크를 사용하지 않고 조회 시 계산한다.
-- PG 18: VIRTUAL이 기본값 (STORED는 명시 필요)
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
base_price numeric(10,2) NOT NULL,
tax_rate numeric(4,3) NOT NULL DEFAULT 0.1,
-- VIRTUAL generated column: 디스크 공간 0, 조회 시 계산
total_price numeric(10,2) GENERATED ALWAYS AS (
base_price * (1 + tax_rate)
) VIRTUAL,
-- STORED generated column: 디스크 저장, INSERT/UPDATE 시 계산
search_vector tsvector GENERATED ALWAYS AS (
to_tsvector('korean', name)
) STORED
);
-- VIRTUAL vs STORED 비교
-- VIRTUAL: 디스크 0, 읽기 시 CPU 사용, 인덱스 생성 불가
-- STORED: 디스크 사용, 읽기 시 즉시 반환, 인덱스 생성 가능
-- VIRTUAL column에는 직접 인덱스를 만들 수 없으므로
-- 인덱스가 필요한 경우 expression index 사용
CREATE INDEX idx_products_total ON products ((base_price * (1 + tax_rate)));
PG 18 신규 기능 활용: UUIDv7
UUIDv7은 시간 기반 정렬이 가능한 UUID 형식이다. 기존 UUIDv4의 랜덤성으로 인한 B-tree 인덱스 성능 저하 문제를 해결한다.
-- PG 18: 네이티브 UUIDv7 생성
SELECT uuidv7();
-- 결과: 019576a0-6c00-7def-8000-1a2b3c4d5e6f
-- 앞 48비트가 Unix timestamp (밀리초)
-- UUIDv7을 Primary Key로 사용
CREATE TABLE distributed_events (
id uuid DEFAULT uuidv7() PRIMARY KEY,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- UUIDv7 vs UUIDv4 인덱스 성능 비교 실험
-- 100만 건 INSERT 후 인덱스 크기와 페이지 분할 비교
CREATE TABLE bench_uuid4 (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, val int);
CREATE TABLE bench_uuid7 (id uuid DEFAULT uuidv7() PRIMARY KEY, val int);
INSERT INTO bench_uuid4 (val) SELECT i FROM generate_series(1, 1000000) i;
INSERT INTO bench_uuid7 (val) SELECT i FROM generate_series(1, 1000000) i;
SELECT
relname,
pg_size_pretty(pg_relation_size(oid)) AS table_size,
pg_size_pretty(pg_indexes_size(oid)) AS index_size
FROM pg_class
WHERE relname IN ('bench_uuid4', 'bench_uuid7');
-- 예상 결과:
-- bench_uuid4 | table: 42MB | index: 34MB (랜덤 분산으로 페이지 분할 많음)
-- bench_uuid7 | table: 42MB | index: 21MB (순차 삽입으로 효율적 패킹)
PG 18 신규 기능 활용: RETURNING 절의 OLD/NEW
INSERT, UPDATE, DELETE, MERGE에서 OLD와 NEW 테이블을 RETURNING 절에 사용할 수 있다.
-- UPDATE에서 변경 전/후 값을 함께 조회
UPDATE products
SET base_price = base_price * 1.05 -- 5% 인상
WHERE id BETWEEN 1 AND 100
RETURNING
old.id,
old.base_price AS old_price,
new.base_price AS new_price,
new.base_price - old.base_price AS price_diff;
-- 감사 로그 테이블 자동 생성 패턴
CREATE TABLE price_audit_log (
id bigint GENERATED ALWAYS AS IDENTITY,
product_id bigint,
old_price numeric(10,2),
new_price numeric(10,2),
changed_at timestamptz DEFAULT now()
);
-- UPDATE + RETURNING + INSERT를 CTE로 결합
WITH price_changes AS (
UPDATE products
SET base_price = base_price * 0.9 -- 10% 할인
WHERE tax_rate > 0.15
RETURNING old.id, old.base_price AS old_price, new.base_price AS new_price
)
INSERT INTO price_audit_log (product_id, old_price, new_price)
SELECT id, old_price, new_price FROM price_changes;
PG 18 성능 튜닝: AIO 서브시스템 설정
비동기 I/O 서브시스템은 PG 18의 가장 큰 아키텍처 변경이다. 기존의 동기 I/O에서 벗어나 커널의 io_uring(Linux) 또는 posix_aio를 활용한다.
-- AIO 관련 설정 확인
SHOW io_method; -- 'io_uring' 또는 'worker' 또는 'sync'
SHOW io_max_concurrency; -- 동시 비동기 I/O 요청 수
-- NVMe SSD 환경 권장 설정
ALTER SYSTEM SET io_method = 'io_uring'; -- Linux 5.1+ 필요
ALTER SYSTEM SET io_max_concurrency = 256;
ALTER SYSTEM SET effective_io_concurrency = 256; -- AIO와 연동
SELECT pg_reload_conf();
AIO 효과 벤치마크 (1억 건 테이블 기준):
| 작업 | sync I/O (PG 17) | AIO worker (PG 18) | AIO io_uring (PG 18) |
|---|---|---|---|
| Full table scan | 12.4s | 6.8s | 4.2s |
| Parallel seq scan (4 workers) | 4.1s | 2.3s | 1.5s |
| VACUUM (200만 dead tuples) | 9.1s | 5.4s | 3.8s |
| CREATE INDEX | 45s | 28s | 22s |
io_uring이 가장 빠르지만, Linux 커널 5.1 이상에서만 사용 가능하다. 컨테이너 환경에서는 커널 버전과 seccomp 프로필을 확인해야 한다.
업그레이드 트러블슈팅
시나리오 1: pg_upgrade --check 실패 - reg* 타입
Checking for reg* data types in user tables FAILED
Your installation contains one of the reg* data types in user tables.
These data types reference system OIDs that are not preserved by
pg_upgrade.
-- 문제가 되는 컬럼 찾기
SELECT n.nspname, c.relname, a.attname, t.typname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
JOIN pg_type t ON t.oid = a.atttypid
WHERE t.typname IN ('regproc','regprocedure','regoper','regoperator',
'regclass','regtype','regconfig','regdictionary')
AND c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog','information_schema');
-- 해결: 해당 컬럼을 text로 변환
ALTER TABLE my_table ALTER COLUMN proc_col TYPE text;
시나리오 2: --link 모드에서 남은 이전 클러스터 처리
# pg_upgrade가 생성하는 삭제 스크립트 확인
cat ./delete_old_cluster.sh
# 내용 예시:
#!/bin/sh
rm -rf '/var/lib/postgresql/17/main'
# 주의: --link 사용 후에는 이전 클러스터를 절대 시작하면 안 된다
# 하드링크로 데이터 파일을 공유하므로 양쪽에서 동시 접근하면 손상됨
시나리오 3: 확장이 PG 18을 지원하지 않는 경우
# 업그레이드 전 확장 제거, 업그레이드 후 재설치
psql -U postgres -d mydb -c "DROP EXTENSION IF EXISTS old_extension CASCADE;"
# pg_upgrade 실행
# 업그레이드 후 최신 버전 확장 설치
psql -U postgres -d mydb -c "CREATE EXTENSION old_extension VERSION '2.0';"
시나리오 4: 업그레이드 후 쿼리 성능 저하 (PG 18에서는 드묾)
PG 18에서는 통계가 보존되므로 이 문제가 대폭 줄었지만, planner cost model 변경으로 인한 플랜 변경은 여전히 가능하다.
-- 업그레이드 전에 핵심 쿼리 플랜 스냅샷 저장
\o /tmp/critical_query_plans_pg17.txt
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
-- 핵심 쿼리 1
SELECT ...;
-- 핵심 쿼리 2
SELECT ...;
\o
-- 업그레이드 후 동일 쿼리 플랜 비교
\o /tmp/critical_query_plans_pg18.txt
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
-- 동일 쿼리 실행
\o
-- 플랜이 달라진 경우 cost 파라미터 조정
-- PG 18에서 변경된 기본값 확인
SHOW random_page_cost; -- AIO 도입으로 변경되었을 수 있음
SHOW cpu_tuple_cost;
SHOW parallel_tuple_cost;
업그레이드 체크리스트
- PG 18 릴리스 노트에서 비호환 변경사항 읽기 완료
- 모든 확장의 PG 18 호환 버전 확인
- MD5 인증 사용자 -> SCRAM-SHA-256 마이그레이션
- pg_hba.conf에서 인증 방식 변경
- 스테이징에서 pg_upgrade --check 실행 성공
- 스테이징에서 실제 업그레이드 리허설 완료
- 핵심 쿼리 실행 계획 스냅샷 (업그레이드 전)
- 애플리케이션 커넥션 풀 설정 호환성 확인
- 백업 완료 (pg_basebackup 또는 pg_dump)
- --link vs --copy 결정 (롤백 필요 여부)
- 업그레이드 윈도우 시간 산정 (데이터 크기별)
- 업그레이드 후 모니터링 대시보드 확인 항목 정리
- 롤백 계획 문서화 (--link 사용 시 백업 복원 필요)
퀴즈
Q1. PG 18에서 pg_upgrade 시 통계가 보존되는 것의 실무적 의미는?
정답: ||업그레이드 직후 vacuumdb --analyze-in-stages를 실행하지 않아도 planner가 기존 통계를
사용하여 최적 쿼리 플랜을 선택할 수 있다. 대규모 DB에서 수 시간이 걸리던 post-upgrade ANALYZE가
불필요해진다.||
Q2. VIRTUAL generated column과 STORED generated column의 차이점과 각각의 적합한 사용 사례는?
정답: ||VIRTUAL은 디스크를 사용하지 않고 조회 시 계산하므로 단순 연산(가격 계산 등)에 적합하다. STORED는 디스크에 저장하므로 계산 비용이 높은 값(tsvector 등)이나 인덱스가 필요한 경우에 적합하다. PG 18에서 VIRTUAL이 기본값이다.||
Q3. UUIDv7이 UUIDv4 대비 B-tree 인덱스에서 유리한 이유는?
정답: ||UUIDv7은 상위 48비트가 Unix timestamp이므로 시간 순으로 정렬된다. B-tree에 삽입할 때 항상
우측 leaf page에 추가되므로 page split이 발생하지 않고, 인덱스가 콤팩트하게 유지된다. UUIDv4는
랜덤이므로 전체 인덱스에 분산 삽입되어 page split과 bloat가 발생한다.||
Q4. pg_upgrade --link 옵션 사용 후 주의사항은?
정답: ||--link는 하드링크를 사용하므로 이전 클러스터와 새 클러스터가 데이터 파일을 공유한다.
업그레이드 후 이전 클러스터를 절대 시작하면 안 되며, 롤백이 필요한 경우 백업에서 복원해야 한다.
--copy와 달리 업그레이드 속도는 빠르지만 안전한 롤백 경로가 없다.||
Q5. PG 18의 AIO 서브시스템에서 io_uring과 worker 모드의 차이는?
정답: ||io_uring은 Linux 커널의 비동기 I/O 인터페이스를 직접 사용하여 시스템콜 오버헤드가
최소화된다. worker 모드는 별도 프로세스가 I/O를 대행하는 방식으로 호환성은 높지만 프로세스 간 통신
비용이 추가된다. NVMe SSD + Linux 5.1+ 환경에서는 io_uring이 최적이다.||
Q6. RETURNING 절에서 OLD와 NEW를 사용하는 패턴의 실무적 활용 예시는?
정답: ||UPDATE/DELETE 시 변경 전 값(OLD)과 변경 후 값(NEW)을 동시에 반환하여 감사 로그를 CTE로
한번에 기록할 수 있다. 기존에는 트리거나 2번의 쿼리가 필요했던 패턴을 단일 SQL 문으로 처리할 수
있다.||
Q7. MD5 인증에서 SCRAM-SHA-256으로 마이그레이션할 때 주의할 점은?
정답: ||password_encryption을 scram-sha-256으로 변경한 뒤 ALTER USER로 비밀번호를 재설정해야 한다. pg_hba.conf의 인증 방식도 변경해야 하며, 애플리케이션의 DB 드라이버가 SCRAM-SHA-256을 지원하는지 확인해야 한다. 구형 드라이버(libpq < 10)는 SCRAM을 지원하지 않는다.||
References
PostgreSQL 18 Upgrade and Tuning Guide 2026

- What PostgreSQL 18 Changes
- Pre-Upgrade Compatibility Check
- pg_upgrade Practical Procedure
- Leveraging PG 18 New Features: Virtual Generated Columns
- Leveraging PG 18 New Features: UUIDv7
- Leveraging PG 18 New Features: OLD/NEW in RETURNING Clause
- PG 18 Performance Tuning: AIO Subsystem Configuration
- Upgrade Troubleshooting
- Upgrade Checklist
- Quiz
- References
What PostgreSQL 18 Changes
PostgreSQL 18 was officially released on September 25, 2025. The three most notable changes in this version are as follows.
First, the Asynchronous I/O (AIO) subsystem has been introduced. Moving away from PostgreSQL's traditional synchronous I/O model, it delivers up to 3x performance improvement in storage reads. This change is built on top of PG 17's Read Stream API and represents an architectural shift that will be further expanded in future versions.
Second, planner statistics are now preserved during pg_upgrade. Until PG 17, you had to run ANALYZE across the entire database after every major upgrade. For large databases, this operation could take several hours. With PG 18, the existing statistics can be used immediately after the upgrade to select optimal query plans.
Third, Virtual Generated Columns have become the default. Unlike STORED generated columns, they do not use disk space and compute values at query time.
Pre-Upgrade Compatibility Check
There are incompatible changes that must be verified before starting the upgrade.
MD5 Authentication Deprecation Warning
MD5 password authentication has been deprecated in PG 18. Environments that previously used MD5 must migrate to SCRAM-SHA-256.
-- Check current authentication methods
SELECT usename, passwd IS NOT NULL AS has_password,
CASE
WHEN passwd LIKE 'md5%' THEN 'MD5'
WHEN passwd LIKE 'SCRAM-SHA-256$%' THEN 'SCRAM-SHA-256'
ELSE 'unknown'
END AS auth_method
FROM pg_shadow
WHERE passwd IS NOT NULL;
-- Migrate to SCRAM-SHA-256
SET password_encryption = 'scram-sha-256';
ALTER USER myapp_user PASSWORD 'new_secure_password';
-- Change md5 -> scram-sha-256 in pg_hba.conf
-- host all all 10.0.0.0/8 scram-sha-256
Extension Compatibility Check
# Check installed extensions and PG 18 compatibility
psql -U postgres -d mydb -c "
SELECT e.extname, e.extversion,
a.default_version AS available_version
FROM pg_extension e
LEFT JOIN pg_available_extensions a ON a.name = e.extname
ORDER BY e.extname;
"
# Key extension compatibility (as of March 2026)
# pg_stat_statements: 1.11 -> compatible
# PostGIS: 3.5+ -> compatible
# pgvector: 0.8+ -> compatible
# pg_cron: 1.6+ -> compatible
# hypopg: 1.4+ -> compatible
# timescaledb: 2.17+ -> PG 18 support needs verification
pg_upgrade Practical Procedure
Step 1: Prepare the Upgrade Environment
# Install PG 18 binaries (Ubuntu/Debian)
sudo apt-get install postgresql-18
# Directory structure
# /usr/lib/postgresql/17/bin/ <- existing PG 17
# /usr/lib/postgresql/18/bin/ <- new PG 18
# /var/lib/postgresql/17/main/ <- existing data
# /var/lib/postgresql/18/main/ <- new data (created by pg_upgrade)
# Gracefully stop PG 17
sudo systemctl stop postgresql@17-main
# Initialize new PG 18 cluster
sudo -u postgres /usr/lib/postgresql/18/bin/initdb \
-D /var/lib/postgresql/18/main \
--encoding=UTF8 \
--locale=ko_KR.UTF-8 \
--data-checksums
Step 2: Pre-Upgrade Compatibility Verification (--check)
# Always run in --check mode first
sudo -u postgres /usr/lib/postgresql/18/bin/pg_upgrade \
--old-datadir=/var/lib/postgresql/17/main \
--new-datadir=/var/lib/postgresql/18/main \
--old-bindir=/usr/lib/postgresql/17/bin \
--new-bindir=/usr/lib/postgresql/18/bin \
--check
# Expected successful output:
# Performing Consistency Checks
# -----------------------------
# Checking cluster versions ok
# Checking database user is the install user ok
# Checking database connection settings ok
# Checking for prepared transactions ok
# Checking for system-defined composite types ok
# Checking for reg* data types in user tables ok
# Checking for contrib/isn with bigint-passing mismatch ok
# Checking for user-defined encoding conversions ok
# Checking for user-defined postfix operators ok
# Checking for incompatible polymorphic functions ok
# *Clusters are compatible*
Step 3: Execute the Actual Upgrade
# Use --link option for hard links (completes in seconds without data copying)
# Note: rollback to the previous cluster is not possible when using --link
sudo -u postgres /usr/lib/postgresql/18/bin/pg_upgrade \
--old-datadir=/var/lib/postgresql/17/main \
--new-datadir=/var/lib/postgresql/18/main \
--old-bindir=/usr/lib/postgresql/17/bin \
--new-bindir=/usr/lib/postgresql/18/bin \
--link \
--jobs=4
# Output example:
# Performing Upgrade
# ------------------
# ...
# Setting next OID for new cluster ok
# Sync data directory to disk ok
# Creating script to delete old cluster ok
# Checking for extension updates ok
#
# Upgrade Complete
# ----------------
# Optimizer statistics are carried over. <- PG 18 new feature!
Key improvement in PG 18: The message "Optimizer statistics are carried over" is displayed. In previous versions, you had to run vacuumdb --analyze-in-stages --all after this step, but in PG 18 it is optional.
Step 4: Post-Upgrade Verification
# Start PG 18
sudo systemctl start postgresql@18-main
# Verify version
psql -U postgres -c "SELECT version();"
# PostgreSQL 18.0 on x86_64-pc-linux-gnu, compiled by gcc ...
# Update extensions
psql -U postgres -d mydb -c "ALTER EXTENSION pg_stat_statements UPDATE;"
psql -U postgres -d mydb -c "ALTER EXTENSION postgis UPDATE;"
# Verify statistics status (PG 18: should already exist)
psql -U postgres -d mydb -c "
SELECT schemaname, relname, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC
LIMIT 10;
"
Leveraging PG 18 New Features: Virtual Generated Columns
In PG 18, the default storage mode for generated columns has changed to VIRTUAL. Values are computed at query time without using disk space.
-- PG 18: VIRTUAL is the default (STORED must be explicitly specified)
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
base_price numeric(10,2) NOT NULL,
tax_rate numeric(4,3) NOT NULL DEFAULT 0.1,
-- VIRTUAL generated column: 0 disk space, computed at query time
total_price numeric(10,2) GENERATED ALWAYS AS (
base_price * (1 + tax_rate)
) VIRTUAL,
-- STORED generated column: stored on disk, computed at INSERT/UPDATE
search_vector tsvector GENERATED ALWAYS AS (
to_tsvector('korean', name)
) STORED
);
-- VIRTUAL vs STORED comparison
-- VIRTUAL: 0 disk usage, CPU used on read, cannot create index directly
-- STORED: uses disk, returns immediately on read, index creation possible
-- Since indexes cannot be created directly on VIRTUAL columns,
-- use expression indexes when indexing is needed
CREATE INDEX idx_products_total ON products ((base_price * (1 + tax_rate)));
Leveraging PG 18 New Features: UUIDv7
UUIDv7 is a UUID format that supports time-based sorting. It solves the B-tree index performance degradation caused by the randomness of existing UUIDv4.
-- PG 18: Native UUIDv7 generation
SELECT uuidv7();
-- Result: 019576a0-6c00-7def-8000-1a2b3c4d5e6f
-- First 48 bits are Unix timestamp (milliseconds)
-- Using UUIDv7 as Primary Key
CREATE TABLE distributed_events (
id uuid DEFAULT uuidv7() PRIMARY KEY,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- UUIDv7 vs UUIDv4 index performance comparison experiment
-- Compare index size and page splits after 1 million INSERTs
CREATE TABLE bench_uuid4 (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, val int);
CREATE TABLE bench_uuid7 (id uuid DEFAULT uuidv7() PRIMARY KEY, val int);
INSERT INTO bench_uuid4 (val) SELECT i FROM generate_series(1, 1000000) i;
INSERT INTO bench_uuid7 (val) SELECT i FROM generate_series(1, 1000000) i;
SELECT
relname,
pg_size_pretty(pg_relation_size(oid)) AS table_size,
pg_size_pretty(pg_indexes_size(oid)) AS index_size
FROM pg_class
WHERE relname IN ('bench_uuid4', 'bench_uuid7');
-- Expected results:
-- bench_uuid4 | table: 42MB | index: 34MB (many page splits due to random distribution)
-- bench_uuid7 | table: 42MB | index: 21MB (efficient packing due to sequential insertion)
Leveraging PG 18 New Features: OLD/NEW in RETURNING Clause
The OLD and NEW tables can be used in the RETURNING clause of INSERT, UPDATE, DELETE, and MERGE statements.
-- Retrieve both before and after values in UPDATE
UPDATE products
SET base_price = base_price * 1.05 -- 5% increase
WHERE id BETWEEN 1 AND 100
RETURNING
old.id,
old.base_price AS old_price,
new.base_price AS new_price,
new.base_price - old.base_price AS price_diff;
-- Automatic audit log table pattern
CREATE TABLE price_audit_log (
id bigint GENERATED ALWAYS AS IDENTITY,
product_id bigint,
old_price numeric(10,2),
new_price numeric(10,2),
changed_at timestamptz DEFAULT now()
);
-- Combine UPDATE + RETURNING + INSERT with CTE
WITH price_changes AS (
UPDATE products
SET base_price = base_price * 0.9 -- 10% discount
WHERE tax_rate > 0.15
RETURNING old.id, old.base_price AS old_price, new.base_price AS new_price
)
INSERT INTO price_audit_log (product_id, old_price, new_price)
SELECT id, old_price, new_price FROM price_changes;
PG 18 Performance Tuning: AIO Subsystem Configuration
The asynchronous I/O subsystem is the biggest architectural change in PG 18. It moves away from traditional synchronous I/O to leverage the kernel's io_uring (Linux) or posix_aio.
-- Check AIO-related settings
SHOW io_method; -- 'io_uring' or 'worker' or 'sync'
SHOW io_max_concurrency; -- Number of concurrent async I/O requests
-- Recommended settings for NVMe SSD environments
ALTER SYSTEM SET io_method = 'io_uring'; -- Requires Linux 5.1+
ALTER SYSTEM SET io_max_concurrency = 256;
ALTER SYSTEM SET effective_io_concurrency = 256; -- Works with AIO
SELECT pg_reload_conf();
AIO benchmark results (based on a 100 million row table):
| Operation | sync I/O (PG 17) | AIO worker (PG 18) | AIO io_uring (PG 18) |
|---|---|---|---|
| Full table scan | 12.4s | 6.8s | 4.2s |
| Parallel seq scan (4 workers) | 4.1s | 2.3s | 1.5s |
| VACUUM (2M dead tuples) | 9.1s | 5.4s | 3.8s |
| CREATE INDEX | 45s | 28s | 22s |
io_uring is the fastest but is only available on Linux kernel 5.1 and above. In containerized environments, you need to check the kernel version and seccomp profile.
Upgrade Troubleshooting
Scenario 1: pg_upgrade --check Failure - reg* Types
Checking for reg* data types in user tables FAILED
Your installation contains one of the reg* data types in user tables.
These data types reference system OIDs that are not preserved by
pg_upgrade.
-- Find the problematic columns
SELECT n.nspname, c.relname, a.attname, t.typname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
JOIN pg_type t ON t.oid = a.atttypid
WHERE t.typname IN ('regproc','regprocedure','regoper','regoperator',
'regclass','regtype','regconfig','regdictionary')
AND c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog','information_schema');
-- Solution: Convert the column to text
ALTER TABLE my_table ALTER COLUMN proc_col TYPE text;
Scenario 2: Handling the Old Cluster After --link Mode
# Check the deletion script generated by pg_upgrade
cat ./delete_old_cluster.sh
# Example content:
#!/bin/sh
rm -rf '/var/lib/postgresql/17/main'
# Warning: Never start the old cluster after using --link
# Since data files are shared via hard links, concurrent access from both sides causes corruption
Scenario 3: Extension Does Not Support PG 18
# Remove extension before upgrade, reinstall after upgrade
psql -U postgres -d mydb -c "DROP EXTENSION IF EXISTS old_extension CASCADE;"
# Run pg_upgrade
# Install latest version of extension after upgrade
psql -U postgres -d mydb -c "CREATE EXTENSION old_extension VERSION '2.0';"
Scenario 4: Query Performance Degradation After Upgrade (Rare in PG 18)
Since PG 18 preserves statistics, this issue has been greatly reduced. However, plan changes due to planner cost model modifications are still possible.
-- Save critical query plan snapshots before upgrade
\o /tmp/critical_query_plans_pg17.txt
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
-- Critical query 1
SELECT ...;
-- Critical query 2
SELECT ...;
\o
-- Compare with the same query plans after upgrade
\o /tmp/critical_query_plans_pg18.txt
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
-- Run the same queries
\o
-- Adjust cost parameters if plans have changed
-- Check default values changed in PG 18
SHOW random_page_cost; -- May have changed with AIO introduction
SHOW cpu_tuple_cost;
SHOW parallel_tuple_cost;
Upgrade Checklist
- Finished reading incompatible changes in PG 18 release notes
- Verified PG 18 compatible versions for all extensions
- Migrated MD5 authentication users to SCRAM-SHA-256
- Changed authentication method in pg_hba.conf
- Successfully ran pg_upgrade --check on staging
- Completed actual upgrade rehearsal on staging
- Captured critical query execution plan snapshots (before upgrade)
- Verified application connection pool configuration compatibility
- Completed backup (pg_basebackup or pg_dump)
- Decided between --link vs --copy (based on rollback requirements)
- Estimated upgrade window time (by data size)
- Prepared monitoring dashboard check items after upgrade
- Documented rollback plan (backup restoration required when using --link)
Quiz
Q1. What is the practical significance of statistics being preserved during pg_upgrade in PG 18?
Answer: The planner can use existing statistics to select optimal query plans immediately after upgrade without running vacuumdb --analyze-in-stages. The post-upgrade ANALYZE that used to take several hours on large databases is no longer necessary.
Q2. What are the differences between VIRTUAL and STORED generated columns, and what are the
appropriate use cases for each?
Answer: VIRTUAL does not use disk space and computes values at query time, making it suitable for simple calculations (such as price computation). STORED saves to disk, making it suitable for computationally expensive values (such as tsvector) or when indexes are needed. VIRTUAL is the default in PG 18.
Q3. Why is UUIDv7 advantageous over UUIDv4 for B-tree indexes?
Answer: UUIDv7 has the upper 48 bits as a Unix timestamp, so it is sorted chronologically. When
inserting into a B-tree, it always appends to the rightmost leaf page, preventing page splits and
keeping the index compact. UUIDv4 is random, so insertions are distributed across the entire
index, causing page splits and bloat.
Q4. What precautions should be taken after using the pg_upgrade --link option?
Answer: --link uses hard links, so the old cluster and new cluster share data files. After the
upgrade, the old cluster must never be started. If rollback is needed, restoration from backup is
required. Unlike --copy, the upgrade speed is fast, but there is no safe rollback path.
Q5. What is the difference between io_uring and worker modes in PG 18's AIO subsystem?
Answer: io_uring directly uses the Linux kernel's asynchronous I/O interface, minimizing system call overhead. Worker mode uses separate processes to handle I/O, offering higher compatibility but adding inter-process communication costs. io_uring is optimal in NVMe SSD + Linux 5.1+ environments.
Q6. What is a practical use case for the pattern of using OLD and NEW in the RETURNING clause?
Answer: During UPDATE/DELETE, the pre-change value (OLD) and post-change value (NEW) can be returned simultaneously, allowing audit logs to be recorded in a single CTE. Patterns that previously required triggers or two separate queries can now be handled in a single SQL statement.
Q7. What should you watch out for when migrating from MD5 authentication to SCRAM-SHA-256?
Answer: After changing password_encryption to scram-sha-256, passwords must be reset using ALTER USER. The authentication method in pg_hba.conf must also be changed, and you need to verify that the application's DB driver supports SCRAM-SHA-256. Older drivers (libpq older than version 10) do not support SCRAM.