データ移行のマッピング定義書とは、移行先の項目1つずつについて「移行元のどの項目を、どう変換して入れるか」を書いた表です。 書くときは移行先の項目を起点に1行1項目とし、移行元・変換ルール・初期値・NULL や不正値の扱い・検証方法までを同じ行に書き切ります。

この記事では、そのまま表計算ソフトに貼れるテンプレートと記入例を載せ、定義書どおりの変換SQLと検証SQLを PostgreSQL で実際に流しました。旧テーブルの12件が「成功4・除外2・エラー6」に分かれるところまで、結果つきで追えます。

確認環境:PostgreSQL 16.15(Ubuntu 24.04)、Python 3.11.15。確認日は 2026-10-05。題材は架空の会員管理システムで、氏名・住所・電話番号・メールアドレスはすべて架空の値です。

この記事の結論

  • マッピング定義書は移行先の項目を起点に、1行1項目で書く。移行元を起点にすると、移行先の必須項目の埋め忘れに気づきにくい。
  • 1行に移行元・変換ルール・初期値・NULL/空の扱い・不正値の扱い・検証方法まで書く。「変換ルール」だけでは、想定外の値が来たときの動きが決まらない。
  • 値の対応が3組以上ある、または途中で増える見込みがあるコードは、コード変換表を別に分ける。
  • 検証は件数・合計値・ハッシュ突合の3段階。件数は「移行対象=成功+除外+エラー」が合うことを確かめる。
  • 定義書を書く前に、移行元のデータをSQLで下調べする。変換表に無いコードや暦に無い日付は、この段階で見つかる。

マッピング定義書の項目 早見表

No 列 書くこと 記入例
1 No 行の通し番号 7
2 移行先テーブル 新システムのテーブル名 member
3 移行先項目 新システムの列名 birth_date
4 移行先の型・桁 型、桁数 date
5 必須 NOT NULL かどうか 任意
6 移行元テーブル 旧システムのテーブル名。無ければ「なし」 old_kaiin
7 移行元項目 旧システムの列名。複数なら並べる seinen
8 移行元の型・桁 型、桁数、書式 char(8)、YYYYMMDD
9 変換種別 そのまま/型変換/コード変換/分割/結合/固定値/採番/計算 型変換(日付)
10 変換ルール 式や条件を、SQLに直せる細かさで YYYYMMDD を日付に変換
11 初期値 移行元に値が無いときに入れる値 なし
12 NULL・空の扱い NULL、空文字、ゼロ埋めの値をどうするか 00000000 は NULL にする
13 不正値の扱い 変換できない値が来たとき:エラーにする/初期値にする/切り捨てる 暦に無い日付はエラー(E04)
14 検証方法 件数、合計、最小・最大、ハッシュなど 最小・最大と NULL 件数を照合
15 備考 決めた日、確認した人、未決事項 2026-09-20 利用部門と合意

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「データ移行のマッピング定義書の書き方|項目テンプレートと件数検証SQL」

マッピング定義書・移行計画書・インターフェース設計書の違い

文書 答える問い 単位 主な中身 使う場面
マッピング定義書 どの項目を、どう変換して入れるか 項目 項目対応、変換ルール、不正値の扱い、検証方法 移行プログラムの作成、移行結果の検証
移行計画書 いつ、誰が、どの順で移すか 作業 方式、体制、スケジュール、リハーサル、切り戻し 移行全体の進め方の合意
インターフェース設計書 稼働後に、システム間で何をやり取りするか 連携 ファイル形式、項目定義、タイミング、エラー時の動き 継続的な連携の開発
コード変換表 旧コードのどの値が、新コードのどの値になるか 値 旧値、新値、意味 マッピング定義書から参照
条件 選ぶもの
1回きりの移し替えで、旧データの不ぞろいを吸収したい マッピング定義書
稼働後も毎日・毎時でデータを渡し続ける インターフェース設計書
日程、担当、リハーサルの回数、切り戻しの条件を決めたい 移行計画書
コードの値の対応が3組以上ある、複数の項目で同じ対応を使う、途中で値が増えそう コード変換表を別に分ける
値の対応が2組までで、今後増えない(例:0→false、1→true) マッピング定義書の変換ルール欄に直接書く

コード変換表を分けておくと、対応が増えたときに表へ1行足すだけで済み、変換SQLも結合で書けます。あとで見るように、「変換表に無い値」を機械的に見つけられるのも利点です。

題材:旧会員管理テーブルと新会員管理テーブル

旧システムの old_kaiin は、すべての項目が文字列で、書式が統一されていない表という想定です。

