Claude Media
Claude CodeでMySQLからPostgreSQLへ移行する — pgloaderの変換結果を直す

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. 1

    dry-runで接続とファイル構文を確かめる

    pgloader --dry-run load/shop.loadを実行します。データは動きません。

  2. 2

    スキーマだけを流す

    WITH句にschema onlyを足して実行し、生成されたDDLをClaudeに読ませます。CASTの抜けや型の違いを洗い出す段階です。

  3. 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、スキーマ、データの順に進めることです。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. pgloader.readthedocs.io/en/latest/ref/mysql.html
  2. pgloader.readthedocs.io/en/latest/pgloader.html
  3. postgresql.org/docs/current/datatype-numeric.html
  4. postgresql.org/docs/current/citext.html
  5. dev.mysql.com/doc/refman/8.4/en/case-sensitivity.html
  6. code.claude.com/docs/en/common-workflows
  7. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn