公開日 2026-05-20

PostgreSQLのマイグレーション設計(列追加・データ移行・切り替えの安全な流れ)

既存テーブルに新しい列を追加し、backfill と整合性確認を経て安全に read/write を切り替える PostgreSQL migration の順番を整理する。

目次

  1. 1. 一番危ないのは「schema は正しいが deploy 順が壊れている」状態
  2. 2. 安全な切り替えは「受け入れ -> 併走 -> 切り替え -> 片付け」で考える
  3. 3. 最初の DB リリースでは新列を「まだ厳しくしない」
  4. 4. アプリは旧列を読みつつ、新旧両方へ書く
  5. 5. backfill は一括 UPDATE より「止めやすい単位」で流す
  6. 6. 整合性確認が 0 件になってから read を切り替える
  7. 7. 切り替え判断は「工程が終わったか」ではなく「互換性が消えてもよいか」で決める
  8. 8. まとめ

PostgreSQL の migration で事故になりやすいのは、ALTER TABLE の書き方そのものより、schema とアプリの公開順が噛み合っていない状態です。この記事では、既存の orders.status TEXTorders.status_code SMALLINT に移す想定で、列追加、backfill、read/write の切り替え、最終 cleanup までの順番を整理します。

対象読者は、ALTER TABLEUPDATE は読めるが、既存データを抱えたまま新列へ安全に切り替える手順がまだ曖昧な人です。フレームワーク固有の実装やオンライン schema change 専用ツールの比較は扱いません。ここでは PostgreSQL 単体で考えても崩れにくい順番に絞ります。

先に読んでおくとつながりやすい記事:

1. 一番危ないのは「schema は正しいが deploy 順が壊れている」状態

よくある変更は、既存の文字列列を新しいコード列へ移したい、あるいは nullable な列を最終的に必須にしたい、というものです。たとえば次のような orders テーブルがあり、status TEXTstatus_code SMALLINT へ寄せたいとします。

CREATE TABLE orders (
	id BIGSERIAL PRIMARY KEY,
	status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'shipped')),
	updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

このとき危ないのは、次のように 1 回の切り替えで終わらせようとする案です。

進め方判定危ない理由
新列追加、backfill、旧列削除、rename を 1 release でまとめる避けたい旧アプリが残っている間に status が消え、即座に互換性が壊れる
新列追加後すぐ NOT NULL を付ける避けたいbackfill 前の既存行が通らない。writer が新列を書き始める前でも詰まる
新列追加 -> dual-write -> backfill -> read 切り替え -> 制約強化 -> cleanup採用rollback 地点を残しやすく、旧アプリと新 schema の共存期間を持てる

この記事で到達したい状態は次のとおりです。

  • 新列を permissive に追加し、旧アプリを壊さずに deploy できる
  • backfill を止めやすい単位で進め、整合性確認をはさめる
  • read 切り替えと制約強化を、確認結果に基づいて後ろへ送れる

扱わない内容は、trigger での同期、partition table の親子変更、巨大テーブルの bloat 対策、専用ツール比較です。「順番で事故を減らす」ことに絞ります。

2. 安全な切り替えは「受け入れ -> 併走 -> 切り替え -> 片付け」で考える

列移行は 1 回の migration ではなく、少なくとも 4 段階に分けて考えると崩れにくくなります。

flowchart TD
		A[DB release 1<br/>新列を nullable で追加] --> B[App release 1<br/>旧列を読みつつ新旧両方へ書く]
		B --> C[Backfill<br/>既存行を batch で埋める]
		C --> D[整合性確認<br/>null と不一致を 0 件にする]
		D --> E[App release 2<br/>read を新列へ切り替える]
		E --> F[DB release 2<br/>VALIDATE / NOT NULL / DEFAULT]
		F --> G[DB release 3<br/>旧列 drop・必要なら rename]

この順番にすることで、各段階の責任がはっきりします。

  • DB release 1 の仕事は「新列を受け入れられるようにする」ことだけ
  • App release 1 の仕事は「新旧両方へ書いても壊れない状態」を作ること
  • Backfill の仕事は「過去データを新列に追いつかせる」ことにあります
  • App release 2 の仕事は「整合性確認が終わった列へ read を切り替える」こと
  • DB release 2 以降の仕事は「もう不要な緩さを片付ける」こと

