Claude CodeでMySQLからPostgreSQLへ移行する — pgloaderの変換結果を直す
pgloaderの既定ルールで変わる型、AUTO_INCREMENT、日時のゼロ値、大文字小文字を、Claude Codeに検証SQLを書かせて件数と最大値で突き合わせながら直す手順です。
MySQLからPostgreSQLへの移行で、pgloaderはスキーマ変換とデータ転送を1コマンドで済ませます。ただし、型の対応表は既定のルールで決まっており、そこから外れる列は手で直すことになります。
Claude Codeに任せやすいのは、その「直す」側です。変換結果のDDLを読ませてCASTルールの案を出させ、移行前後を比べる検証SQLも書かせます。一致を判定するのは人間で、判定材料は件数と最大値のような数字に絞ります。
pgloaderの既定ルールが決めること
pgloaderはMySQLのデータベースを読み、PostgreSQLの表・索引・外部キーを作ってデータを流し込みます。最も単純な実行は、接続先を2つ並べるだけです。
pgloader mysql://myuser@myhost/dbname \
pgsql://pguser@pghost/dbname何も指定しないときのWITH句の既定は、no truncate、create tables、include drop、create indexes、reset sequences、foreign keys、downcase identifiers、uniquify index namesです。注意したいのはinclude dropで、移行先に同名の表があればCASCADE付きで落とします。その表を参照する外部キー制約やビューもまとめて消えるため、空のデータベースへ向けて実行するのが前提です。
型の対応も既定のCASTルールで決まります。記事で扱う列に関わる部分だけ抜き出すと、次のとおりです。
| MySQLの列 | 既定の変換先 | 直す理由になりやすい点 |
|---|---|---|
int + AUTO_INCREMENT | 既定の変換先serial(精度10以上はbigserial) | 直す理由になりやすい点採番の現在値が移行後に合っているか |
tinyint(1) | 既定の変換先boolean | 直す理由になりやすい点アプリ側の= 1比較が残っていないか |
tinyint unsigned | 既定の変換先smallint | 直す理由になりやすい点値の範囲は広がるが型は別物になる |
integer unsigned | 既定の変換先bigint | 直す理由になりやすい点JOIN先の型と食い違わないか |
datetime / timestamp | 既定の変換先timestamptz | 直す理由になりやすい点タイムゾーンの前提が変わる |
既定値が0000-00-00の日時 | 既定の変換先zero-dates-to-nullでNULL化 | 直す理由になりやすい点NOT NULLと既定値が落ちる |
enum | 既定の変換先表名と列名から付けた名前の型 | 直す理由になりやすい点型名が増える |
tinyint unsignedからsmallint、integer unsignedからbigintといった対応はいずれもpgloaderのルール表にあるものです。変換の全体は固定ではなく、CAST句で上書きできます。ルールは上から順に評価され、最初に一致したものだけが使われます。
作業の準備 — 規約・権限・手順の分け方
移行作業のリポジトリには、Claudeに守らせる規約を最初に書いておきます。次の断片は一例です。
# MySQL→PostgreSQL移行のルール
- load/*.load が移行定義。直すのは CAST 句と BEFORE/AFTER LOAD のSQLだけ
- 本番のMySQLには書き込まない。pgloaderは検証用コピーのPostgreSQLにだけ向ける
- 変換ルールを変えたら、まず pgloader --dry-run、次に schema only、最後にデータ
- 完了の定義は verify/*.sql の結果が、移行前後で期待どおりの差分に収まること(一致、またはゼロ値のNULL化分だけの差)変更を実際に書き込む前に確認したいときは、plan modeが使えます。claude --permission-mode planで起動すると、ファイルを読んで計画を出し、承認するまで編集しません。実行中はShift+Tabでも切り替えられます。CASTルールの方針を決める最初の1回は、このモードで案を見ます。
権限は.claude/settings.jsonに書きます。評価の順序は拒否、確認、許可で、拒否に入れた対象は許可ルールを足しても通りません。接続情報を含む設定ファイルは読ませない側に、検証用の安全なコマンドは許可の側に置きます。
{
"permissions": {
"allow": [
"Bash(pgloader --dry-run *)",
"Bash(./verify/run.sh *)"
],
"deny": [
"Read(./.env)",
"Read(./load/secrets.load)"
]
}
}pgloader --dry-runは、データを読み込まずに.loadファイルを試す公式のオプションです。接続文字列の誤りを潰すのに使います。実データを流すpgloader本体は許可に入れず、毎回確認が出る状態にしておきます。
見直す6つの差と、それぞれの検証クエリ
ここからは、変換後に食い違いやすい6点を順に見ます。どれも「移行元で数え、移行先で数えて比べる」形に揃えると、Claudeに書かせたSQLの正しさも確かめやすくなります。
AUTO_INCREMENTとシーケンスの現在値
AUTO_INCREMENT列はserialかbigserialになります。PostgreSQLのserialは型ではなく表記上の便宜で、シーケンスを作って列の既定値にnextvalを設定する書き方の短縮です。
既定のWITH句にreset sequencesが入っているので、ロード後にシーケンスは列の最大値へ合わせられます。それでも、no reset sequencesで外したり、データだけを別経路で入れ直したりすると、次の採番が既存の主キーとぶつかります。確認は、表の最大値とシーケンスのlast_valueを並べるだけです。
-- PostgreSQL側
SELECT max(id) AS max_id FROM orders;
SELECT sequencename, last_value FROM pg_sequences
WHERE sequencename = 'orders_id_seq';last_valueは、シーケンスがまだ読み出されていない間はnullになります。ずれていたらsetvalで合わせます。第3引数のfalseを付けると、次のnextvalが指定した値そのものを返します。
日時のゼロ値
MySQLには0000-00-00のような日時が入っていることがあります。pgloaderの既定ルールがzero-dates-to-nullを当てるのは、既定値が0000-00-00の日時列と、ON UPDATE CURRENT_TIMESTAMP付きのdatetimeです。この変換では、NOT NULLと既定値も外れます。
列の定義がそれらに当たらないのに、行にだけゼロ値が入っている場合は、ルール表の条件に合いません。そこで、MySQL側で列ごとに数えます。
-- MySQL側
SELECT COUNT(*) FROM orders WHERE created_at = '0000-00-00 00:00:00';件数が0より大きい列は、CASTで明示的に変換します。公式のサンプルにも、type date drop not null drop default using zero-dates-to-nullという指定があります。
CAST type date drop not null drop default using zero-dates-to-null,
type datetime to timestamptz using zero-dates-to-nullどの列でNULLを許すかは業務判断です。ClaudeにはNULL化の候補列を一覧させ、アプリがその列をNOT NULL前提で読んでいないかをコードから探させます。
大文字小文字 — 識別子と値の比較
大文字小文字の差は2か所に現れます。
1つ目は識別子です。pgloaderは既定で、MySQLの表名・列名・索引名を小文字にします(downcase identifiers)。PostgreSQLの予約語は例外です。大文字を含む名前をそのまま残したいならquote identifiersを使えますが、アプリ側のSQLでも同じ引用が必要になります。
2つ目は値の比較です。MySQL 8.4のマニュアルによると、既定の照合順序はutf8mb4_0900_ai_ciで、文字列の比較は既定で大文字小文字を区別しません。LIKE 'a%'はA始まりの値にも一致します。PostgreSQLのtext列では、UNIQUEやPRIMARY KEYが作る索引は大文字小文字を区別します。メールアドレスのような列で、A@example.comとa@example.comが別の行として共存できるようになります。
重複の有無は、移行前に小文字化して数えます。
SELECT lower(email) AS k, COUNT(*) FROM users
GROUP BY lower(email) HAVING COUNT(*) > 1;MySQL側で0件でも、移行後のアプリがPostgreSQLでWHERE email = ?を使うと、大文字小文字の違いで一致しなくなります。選択肢は3つです。
- アプリ側で入力を小文字に正規化する
citext拡張を使う。比較時に内部でlowerを呼ぶ型で、主キーも大文字小文字を区別しなくなります。- ICUの非決定的な照合順序を使う。
citextのマニュアルは、こちらの採用を検討するよう書いています。
ただしcitextはtextより効率が劣り、照合順序の設定(LC_CTYPE)に依存します。非決定的な照合順序にも性能面の代償と、使えない操作(一部のパターンマッチ)があります。
符号なし整数とtinyint(1)
tinyint(1)はbooleanに変わります。コード側にWHERE is_active = 1が残っていないか、Claudeに検索させます。PostgreSQLにはbooleanと整数を比べる演算子がないため、is_active = 1はエラーになります。is_activeやis_active = trueへの書き換えが必要です。
符号なし整数は、tinyint unsignedがsmallint、smallint unsignedとmediumint unsignedがinteger、integer unsignedがbigintへ広がります。主キーとその参照元で型がずれないかは、information_schema.columnsを両側から出力して突き合わせるのが速い方法です。
ENUMの扱い
MySQLのENUMは列定義の中に書かれますが、PostgreSQLでは別にCREATE TYPEで作ります。pgloaderは表名と列名から名前を付けた型を作り、ラベルの順序も保ちます。型名が増えることを前提に、アプリのORMが型名を直接参照していないかを確認します。
索引名の変わり方
MySQLの索引名は表ごとに一意ですが、PostgreSQLではスキーマ内で一意にする必要があります。pgloaderは既定で、索引名の前にidx_と表のOIDを付けます。preserve index namesを指定しても、PRIMARYという名前の主キー索引だけは付け替えられます。
マイグレーション用のSQLやORMの設定に索引名を直書きしているなら、名前が変わる前提で洗い出しておきます。Claudeには、リポジトリ内の索引名の参照箇所を一覧させるだけで十分です。
実行の順番 — dry-run、スキーマ、データ
CASTルールは、ファイルに書いて版管理します。公式の例に沿って作ると、次のような形になります。
LOAD DATABASE
FROM mysql://myuser@myhost/shop
INTO postgresql://pguser@pghost/shop_copy
WITH include drop, create tables, create indexes,
reset sequences, foreign keys
CAST type date drop not null drop default using zero-dates-to-null,
type datetime to timestamptz using zero-dates-to-null
BEFORE LOAD DO
$$ create schema if not exists shop; $$;実行は3段階に分けます。
pgloaderの実行を段階に分ける
- 1
dry-runで接続とファイル構文を確かめる
pgloader --dry-run load/shop.loadを実行します。データは動きません。 - 2
スキーマだけを流す
WITH句に
schema onlyを足して実行し、生成されたDDLをClaudeに読ませます。CASTの抜けや型の違いを洗い出す段階です。 - 3
データを流し、止まる設定で原因を追う
--on-error-stopを付けると、PostgreSQLが何かを拒否した時点で止まります。既定は、不良行を分けて続行する動作です。型変換の失敗を1件ずつ潰すときは、止まる設定のほうが原因を特定しやすくなります。
実行の記録は--summaryでファイルに残せます。拡張子を.jsonにすると機械で読める形式になるので、結果をClaudeに読ませる場合はこの形式が向きます。
件数と値の照合を、同じクエリで両側に流す
DDLが通ったあと、データの一致を確かめます。両側で同じ形のクエリを流し、結果をClaudeに比較させます。
| 確認項目 | クエリの例 | 判定 |
|---|---|---|
| 行数 | クエリの例SELECT COUNT(*) FROM orders | 判定両側で一致 |
| 数値の合計 | クエリの例SELECT SUM(total_cents) FROM orders | 判定両側で一致 |
| 最大の主キー | クエリの例SELECT MAX(id) FROM orders | 判定一致し、シーケンスがそれ以上 |
| NULLの数 | クエリの例SELECT COUNT(*) FROM orders WHERE shipped_at IS NULL | 判定ゼロ値のNULL化分だけ増える |
最後の行は、ゼロ値をNULL化した列で「一致しないのが正しい」例です。差分が期待どおりかどうかは人間が判断し、判断した理由をverify/のコメントに残します。照合表の作り方そのものは、データ移行の項目マッピング表の記事に詳しく書いています。
Claudeには、次のように頼みます。
orders、users、order_items の3表について、MySQLとPostgreSQLの
両方で流せる行数・合計・最大値のクエリを verify/ 配下に書いて。
実行はせず、まず内容を見せて。「実行はせず」と付けるのは、書いた内容を人が先に見るためです。承認後に、許可済みの./verify/run.shだけを実行させます。
Oracle起点の移行との違い
同じ移行でも、起点がOracleなら変換ツールもつまずく点も違います。PL/SQLの手直しが中心になるOracleの場合は、OracleからPostgreSQLへの移行の記事を参照してください。MySQLの場合は、手直しの中心はPL/SQLではなく、型・日時・大文字小文字・採番という「データ」の側にあります。
移行後に遅いクエリが出たときは、MySQLのスロークエリを読む記事の読み方が、PostgreSQL側のEXPLAINを見るときの下敷きになります。
まとめ
pgloaderが一度で決める変換は多く、直す箇所は「ルールに当たらなかった列」に集中します。Claude Codeには、CASTルールの案と検証SQLの下書きを任せ、一致の判定は人間の目で確かめる分担が成り立ちます。最初に書く規約は、本番に書き込まないことと、--dry-run、スキーマ、データの順に進めることです。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- pgloader.readthedocs.io/en/latest/ref/mysql.html
- pgloader.readthedocs.io/en/latest/pgloader.html
- postgresql.org/docs/current/datatype-numeric.html
- postgresql.org/docs/current/citext.html
- dev.mysql.com/doc/refman/8.4/en/case-sensitivity.html
- code.claude.com/docs/en/common-workflows
- code.claude.com/docs/en/permissions