-- 移行元(旧会員管理)
CREATE TABLE old_kaiin (
  kaiin_no   char(6) PRIMARY KEY,   -- 会員番号
  simei      varchar(40),           -- 氏名(姓と名を全角空白で区切る)
  kbn        char(1),               -- 会員区分 1:一般 2:学生
  tel        varchar(15),           -- 電話番号(ハイフンあり・なしが混在)
  seinen     char(8),               -- 生年月日 YYYYMMDD(不明は 00000000)
  toroku_bi  varchar(10),           -- 登録日 YYYY/MM/DD
  point      varchar(9),            -- ポイント(文字列)
  mail       varchar(100),          -- メールアドレス
  jusho      varchar(100),          -- 住所
  taikai_flg char(1)                -- 退会フラグ 1:退会済み
);
INSERT INTO old_kaiin VALUES
 ('000001','見本 一郎','1','9990001000','19850412','2019/04/01','1200','ichiro@example.com','架空県見本市中央1-2-3','0'),
 ('000002','例示 花子','2','999-0002-0002','19991130','2020/10/15','300','HANAKO@example.com ','架空県例示町2-5','0'),
 ('000003','架空 次郎','1',NULL,'00000000','2021/01/20','0','jiro@example.com','試験県架空市東3-1','0'),
 ('000004','試験 さくら','3','9990004000','19780229','2018/07/07','50','sakura@example.com','試験県見本村4','0'),
 ('000005','仮名 三郎','1','9990005000','19900101','2022/02/30','700','saburo@example.com','架空県見本市西5-9','0'),
 ('000006','見本 もみじ','9','9990006000','19690815','2017/12/01','0','momiji@example.com','架空県例示町6','1'),
 ('000007','例示 四郎','2','9990007000','20010505','2023/03/03','15000','hanako@example.com','試験県架空市南7','0'),
 ('000008','寿限無寿限無五劫の擦り切れ海砂利水魚の水行末雲来末','1','9990008000','19950606','2024/05/05','80','jugemu@example.com','架空県見本市北8','0'),
 ('000009','架空 五郎','1','9990009000','19881224','2016/09/09','abc','goro@example.com','試験県例示町9','0'),
 ('000010','試験花子','2','9990010000','20030303','2025/01/10','420',NULL,'架空県見本市中央10','0'),
 ('000011','仮名 六郎','1','9990011000','19720720','2015/06/30','999','rokuro@example.com','試験県架空市11','1'),
 ('000012','見本 七海','2','9990012000','19970909','2025/08/08','60','nanami@example.com','架空県例示町12-3','0');

-- コード変換表
CREATE TABLE code_map_member_type (
  old_kbn  char(1) PRIMARY KEY,
  new_type varchar(10) NOT NULL,
  note     text
);
INSERT INTO code_map_member_type VALUES ('1','GENERAL','一般'),('2','STUDENT','学生');

-- 移行先(新会員管理)
CREATE TABLE member (
  member_id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  legacy_no     char(6)      NOT NULL UNIQUE,
  last_name     varchar(20)  NOT NULL,
  first_name    varchar(20)  NOT NULL,
  member_type   varchar(10)  NOT NULL CHECK (member_type IN ('GENERAL','STUDENT')),
  phone         varchar(11),
  birth_date    date,
  registered_on date         NOT NULL,
  points        integer      NOT NULL DEFAULT 0 CHECK (points >= 0),
  email         varchar(100) UNIQUE,
  address       varchar(100) NOT NULL
);

-- 1件ごとの移行結果
CREATE TABLE migration_result (
  kaiin_no char(6) PRIMARY KEY,
  result   varchar(8) NOT NULL CHECK (result IN ('OK','EXCLUDED','ERROR')),
  reason   text
);

定義書を書く前に、移行元をSQLで下調べする

変換ルールや不正値の扱いは、移行元に実際どんな値が入っているかを見てから決めます。次の4本を流すと、定義書の「NULL・空の扱い」「不正値の扱い」に書くべきことが出てきます。

-- 変換表に無いコード
SELECT o.kbn, count(*) FROM old_kaiin o
  LEFT JOIN code_map_member_type c ON c.old_kbn = o.kbn
 WHERE c.old_kbn IS NULL GROUP BY o.kbn ORDER BY 1;

-- 大文字小文字と前後の空白をそろえると重複するメールアドレス
SELECT lower(btrim(mail)) AS email, count(*), string_agg(kaiin_no, ',' ORDER BY kaiin_no) AS kaiin_nos
  FROM old_kaiin WHERE mail IS NOT NULL GROUP BY 1 HAVING count(*) > 1;

-- 氏名の最大の長さと、区切りの無い件数
SELECT max(length(simei)) AS max_chars, max(octet_length(simei)) AS max_bytes_utf8,
       count(*) FILTER (WHERE simei NOT LIKE '% %') AS no_separator
  FROM old_kaiin;
 kbn | count
-----+-------
 3   |     1
 9   |     1

       email        | count |   kaiin_nos
--------------------+-------+---------------
 hanako@example.com |     2 | 000002,000007

 max_chars | max_bytes_utf8 | no_separator
-----------+----------------+--------------
        25 |             75 |            2

