- 設計
データ移行のマッピング定義書の書き方|項目テンプレートと件数検証SQL
データ移行のマッピング定義書とは、移行先の項目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 | varchar(100) | 任意 | 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期連続の黒字決算。