PostgreSQL の migration で事故になりやすいのは、ALTER TABLE の書き方そのものより、schema とアプリの公開順が噛み合っていない状態です。この記事では、既存の orders.status TEXT を orders.status_code SMALLINT に移す想定で、列追加、backfill、read/write の切り替え、最終 cleanup までの順番を整理します。
対象読者は、ALTER TABLE や UPDATE は読めるが、既存データを抱えたまま新列へ安全に切り替える手順がまだ曖昧な人です。フレームワーク固有の実装やオンライン schema change 専用ツールの比較は扱いません。ここでは PostgreSQL 単体で考えても崩れにくい順番に絞ります。
先に読んでおくとつながりやすい記事:
1. 一番危ないのは「schema は正しいが deploy 順が壊れている」状態
よくある変更は、既存の文字列列を新しいコード列へ移したい、あるいは nullable な列を最終的に必須にしたい、というものです。たとえば次のような orders テーブルがあり、status TEXT を status_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でボトルネックを読む) がつながります。