変換表に無い会員区分が2種類、名寄せすると重なるメールアドレスが1組、姓と名の区切りが無い氏名が2件、移行先の20文字を超える氏名(25文字)があることが分かりました。これを利用部門に見せて、扱いを決めてから定義書に書きます。

マッピング定義書のテンプレート(タブ区切り・15列)

次のブロックをコピーして表計算ソフトに貼ると、15列の表になります。

No  移行先テーブル 移行先項目   移行先の型・桁 必須  移行元テーブル 移行元項目   移行元の型・桁 変換種別    変換ルール   初期値 NULL・空の扱い   不正値の扱い  検証方法    備考
1                                                       
2                                                       
3                                                       

列の決め方の補足です。

  • 変換種別は選択肢を固定します(そのまま/型変換/コード変換/分割/結合/固定値/採番/計算)。並べ替えや絞り込みができ、変換の難しい行だけを拾ってレビューできます。
  • 不正値の扱いは「エラーにする/初期値にする/切り捨てる」の3つから選び、エラーにするときは理由コードを書きます。
  • 検証方法は、後の検証SQLのどれで確かめるかが分かる言葉で書きます。

記入例:会員管理の旧新テーブル

No 移行先項目 型・桁 必須 移行元項目 移行元の型・桁 変換種別 変換ルール 初期値 NULL・空の扱い 不正値の扱い 検証方法
1 member_id bigint 必須 なし ― 採番 移行先で自動採番。会員番号の昇順で登録 ― ― ― 件数
2 legacy_no char(6) 必須 kaiin_no char(6) そのまま 変換なし ― 主キーのため NULL なし ― 件数、ハッシュ突合のキー
3 last_name varchar(20) 必須 simei varchar(40) 分割 全角空白で区切った1つ目 なし ― 区切りなしはエラー(E02)、20文字超はエラー(E03)。切り捨てない ハッシュ突合
4 first_name varchar(20) 必須 simei varchar(40) 分割 全角空白で区切った2つ目 なし ― 同上 ハッシュ突合
5 member_type varchar(10) 必須 kbn char(1) コード変換 コード変換表「会員区分」を参照 なし ― 変換表に無い値はエラー(E01) 区分別件数
6 phone varchar(11) 任意 tel varchar(15) 型変換(文字) 数字以外を取り除く なし NULL・空は NULL ― ハッシュ突合
7 birth_date date 任意 seinen char(8)、YYYYMMDD 型変換(日付) YYYYMMDD を日付に変換 なし 00000000 は NULL 暦に無い日付はエラー(E04) 最小・最大、NULL 件数
8 registered_on date 必須 toroku_bi varchar(10)、YYYY/MM/DD 型変換(日付) YYYY/MM/DD を日付に変換 なし ― 暦に無い日付はエラー(E05) 最小・最大
9 points integer 必須 point varchar(9) 型変換(数値) 数字だけの文字列を整数に変換 0 ― 数字以外を含む値はエラー(E06) 合計値
10 email varchar(100) 任意 mail varchar(100) 計算(名寄せ) 前後の空白を除き、小文字にそろえる なし NULL は NULL そろえた結果が重複する2件目以降はエラー(E07) 種類の数、ハッシュ突合
11 address varchar(100) 必須 jusho varchar(100) そのまま 変換なし なし ― ― ハッシュ突合

移行対象の条件(行単位の決めごと)は、表の外に書いておきます。

項目 内容
移行対象 old_kaiin の全件(12件)
除外条件 taikai_flg = '1'(退会済み)は移行しない
エラーの扱い 1項目でもエラーなら、その会員は移行せず、理由を migration_result に残す
登録順 会員番号の昇順

上の表は幅を抑えるため、移行先テーブル・移行元テーブル・備考の3列を省いています。15列すべてを入れたタブ区切り版はこちらです(テンプレートにそのまま貼れます)。