この順番の肝は、DB を先に壊さず受け入れ状態にし、そのあとでアプリの書き込みを変える点にあります。旧列 drop や rename は、互換性を壊す操作なので最後まで遅らせます。

3. 最初の DB リリースでは新列を「まだ厳しくしない」

最初の migration では、新列を追加し、将来の値域だけ定義します。ここでは status_code を nullable のまま追加し、0 / 10 / 20 だけを許す制約を NOT VALID で先に置きます。

ALTER TABLE orders
	ADD COLUMN status_code SMALLINT;

ALTER TABLE orders
	ADD CONSTRAINT orders_status_code_valid
	CHECK (status_code IS NULL OR status_code IN (0, 10, 20))
	NOT VALID;
  • 2 行目 ADD COLUMN status_code SMALLINT は nullable で始めています。backfill 前の既存行や、まだ新列を書かない旧アプリが残っていても、ここでは詰まりません。
  • 5 行目 ADD CONSTRAINT orders_status_code_valid で、新列の値域だけ先に固定します。将来の app release で新列へ書き始めたとき、不正なコードを混ぜにくくなります。
  • 6 行目 CHECK (status_code IS NULL OR ...) にしているのは、移行途中の NULL を許容するためです。厳格化は、backfill と read 切り替えのあとに回します。

ADD COLUMN ... DEFAULT 0 は現行の PostgreSQL では高速に終わる場合があります。ただし、それで安全な切り替えになるわけではありません。問題になりやすいのは table rewrite ではなく、「旧アプリが期待する列名と意味をいつ壊すか」です。

4. アプリは旧列を読みつつ、新旧両方へ書く

DB release 1 の直後にやることは、新列への書き込みを始める app release です。ここで read まで急いで切り替える必要はありません。旧列を引き続き読み、write だけ新旧両方へ寄せます。

更新のイメージは次のようになります。

UPDATE orders
SET
	status = 'paid',
	status_code = 10,
	updated_at = NOW()
WHERE id = $1;

この段階で押さえたい点は 3 つあります。

  • 旧列を読む reader が残っている間は、旧列を消さない
  • 新列へ書く writer が出始めても、read までは旧列に残す
  • 複数の writer があるなら、全部が dual-write へ移るまで backfill 完了扱いにしない

もしバッチ処理、管理画面、API サーバーが同じテーブルへ書いているなら、1 つだけ dual-write 化しても不十分です。旧 writer が 1 つでも残っていると、新列に NULL のまま増える行が残ります。

5. backfill は一括 UPDATE より「止めやすい単位」で流す

dual-write が入ったら、今度は既存行を新列に追いつかせます。ここでテーブル全体へ 1 回の UPDATE を打つより、batch に分けて進めたほうが止めやすく、進捗も追いやすくなります。

WITH target AS (
	SELECT id
	FROM orders
	WHERE status_code IS NULL
	ORDER BY id
	LIMIT 1000
)
UPDATE orders o
SET status_code = CASE o.status
	WHEN 'pending' THEN 0
	WHEN 'paid' THEN 10
	WHEN 'shipped' THEN 20
END
FROM target
WHERE o.id = target.id;
  • 2 行目 SELECT id で対象を先に切り出しているのは、毎回どの行を触るかを安定させるためです。
  • 6 行目 LIMIT 1000 により、1 回で処理する量を制御できます。負荷や lock 待ちを見ながら batch size を調整しやすくなります。
  • 9 行目 SET status_code = CASE o.status では、旧列から新列への写像を明示しています。変換ルールが SQL の中に見えるので、確認 query と対応づけやすくなります。

この SQL を scheduler や手元の運用コマンドで繰り返し流し、毎回影響行数を見ます。途中で止める判断が必要になっても、どこまで進んだかを追いやすい構成です。

6. 整合性確認が 0 件になってから read を切り替える

backfill の完了条件は「何となく十分流した」ではありません。新列が欠けていないこと、写像がずれていないことを query で確認してから、read を切り替えます。

SELECT
	COUNT(*) FILTER (WHERE status_code IS NULL) AS missing_new_value,
	COUNT(*) FILTER (
		WHERE status NOT IN ('pending', 'paid', 'shipped')
	) AS unmapped_old_value,
	COUNT(*) FILTER (
		WHERE status IN ('pending', 'paid', 'shipped')
			AND status_code IS DISTINCT FROM CASE status
				WHEN 'pending' THEN 0
				WHEN 'paid' THEN 10
				WHEN 'shipped' THEN 20
			END
	) AS mismatched_rows
FROM orders;

この結果が 0 / 0 / 0 になるまで、read は旧列のままです。0 件になったら app release 2 で read を新列へ切り替え、それでも短期間は dual-write を残すと rollback しやすくなります。

read 切り替え後に、DB 側の制約を強くします。

ALTER TABLE orders
	ADD CONSTRAINT orders_status_code_not_null
	CHECK (status_code IS NOT NULL)
	NOT VALID;

ALTER TABLE orders
	VALIDATE CONSTRAINT orders_status_code_valid;

ALTER TABLE orders
	VALIDATE CONSTRAINT orders_status_code_not_null;

ALTER TABLE orders
	ALTER COLUMN status_code SET NOT NULL,
	ALTER COLUMN status_code SET DEFAULT 0;
  • 2 行目の ADD CONSTRAINT から始まる制約全体が NOT VALID であることで、まず「既存行の確認」と「今後の更新ルール」を分けています。いきなり SET NOT NULL に飛ばず、検証可能な形にします。
  • 7 行目 VALIDATE CONSTRAINT orders_status_code_valid は、値域の確認を既存行まで広げる段階です。新規更新はすでに制約の対象なので、ここでは過去データの整合性を追っています。
  • 13 行目 ALTER COLUMN status_code SET NOT NULL は、確認が通ったあとで付けます。SET DEFAULT 0 もこの段階なら、新しい insert の初期値として意味が揃います。

最後に、旧列を片付けます。

ALTER TABLE orders
	DROP COLUMN status;

-- 列名を戻したいなら、さらに別 release で実施する
ALTER TABLE orders
	RENAME COLUMN status_code TO status;

DROP COLUMN や rename 自体は速く終わることがあります。けれども互換性を壊す操作である点は変わりません。旧アプリ、古い batch、参照 view が残っていないことを確認してからにします。

7. 切り替え判断は「工程が終わったか」ではなく「互換性が消えてもよいか」で決める

工程を進めるかどうかは、SQL を流し終えたかではなく、次の互換性が消えてもよいかで判断したほうが安全です。

状態次へ進めてよいか理由
新列を追加した直後app の dual-write までは進めてよい旧列の read/write が残っていても壊れない
dual-write 導入後だが status_code IS NULL が残るread 切り替えはまだ早い既存行の新列が埋まっていない
整合性確認が 0 / 0 / 0 になったread 切り替えへ進める新列を読んでも旧列との差分がない
新列 read に切り替えた直後すぐ drop しないrollback 用に旧列を短期間残したほうが安全
旧 reader / writer が完全に消えたdrop / rename に進める互換性破壊の影響先が残っていない

この基準で見ると、migration でいちばん先に決めるべきなのは SQL の細部より「どの release で何を壊してよいか」です。列追加は受け入れ、dual-write は併走、整合性確認は切り替え条件、drop と rename は片付け。役割が混ざると事故になります。

8. まとめ

安全な列移行は、新列を permissive に追加し、app の dual-write で追いつかせ、backfill と整合性確認が終わってから read を切り替える流れで組み立てます。NOT NULL や旧列 drop を急ぐより、互換性を壊す位置を最後まで後ろへ送ることで、各 release のロールバック地点を確保したまま進められます。

次に PostgreSQL の運用寄りテーマを読むなら、同時更新の前提整理には PostgreSQLのトランザクション入門(BEGIN / COMMIT / ROLLBACK と同時更新)、query 改善の入口には 遅いSQLをどう見るか(EXPLAIN ANALYZEでボトルネックを読む) がつながります。