No  移行先テーブル 移行先項目   移行先の型・桁 必須  移行元テーブル 移行元項目   移行元の型・桁 変換種別    変換ルール   初期値 NULL・空の扱い   不正値の扱い  検証方法    備考
1   member  member_id   bigint  必須  なし  なし  ―   採番  移行先で自動採番。会員番号の昇順で登録 ―   ―   ―   件数  
2   member  legacy_no   char(6) 必須  old_kaiin   kaiin_no    char(6) そのまま    変換なし    ―   主キーのためNULLなし    ―   件数、ハッシュ突合のキー    
3   member  last_name   varchar(20) 必須  old_kaiin   simei   varchar(40) 分割  全角空白で区切った1つ目    なし  ―   区切りなしはエラー(E02)、20文字超はエラー(E03)。切り捨てない    ハッシュ突合  2026-09-20 利用部門と合意
4   member  first_name  varchar(20) 必須  old_kaiin   simei   varchar(40) 分割  全角空白で区切った2つ目    なし  ―   同上  ハッシュ突合  
5   member  member_type varchar(10) 必須  old_kaiin   kbn char(1) コード変換   コード変換表「会員区分」を参照 なし  ―   変換表に無い値はエラー(E01)    区分別件数   区分3の扱いは未決
6   member  phone   varchar(11) 任意  old_kaiin   tel varchar(15) 型変換(文字) 数字以外を取り除く   なし  NULL・空はNULL ―   ハッシュ突合  
7   member  birth_date  date    任意  old_kaiin   seinen  char(8)、YYYYMMDD    型変換(日付) YYYYMMDDを日付に変換  なし  00000000はNULL   暦に無い日付はエラー(E04) 最小・最大、NULL件数    
8   member  registered_on   date    必須  old_kaiin   toroku_bi   varchar(10)、YYYY/MM/DD  型変換(日付) YYYY/MM/DDを日付に変換    なし  ―   暦に無い日付はエラー(E05) 最小・最大   
9   member  points  integer 必須  old_kaiin   point   varchar(9)  型変換(数値) 数字だけの文字列を整数に変換  0   ―   数字以外を含む値はエラー(E06)   合計値 
10  member  email   varchar(100)    任意  old_kaiin   mail    varchar(100)    計算(名寄せ) 前後の空白を除き小文字にそろえる    なし  NULLはNULL   そろえた結果が重複する2件目以降はエラー(E07)   種類の数、ハッシュ突合 
11  member  address varchar(100)    必須  old_kaiin   jusho   varchar(100)    そのまま    変換なし    なし  ―   ―   ハッシュ突合  

コード変換表は別の表にします。

コード名    旧値  旧の意味    新値  新の意味    備考
会員区分    1   一般  GENERAL 一般  
会員区分    2   学生  STUDENT 学生  
会員区分    3   (不明)    (未決)        1件あり。利用部門に確認中
会員区分    9   退会  (対象外)       退会フラグで除外されるため変換しない

変換パターン一覧

パターン 例 定義書に書くこと つまずきやすい点
コード変換 1→GENERAL、2→STUDENT 参照するコード変換表の名前、変換表に無い値の扱い 変換表に無い値を黙って初期値にすると、後から区別がつかない
桁あふれ varchar(40) → varchar(20) 超えたときにエラーにするか、切り捨てるか 型変換の書き方によっては、エラーにならずに切り捨てられる
文字コード Shift_JIS 系 → UTF-8 移行元の文字コードの正確な名前、変換できない文字の扱い 文字数は同じでもバイト数が増える。機種依存の文字が変換できないことがある
日付書式 YYYYMMDD、YYYY/MM/DD → date 書式、不明を表す値(00000000 など)、暦に無い日付の扱い 00000000 がエラーにならず、紀元前の日付になった
名寄せ 大文字小文字・前後の空白をそろえる そろえる規則、重複したときにどれを残すか そろえた結果、移行先の一意制約に当たる
分割・結合 氏名→姓と名、県+市区町村→住所 区切り文字、区切りが無いときの扱い、結合の順序と間に入れる文字 区切りが無い値、区切りが2つ以上ある値
型変換(数値) '1200' → 1200 数字以外を含む値の扱い、符号や小数の有無 全角数字、カンマ、空白が混じる
固定値・初期値 移行元に無い項目に 0 を入れる 入れる値と、その値にした理由 NOT NULL の項目の埋め忘れ

桁あふれ:切り捨てられる書き方と、エラーになる書き方

PostgreSQL では、同じ25文字の氏名でも、書き方によって結果が変わりました。

-- 型を指定して変換(キャスト)した場合
SELECT '寿限無寿限無五劫の擦り切れ海砂利水魚の水行末雲来末'::varchar(20) AS cast_silently_cut,
       length('寿限無寿限無五劫の擦り切れ海砂利水魚の水行末雲来末') AS len;

-- そのまま INSERT した場合
INSERT INTO member (legacy_no,last_name,first_name,member_type,registered_on,address)
VALUES ('999999','寿限無寿限無五劫の擦り切れ海砂利水魚の水行末雲来末','x','GENERAL','2024-01-01','x');
            cast_silently_cut             | len
------------------------------------------+-----
 寿限無寿限無五劫の擦り切れ海砂利水魚の水 |  25

ERROR:  value too long for type character varying(20)

キャストは20文字で切り捨て、INSERT はエラーで止まります。変換SQLの中で ::varchar(20) と書くと、定義書に「切り捨てない」と書いてあっても切り捨てられてしまいます。長さは変換の前に length() で確かめ、超えた行はエラーとして記録する形にします。

日付書式:暦に無い日付と 00000000

SELECT to_date('20220230', 'YYYYMMDD');
SELECT to_date('2022/02/30', 'YYYY/MM/DD');
ERROR:  date/time field value out of range: "20220230"
ERROR:  date/time field value out of range: "2022/02/30"

暦に無い日付は、PostgreSQL 16 の to_date() ではエラーになります。INSERT ... SELECT の途中でこのエラーが出ると全体が止まるので、エラーを NULL に置き換える関数を用意して、行ごとに判定できるようにします。

CREATE FUNCTION try_date(s text, fmt text) RETURNS date LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
  RETURN to_date(s, fmt);
EXCEPTION WHEN others THEN
  RETURN NULL;
END $$;

SELECT try_date('19850412','YYYYMMDD') AS ok,
       try_date('19780229','YYYYMMDD') AS bad,
       try_date('00000000','YYYYMMDD') AS zero;
     ok     | bad |     zero
------------+-----+---------------
 1985-04-12 |     | 0001-01-01 BC

1978年2月29日は NULL になりましたが、00000000 はエラーにならず、紀元前1年1月1日として変換されました。「不明は 00000000」という旧システムの決まりは、関数に任せず、定義書の「NULL・空の扱い」に書いて、変換SQLで先に判定します。

文字コード:文字数とバイト数、変換できない文字

旧システムが Shift_JIS 系のとき、UTF-8 へ移すとバイト数が変わります。Python で確かめました。

samples = ["見本 一郎", "髙橋", "①番地", "アイウ"]
for s in samples:
    row = [s, len(s)]
    for enc in ("cp932", "shift_jis", "utf-8"):
        try:
            row.append(len(s.encode(enc)))
        except UnicodeEncodeError:
            row.append("変換できない")
    print(*row, sep="\t")

raw = "髙橋①".encode("cp932")
print(raw.decode("cp932"))
try:
    print(raw.decode("shift_jis"))
except UnicodeDecodeError as e:
    print("UnicodeDecodeError:", e)
print("見本".encode("utf-8").decode("cp932", errors="replace"))
見本 一郎   5   10  10  15
髙橋  2   4   変換できない  6
①番地 3   6   変換できない  9
アイウ 3   3   3   9
髙橋①
UnicodeDecodeError: 'shift_jis' codec can't decode byte 0xee in position 0: illegal multibyte sequence
隕区悽

列は左から、文字列・文字数・cp932 のバイト数・shift_jis のバイト数・UTF-8 のバイト数です。

  • 全角文字は cp932 で2バイト、UTF-8 で3バイト。半角カナは1バイトが3バイトになります。移行先の桁がバイト数で決まる製品では、同じ文字数でも収まらなくなることがあります。PostgreSQL の varchar(n) は文字数で数えます(上の下調べでも、25文字の氏名は UTF-8 で75バイトでした)。
  • 「髙」「①」は cp932 では表せますが、Python の shift_jis では変換できません。定義書には「Shift_JIS」ではなく、cp932 なのか shift_jis なのかまで書きます。
  • UTF-8 のデータを cp932 として読むと「隕区悽」のような別の文字になります。エラーにはならないので、移行後に目で見るサンプル確認も残しておきます。

定義書どおりに変換するSQL

変換と判定を分けて書きます。まず、定義書の「変換ルール」と「NULL・空の扱い」をそのままビューにします。

CREATE VIEW v_convert AS
SELECT o.kaiin_no,
       split_part(o.simei, ' ', 1)                         AS last_name,
       NULLIF(split_part(o.simei, ' ', 2), '')              AS first_name,
       c.new_type                                           AS member_type,
       NULLIF(regexp_replace(o.tel, '[^0-9]', '', 'g'), '') AS phone,
       CASE WHEN o.seinen = '00000000' THEN NULL
            ELSE try_date(o.seinen, 'YYYYMMDD') END         AS birth_date,
       try_date(o.toroku_bi, 'YYYY/MM/DD')                  AS registered_on,
       CASE WHEN o.point ~ '^[0-9]+$' THEN o.point::integer END AS points,
       lower(btrim(o.mail))                                 AS email,
       o.jusho                                              AS address,
       o.taikai_flg, o.kbn, o.seinen, o.simei, o.point, o.toroku_bi
  FROM old_kaiin o LEFT JOIN code_map_member_type c ON c.old_kbn = o.kbn;

次に、定義書の「不正値の扱い」と除外条件に沿って、1件ごとの結果を migration_result に入れます。

INSERT INTO migration_result
SELECT kaiin_no,
       CASE WHEN taikai_flg = '1' THEN 'EXCLUDED' WHEN reason IS NULL THEN 'OK' ELSE 'ERROR' END,
       CASE WHEN taikai_flg = '1' THEN '退会済みのため対象外' ELSE reason END
  FROM (SELECT v.*,
               concat_ws(' / ',
                 CASE WHEN member_type IS NULL THEN 'E01 会員区分が変換表に無い(' || kbn || ')' END,
                 CASE WHEN first_name IS NULL THEN 'E02 氏名を姓と名に分けられない' END,
                 CASE WHEN length(last_name) > 20 OR length(first_name) > 20 THEN 'E03 氏名が20文字を超える' END,
                 CASE WHEN seinen <> '00000000' AND birth_date IS NULL THEN 'E04 生年月日が暦に無い(' || seinen || ')' END,
                 CASE WHEN registered_on IS NULL THEN 'E05 登録日が暦に無い(' || toroku_bi || ')' END,
                 CASE WHEN points IS NULL THEN 'E06 ポイントが数値でない(' || point || ')' END,
                 CASE WHEN email IS NOT NULL AND count(*) OVER (PARTITION BY email, taikai_flg) > 1
                       AND row_number() OVER (PARTITION BY email, taikai_flg ORDER BY kaiin_no) > 1
                      THEN 'E07 メールアドレスが重複(' || email || ')' END) AS r
          FROM v_convert v) s, LATERAL (SELECT NULLIF(r, '') AS reason) x;

-- 成功と判定した行だけを移行先へ
INSERT INTO member (legacy_no, last_name, first_name, member_type, phone, birth_date, registered_on, points, email, address)
SELECT v.kaiin_no, v.last_name, v.first_name, v.member_type, v.phone, v.birth_date, v.registered_on, v.points, v.email, v.address
  FROM v_convert v JOIN migration_result r USING (kaiin_no)
 WHERE r.result = 'OK'
 ORDER BY v.kaiin_no;
INSERT 0 12
INSERT 0 4
SELECT member_id, legacy_no, last_name, first_name, member_type, phone, birth_date, registered_on, points, email
  FROM member ORDER BY member_id;
SELECT kaiin_no, result, reason FROM migration_result WHERE result <> 'OK' ORDER BY kaiin_no;
 member_id | legacy_no | last_name | first_name | member_type |    phone    | birth_date | registered_on | points |       email
-----------+-----------+-----------+------------+-------------+-------------+------------+---------------+--------+--------------------
         1 | 000001    | 見本      | 一郎       | GENERAL     | 9990001000  | 1985-04-12 | 2019-04-01    |   1200 | ichiro@example.com
         2 | 000002    | 例示      | 花子       | STUDENT     | 99900020002 | 1999-11-30 | 2020-10-15    |    300 | hanako@example.com
         3 | 000003    | 架空      | 次郎       | GENERAL     |             |            | 2021-01-20    |      0 | jiro@example.com
         4 | 000012    | 見本      | 七海       | STUDENT     | 9990012000  | 1997-09-09 | 2025-08-08    |     60 | nanami@example.com

 kaiin_no |  result  |                              reason
----------+----------+------------------------------------------------------------------
 000004   | ERROR    | E01 会員区分が変換表に無い(3) / E04 生年月日が暦に無い(19780229)
 000005   | ERROR    | E05 登録日が暦に無い(2022/02/30)
 000006   | EXCLUDED | 退会済みのため対象外
 000007   | ERROR    | E07 メールアドレスが重複(hanako@example.com)
 000008   | ERROR    | E02 氏名を姓と名に分けられない / E03 氏名が20文字を超える
 000009   | ERROR    | E06 ポイントが数値でない(abc)
 000010   | ERROR    | E02 氏名を姓と名に分けられない
 000011   | EXCLUDED | 退会済みのため対象外

1件に複数の理由があるときは、すべて並べて出しています(000004、000008)。最初の1つで止めると、直して流し直すたびに次の理由が出てきて、往復が増えるためです。エラーになった6件は、旧データを直すか、定義書のルールを見直すかを利用部門と決めて、もう一度流します。

検証SQL:件数・合計値・ハッシュ突合

1. 件数:移行対象=成功+除外+エラー

SELECT (SELECT count(*) FROM old_kaiin)                                  AS src_total,
       (SELECT count(*) FROM migration_result WHERE result = 'OK')       AS ok,
       (SELECT count(*) FROM migration_result WHERE result = 'EXCLUDED') AS excluded,
       (SELECT count(*) FROM migration_result WHERE result = 'ERROR')    AS error,
       (SELECT count(*) FROM member)                                     AS dst_total;
 src_total | ok | excluded | error | dst_total
-----------+----+----------+-------+-----------
        12 |  4 |        2 |     6 |         4

12 = 4 + 2 + 6 で、成功の4件と移行先の4件が一致しています。「移行元12件、移行先4件」だけを見ると8件の行方が分かりませんが、除外とエラーを別々に数えておけば、差の理由をその場で説明できます。

2. 合計値・最小と最大・区分別件数

SELECT (SELECT sum(o.point::integer) FROM old_kaiin o JOIN migration_result r USING (kaiin_no)
         WHERE r.result = 'OK') AS src_points,
       (SELECT sum(points) FROM member) AS dst_points,
       (SELECT min(toroku_bi) || '〜' || max(toroku_bi) FROM old_kaiin o JOIN migration_result r USING (kaiin_no)
         WHERE r.result = 'OK') AS src_range,
       (SELECT min(registered_on) || '〜' || max(registered_on) FROM member) AS dst_range;

SELECT c.new_type, count(*) AS src_cnt,
       (SELECT count(*) FROM member m WHERE m.member_type = c.new_type) AS dst_cnt
  FROM old_kaiin o JOIN migration_result r USING (kaiin_no)
  JOIN code_map_member_type c ON c.old_kbn = o.kbn
 WHERE r.result = 'OK' GROUP BY c.new_type ORDER BY 1;
 src_points | dst_points |       src_range        |       dst_range
------------+------------+------------------------+------------------------
       1560 |       1560 | 2019/04/01〜2025/08/08 | 2019-04-01〜2025-08-08

 new_type | src_cnt | dst_cnt
----------+---------+---------
 GENERAL  |       2 |       2
 STUDENT  |       2 |       2

数値は合計、日付は最小と最大、コードは区分別の件数で照合します。件数が合っていても、ポイントが別の会員に入っているような取り違えは、ここまでの確認では見つかりません。そこで行単位の突合を加えます。

3. ハッシュ突合:行ごとに中身が一致するか

移行元を変換した結果(v_convert)と移行先(member)の両方で、項目を区切り文字でつないで md5 を取り、キーごとに比べます。

WITH src AS (
  SELECT kaiin_no AS k,
         md5(concat_ws('|', last_name, first_name, member_type, coalesce(phone,''),
             coalesce(birth_date::text,''), registered_on, points, coalesce(email,''), address)) AS h
    FROM v_convert JOIN migration_result r USING (kaiin_no) WHERE r.result = 'OK'),
dst AS (
  SELECT legacy_no AS k,
         md5(concat_ws('|', last_name, first_name, member_type, coalesce(phone,''),
             coalesce(birth_date::text,''), registered_on, points, coalesce(email,''), address)) AS h
    FROM member)
SELECT count(*) FILTER (WHERE s.h = d.h) AS matched,
       count(*) FILTER (WHERE s.h <> d.h) AS differ,
       count(*) FILTER (WHERE d.k IS NULL) AS only_src,
       count(*) FILTER (WHERE s.k IS NULL) AS only_dst
  FROM src s FULL JOIN dst d USING (k);
 matched | differ | only_src | only_dst
---------+--------+----------+----------
       4 |      0 |        0 |        0

4件とも一致しました。試しに移行先の1件のポイントを1だけ変えてから、差のある行を取り出すと、その1件が出てきます(最後の SELECT を SELECT k AS legacy_no ... WHERE s.h IS DISTINCT FROM d.h に替えたもの)。

UPDATE member SET points = points + 1 WHERE legacy_no = '000002';
 legacy_no
-----------
 000002

合計値の照合では「どこかが1ずれている」ことしか分かりませんが、ハッシュ突合なら行まで特定できます。確かめたあとは値を元に戻しています。

ハッシュを取るときの注意が2つあります。

SELECT md5('見本' || NULL || '一郎') AS with_pipes,
       md5(concat_ws('|', 'ab', 'c')) = md5(concat_ws('|', 'a', 'bc')) AS sep_same,
       md5('ab' || 'c') = md5('a' || 'bc') AS nosep_same,
       md5(concat_ws('|', 'a', NULL)) = md5(concat_ws('|', NULL, 'a')) AS null_skipped_same,
       md5(concat_ws('|', 'a', coalesce(NULL, ''))) = md5(concat_ws('|', coalesce(NULL, ''), 'a')) AS coalesce_same;
 with_pipes | sep_same | nosep_same | null_skipped_same | coalesce_same
------------+----------+------------+-------------------+---------------
            | f        | t          | t                 | f
  • || でつなぐと、NULL が1つあるだけで全体が NULL になり、ハッシュも NULL になります(with_pipes)。
  • 区切り文字なしでつなぐと、「ab」「c」と「a」「bc」が同じハッシュになります(nosep_same が t)。区切り文字を入れると区別できます。
  • concat_ws は NULL を飛ばすので、('a', NULL)と(NULL, 'a')が同じハッシュになります(null_skipped_same が t)。NULL になりうる項目は coalesce で空文字にしてからつなぎます。

Pythonで件数の内訳を検算する

1件ごとの結果を CSV に書き出し、SQLとは別の手段で内訳を数え直します。

\copy (SELECT kaiin_no, result, coalesce(left(reason, 3), '') AS code FROM migration_result ORDER BY 1) TO 'result.csv' WITH (FORMAT csv, HEADER)
import csv
from collections import Counter

rows = list(csv.DictReader(open("result.csv", encoding="utf-8")))
total = len(rows)
by_result = Counter(r["result"] for r in rows)
ok, excluded, error = by_result["OK"], by_result["EXCLUDED"], by_result["ERROR"]

print(f"移行対象   : {total}")
print(f"成功       : {ok}")
print(f"除外       : {excluded}")
print(f"エラー     : {error}")
print(f"成功+除外+エラー = {ok + excluded + error}")
print("内訳は一致" if total == ok + excluded + error else "内訳が合わない")

print("エラーの先頭コード別:")
for code, n in sorted(Counter(r["code"] for r in rows if r["result"] == "ERROR").items()):
    print(f"  {code}: {n}")

dst_count = 4  # 移行先テーブルの件数(SELECT count(*) FROM member の結果)
print("移行先の件数と成功件数:", "一致" if dst_count == ok else "不一致")
移行対象   : 12
成功       : 4
除外       : 2
エラー     : 6
成功+除外+エラー = 12
内訳は一致
エラーの先頭コード別:
  E01: 1
  E02: 2
  E05: 1
  E06: 1
  E07: 1
移行先の件数と成功件数: 一致

エラーのコード別件数を出しておくと、「E02(氏名を分けられない)が多いので、区切りが無いときのルールを定義書に足す」のように、次に直す場所が決めやすくなります。複数の理由がある行は、ここでは先頭のコードで数えています。

検証結果の報告テンプレート

検証項目    移行元 移行先 差   判定  備考
件数(移行対象)    12  ―   ―   ―   old_kaiin 全件
件数(成功)  4   4   0   OK  
件数(除外)  2   ―   ―   OK  退会済み
件数(エラー) 6   ―   ―   要対応 E01:1 E02:2 E05:1 E06:1 E07:1
内訳(成功+除外+エラー)   12  12  0   OK  
ポイント合計(成功分) 1560    1560    0   OK  
登録日の最小〜最大   2019/04/01〜2025/08/08   2019-04-01〜2025-08-08   なし  OK  
区分別件数 GENERAL   2   2   0   OK  
区分別件数 STUDENT   2   2   0   OK  
ハッシュ突合(一致/不一致)  4   4/0 0   OK  

確かめて分かったこと

  • to_date('00000000', 'YYYYMMDD') はエラーにならず、0001-01-01 BC を返した。 「不明を表す値」は関数に任せず、変換の前に判定します。
  • ::varchar(20) のキャストは25文字を黙って20文字に切り捨て、INSERT はエラーで止まった。 変換SQLにキャストを書くと、定義書の「切り捨てない」と食い違います。
  • 変換表に無いコードは、LEFT JOIN して NULL になった行を数えるだけで見つかる。 コード変換表を分けておく利点の1つです。
  • concat_ws は NULL を飛ばすため、値の入っている列がずれても同じハッシュになる。 coalesce で空文字にしてからつなぐと区別できました。
  • 「髙」「①」は cp932 では表せるが、Python の shift_jis では変換できなかった。 文字コードは正確な名前まで定義書に書きます。
  • 1件に複数のエラー理由があった(12件中2件)。 理由をすべて出す形にしておくと、流し直しの回数が減ります。

よくある質問

Q. マッピング定義書とは何ですか? A. 移行先の項目ごとに、移行元の項目・変換ルール・不正値の扱い・検証方法を書いた表です。移行プログラムを作る人と、移行結果を確かめる人の両方が、この1つの表を見て作業します。

Q. マッピング定義書は移行元と移行先のどちらを起点に書きますか? A. 移行先を起点にします。移行先の全項目を先に並べれば、移行元に対応する項目が無い必須項目(初期値が要る項目)がすぐ分かります。移行元にしか無い項目は、別の「移行しない項目一覧」に理由つきで残します。

Q. マッピング定義書とコード変換表の違いは? A. マッピング定義書は項目と項目の対応、コード変換表は値と値の対応です。値の対応が3組以上ある、複数の項目で同じ対応を使う、途中で増えそうという場合はコード変換表に分け、定義書からは表の名前で参照します。

Q. 移行元と移行先の件数が合わないときはどうすればよいですか? A. 「移行対象=成功+除外+エラー」の内訳を出します。除外とエラーを1件ごとに記録しておけば、差がどの理由で何件なのかをSQL1本で説明できます。

Q. 件数と合計が合っていれば検証は十分ですか? A. 十分とは言えません。値が別の行に入っている取り違えは、件数と合計では見つかりません。キーごとにハッシュを比べる突合を加えると、行単位で差を特定できます。

まとめ

  • マッピング定義書は移行先の項目を起点に、1行1項目で書く。
  • 1行に変換ルール・初期値・NULL/空の扱い・不正値の扱い・検証方法まで書き切る。
  • コード変換表は、値の対応が3組以上・複数項目で共用・増える見込みがあるときに分ける。
  • 定義書を書く前に移行元をSQLで下調べし、変換表に無いコード・暦に無い日付・長さ超過・重複を洗い出す。
  • 変換SQLは「変換するビュー」と「判定して結果を残す処理」に分け、エラー理由は1件につき全部出す。
  • 検証は件数(対象=成功+除外+エラー)→ 合計値・最小最大・区分別件数 → ハッシュ突合の順に細かくする。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「データ移行のマッピング定義書の書き方|項目テンプレートと件数検証SQL」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?