データベース正規化を改めて整理する(第1〜第5正規形)
2026-03-29
データベース設計の経験がある人でも、正規化を「なんとなく」やっていることは多い。「重複を排除する」「テーブルを分ける」という理解で止まっていて、第3正規形以降を意識したことがない、というケースもある。
SQLアンチパターンを読んで正規化を改めて整理したので、自分の理解をまとめておく。例はポケモンのゲームデータで統一する。
正規化とは何か
C.J. Dateの定義によると、正規化とは人間が理解できる形で現実世界の事実を表現することとされている。
一言で言えば、データを「正確に」格納すること。事実の格納方法から冗長性を排除して、異常や不整合が起きない構造を作る。「重複を排除する」「テーブルを分ける」は手段であって目的ではない。
より実務的な言い方をすれば、正規化とはデータの冗長性と異常(アノマリ)を排除するために、テーブル構造を整理するプロセスのこと。
異常(アノマリ)には3種類ある(参考:OSS-DB道場 第4回)。
- 挿入異常:必要なデータが揃わないと行を挿入できない
- 更新異常:同じ情報が複数箇所にあり、片方だけ更新すると不整合が起きる
- 削除異常:ある行を削除すると、関係のない別の情報まで失われる
正規化はこれらを防ぐための設計指針であって、「パフォーマンスを上げるもの」でも「テーブルを細かく分けるもの」でもない。
よくある誤解
「正規化するとパフォーマンスが落ちる」
テーブルを分けるとJOINが増えてクエリが重くなる、という話はある。ただそれは正規化の問題ではなく、インデックス設計やクエリの問題であることが多い。パフォーマンスを理由に意図的に非正規化する場合は、その判断を明示的に記録しておくべきで、「なんとなく非正規化」は後から問題になる。
「第3正規形まで満たせばいい」
実務では第3正規形で止まることが多いのは事実。ただ第4・第5正規形を知らないまま設計すると、そもそも問題が起きていることに気づけない。理解しておくことと、毎回適用することは別の話。
「正規化は常に正しい」
読み取り専用のレポートテーブルや、ログ・イベントの記録など、非正規化が合理的な場面はある。正規化はあくまで手段であって目的ではない。
前提:主キーとは
正規化の話は「主キー」を基準に進むので、先に整理しておく。
主キー(Primary Key)とは、テーブルの各行を一意に識別できる列(または列の組み合わせ)のこと。同じ値が2行に存在してはならず、NULLも許容されない。
pokemons テーブル
| pokemon_id | name |
|------------|------------|
| 1 | フシギダネ |
| 4 | ヒトカゲ |
| 25 | ピカチュウ |
この例では pokemon_id が主キー。pokemon_id の値を指定すれば、必ず1行だけに絞り込める。
複合主キー
2列以上の組み合わせで行を識別する場合を複合主キーという。
pokemon_moves テーブル(ポケモンの技スロット)
| pokemon_id | move_slot | move |
|------------|-----------|----------------|
| 25 | 1 | でんきショック |
| 25 | 2 | かみなり | ← pokemon_idは重複してもOK
| 25 | 3 | なきごえ |
| 6 | 1 | かえんほうしゃ | ← move_slotも重複してもOK
| 6 | 2 | たいあたり |
pokemon_id だけでは同じポケモンの複数の技を区別できない。move_slot だけでも複数のポケモンが同じスロット番号を持てる。(pokemon_id, move_slot) の組み合わせで初めて一意になる。これが複合主キー。
2NFの話は「複合主キーのとき」に起きる問題なので、ここを押さえておくと理解しやすい。
候補キー
主キーになれる列または列の組み合わせが複数ある場合、そのすべてを候補キーという。そのうち1つを選んで主キーとする。BCNFの話で出てくる。
第1正規形(1NF)
条件:繰り返しグループを排除し、各列が原子値を持つこと
原子値とは、それ以上分割できない値のこと。カンマ区切りのリストや、配列を1つのセルに入れているのは1NFを満たしていない。
違反例1:カンマ区切りリスト(ジェイウォーク)
ポケモンが覚えている技を1列に詰め込むと問題になる。
pokemons テーブル
| pokemon_id | name | moves |
|------------|------------|--------------------------------------------|
| 25 | ピカチュウ | でんきショック, かみなり, なきごえ |
| 6 | リザードン | かえんほうしゃ, たいあたり, りゅうのいぶき |
「かみなりを覚えているポケモンを検索する」クエリが LIKE '%かみなり%' になり、インデックスが使えない。SQLアンチパターンではこのパターンを「ジェイウォーク(信号無視)」と呼んでいる。
違反例2:列を増やして繰り返す(マルチカラムアトリビュート)
ポケモンのタイプは最大2つ。「type1・type2の2列で十分では」と考えると次のようになる。
pokemons テーブル
| pokemon_id | name | type1 | type2 |
|------------|------------|--------|--------|
| 1 | フシギダネ | くさ | どく |
| 6 | リザードン | ほのお | ひこう |
| 25 | ピカチュウ | でんき | NULL |
| 52 | ニャース | ノーマル | NULL |
実際のゲームのデータファイルもこの構造になっていることが多い。しかし問題がある。
- 単タイプのポケモン(ピカチュウ、ニャース)で
type2が NULL になる - 「でんきタイプを含むポケモン」の検索が
WHERE type1 = 'でんき' OR type2 = 'でんき'と複雑になる
SQLアンチパターンではこれを「マルチカラムアトリビュート」と呼んでいる。
修正例(共通)
どちらも解決策は同じ。別テーブルに切り出す。技はタイプ・威力・命中率を持つので、技マスタも分けて管理する。
moves テーブル(技マスタ)
| move_id | name | type | power | accuracy |
|---------|----------------|----------|-------|----------|
| 1 | でんきショック | でんき | 40 | 100 |
| 2 | かみなり | でんき | 110 | 70 |
| 3 | なきごえ | ノーマル | — | 100 |
| 4 | かえんほうしゃ | ほのお | 90 | 100 |
| 5 | たいあたり | ノーマル | 35 | 95 |
pokemon_types テーブル
| pokemon_id | type |
|------------|----------|
| 1 | くさ |
| 1 | どく |
| 6 | ほのお |
| 6 | ひこう |
| 25 | でんき |
| 52 | ノーマル |
pokemon_moves テーブル(手持ちの技スロット)
| pokemon_id | move_slot | move_id |
|------------|-----------|---------|
| 25 | 1 | 1 |
| 25 | 2 | 2 |
| 25 | 3 | 3 |
| 6 | 1 | 4 |
| 6 | 2 | 5 |
erDiagram
pokemons {
int pokemon_id PK
string name
}
moves {
int move_id PK
string name
string type
int power
int accuracy
}
pokemon_types {
int pokemon_id FK
string type
}
pokemon_moves {
int pokemon_id FK
int move_slot
int move_id FK
}
pokemons ||--o{ pokemon_types : "タイプ"
pokemons ||--o{ pokemon_moves : "技スロット"
moves ||--o{ pokemon_moves : "技"
第2正規形(2NF)
条件:1NFを満たし、非キー列が主キー全体に完全関数従属していること
難しく聞こえるが、要するに「主キーが複数列の組み合わせ(複合主キー)のとき、その一部だけで決まってしまう列がないか」を確認するということ。
「関数従属」とは「Aがわかれば自動的にBが決まる」という関係のこと。たとえば pokemon_id がわかれば pokemon_name は一意に決まる。これを「pokemon_name は pokemon_id に関数従属している」という。
「完全」関数従属とは、複合主キーの全部があって初めて決まるということ。主キーの一部だけで決まってしまう列があると2NFに違反する。
違反例
図鑑レコードを管理するテーブル。各トレーナーは同じポケモン種族を図鑑に1回だけ記録する(捕まえた・見たの記録)。ポケモンの名前と種族値HPも一緒に入れてしまっている。
pokedex_records テーブル
| trainer_id | pokemon_id | status | pokemon_name | base_hp |
|------------|------------|---------|--------------|---------|
| 1 | 25 | caught | ピカチュウ | 35 |
| 1 | 4 | seen | ヒトカゲ | 39 |
| 2 | 25 | caught | ピカチュウ | 35 | ← 重複
| 3 | 25 | seen | ピカチュウ | 35 | ← 重複
主キーは (trainer_id, pokemon_id) の複合キー。pokemon_name と base_hp(種族値HP)は種族ごとに固定の値で、pokemon_id だけで決まる。主キー全体への完全従属ではない。
ピカチュウの種族値HPがバランス調整で変更された場合、pokedex_records テーブルの pokemon_id = 25 を含む全行を更新しなければならない(更新異常)。
修正例
pokedex_records テーブル
| trainer_id | pokemon_id | status |
|------------|------------|---------|
| 1 | 25 | caught |
| 1 | 4 | seen |
| 2 | 25 | caught |
| 3 | 25 | seen |
pokemons テーブル
| pokemon_id | name | base_hp |
|------------|------------|---------|
| 25 | ピカチュウ | 35 |
| 4 | ヒトカゲ | 39 |
ピカチュウの情報は pokemons テーブルに1行だけ存在する。種族値が変わっても1行変えるだけで済む。
erDiagram
pokedex_records {
int trainer_id PK
int pokemon_id PK
string status
}
pokemons {
int pokemon_id PK
string name
int base_hp
}
pokedex_records }o--|| pokemons : "pokemon_id"
第3正規形(3NF)
条件:2NFを満たし、非キー列が他の非キー列に推移的関数従属していないこと
2NFとの違いを先に整理しておく。
- 2NF:「主キーの一部」で決まってしまう列がある。複合主キーが前提の問題。
- 3NF:「非キー列が別の非キー列」で決まってしまう。主キーが1列でも起きる。
つまり3NFは「主キーとは関係なく、普通の列が別の普通の列に依存している」という問題を扱う。「非キー列A → 非キー列B」という連鎖(推移的従属)があると違反になる。
違反例
トレーナーのテーブルに出身地のIDと地名を両方入れている。
trainers テーブル
| trainer_id | name | hometown_id | hometown_name |
|------------|--------|-------------|----------------|
| 1 | サトシ | 1 | マサラタウン |
| 2 | シゲル | 1 | マサラタウン | ← hometown_nameが重複
| 3 | タケシ | 2 | ニビシティ |
| 4 | カスミ | 3 | ハナダシティ |
| 5 | ケンジ | 2 | ニビシティ | ← hometown_nameが重複
hometown_name は hometown_id によって決まる。hometown_id は非キー列なので、hometown_name が主キーに直接依存していない(推移的従属)。
地名の表記を変更する場合(「ニビシティ」→「石の街ニビ」など)、該当の町出身のトレーナー全行を更新しなければならない(更新異常)。
修正例
trainers テーブル
| trainer_id | name | hometown_id |
|------------|--------|-------------|
| 1 | サトシ | 1 |
| 2 | シゲル | 1 |
| 3 | タケシ | 2 |
| 4 | カスミ | 3 |
| 5 | ケンジ | 2 |
hometowns テーブル
| hometown_id | hometown_name |
|-------------|----------------|
| 1 | マサラタウン |
| 2 | ニビシティ |
| 3 | ハナダシティ |
「ニビシティ」の表記変更は hometowns テーブルの1行を変えるだけ。trainers は一切触らなくていい。
erDiagram
trainers {
int trainer_id PK
string name
int hometown_id FK
}
hometowns {
int hometown_id PK
string hometown_name
}
trainers }o--|| hometowns : "hometown_id"
多くの実務ではここまでで止まる。
ボイスコッド正規形(BCNF)
条件:すべての決定因子が候補キーであること
3NFとの違いを先に整理する。
3NFは「非キー列が非キー列を決めてはいけない」というルールだった。ただし3NFには抜け穴がある。「非キー列が候補キーの一部を決める」ケースは3NFを満たしていてもBCNFに違反する。
BCNFは「何かを決定する列(決定因子)はすべて候補キーでなければならない」と言っている。非キー列が何かを決定する構造そのものを禁止している。
違反例
ジムバッジの挑戦記録を管理するテーブル。ルールは以下とする。
- 1人のジムリーダーは1つのタイプを専門とする(カスミはみず、マチスはでんき、など)
- 1人のトレーナーは同じタイプのジムには1回しか挑戦できない(担当ジムリーダーが一意に決まる)
gym_challenges テーブル
| trainer_id | type | gym_leader |
|------------|---------|------------|
| 1 | でんき | マチス |
| 1 | みず | カスミ |
| 2 | でんき | マチス | ← マチス→でんき が重複
| 3 | でんき | マチス | ← マチス→でんき が重複
| 3 | みず | カスミ |
このとき成り立つ関係:
(trainer_id, type)→gym_leader(トレーナーとタイプが決まればジムリーダーが決まる)(trainer_id, gym_leader)→type(トレーナーとジムリーダーが決まればタイプが決まる)gym_leader→type(ジムリーダーが決まれば専門タイプが決まる)
候補キーは (trainer_id, type) と (trainer_id, gym_leader) の2つ。
問題は gym_leader → type という従属関係。gym_leader は非キー列だが type を決定している。gym_leader は候補キーではないのでBCNFに違反する。
マチスが「でんき」から「じめん」専門に変わった場合、マチスが登場する全行を更新しなければならない(更新異常)。
修正例
gym_leader → type の関係を別テーブルに切り出す。
gym_leaders テーブル
| gym_leader | type |
|------------|---------|
| マチス | でんき |
| カスミ | みず |
gym_challenges テーブル
| trainer_id | gym_leader |
|------------|------------|
| 1 | マチス |
| 1 | カスミ |
| 2 | マチス |
| 3 | マチス |
| 3 | カスミ |
gym_leaders で「ジムリーダーの専門タイプ」を管理し、gym_challenges で「誰がどのジムリーダーに挑戦したか」を管理する。マチスの専門タイプが変わっても gym_leaders の1行を変えるだけで済む。
erDiagram
gym_leaders {
string gym_leader PK
string type
}
gym_challenges {
int trainer_id PK
string gym_leader PK
}
gym_challenges }o--|| gym_leaders : "gym_leader"
第4正規形(4NF)
条件:BCNFを満たし、自明でない多値従属性がないこと
「多値従属性」とは、1つの値に対して複数の値が対応する関係のこと。たとえば「ピカチュウが覚えられる技は複数ある」という関係で、pokemon_id → move が多値従属。
4NFが問題にするのは、独立した2つの多値従属が1つのテーブルに混在しているケース。
違反例
ポケモンが習得可能な技(レベルアップやわざマシンで覚えられる技の一覧)と、野生で出現する場所を1つのテーブルで管理しようとしている。
pokemon_wild_data テーブル
| pokemon_id | move_id | location |
|------------|---------|-------------|
| 25 | 1 | トキワの森 |
| 25 | 1 | 22番道路 |
| 25 | 2 | トキワの森 |
| 25 | 2 | 22番道路 |
「ピカチュウが習得可能な技」と「ピカチュウの出現場所」は無関係だ。「でんきショック(move_id=1)を覚えるからトキワの森にいる」わけではない。しかし1つのテーブルに入れると、すべての組み合わせ(2技 × 2場所 = 4行)を記録しなければならない。
問題が起きるのは変更時。
- ピカチュウが新技「10まんボルト」を習得可能になった → 出現場所の数(2つ)だけ行を追加
- 新しい出現場所「5番道路」が追加された → 習得可能技の数(2つ)だけ行を追加
技と場所は関係ないのに、片方を変えると両方に影響する。
修正例
独立した関係を別テーブルに分ける。
learnable_moves テーブル(習得可能技)
| pokemon_id | move_id |
|------------|---------|
| 25 | 1 |
| 25 | 2 |
spawn_locations テーブル(出現場所)
| pokemon_id | location |
|------------|-------------|
| 25 | トキワの森 |
| 25 | 22番道路 |
技を追加するときは learnable_moves に1行追加するだけ。出現場所は関係ない。
erDiagram
pokemons {
int pokemon_id PK
string name
}
learnable_moves {
int pokemon_id FK
int move_id FK
}
spawn_locations {
int pokemon_id FK
string location
}
pokemons ||--o{ learnable_moves : "習得可能技"
pokemons ||--o{ spawn_locations : "出現場所"
第5正規形(5NF)
条件:4NFを満たし、結合従属性がすべて候補キーによるものであること
3項テーブルを持っていても、3つの2項テーブルのJOINで完全に再現できる場合は分解すべき、というルール。再現できるということは3項テーブルが冗長であり、更新・削除のたびに複数箇所を同時に修正しなければならない。
違反例
ポケモンリーグへの参加記録。ルールは次の通り:
- トレーナーは特定のリーグに参加登録している
- リーグごとに使用可能なポケモンが決まっている
- このルール:上記2条件とトレーナーの手持ちがすべて揃ったら、必ずその組み合わせで参加する(フルエントリー制)
league_entries テーブル
| trainer_id | pokemon_id | league_id |
|------------|------------|-----------|
| 1 | 25 | A |
| 1 | 25 | B |
| 1 | 6 | A |
| 1 | 6 | B |
| 2 | 25 | A |
このテーブルが冗長であることを、データを増やして確認する。
トレーナー2がリーグBにも追加登録したとする。
変更が必要な箇所はどこか。
trainer_leaguesに(2, B)を追加 ← これが「事実」- トレーナー2はピカチュウ(25)を持っていて、ピカチュウはリーグBで使える →
league_entriesに(2, 25, B)を追加しなければならない
この (2, 25, B) は新しい事実ではない。「トレーナー2がリーグBに登録した」という1つの事実から自動的に決まる。それでも league_entries に手動で追加しなければならず、追加し忘れると不整合が起きる。
今度はリーグAにニャース(52)が使用可能になったとする。
pokemon_leaguesに(52, A)を追加 ← これが「事実」- トレーナー1がニャースを持っていれば
league_entriesに(1, 52, A)を追加 - トレーナー2がニャースを持っていれば
league_entriesに(2, 52, A)を追加
ポケモンが1種増えるだけで、登録しているトレーナーの数だけ league_entries に行を追加しなければならない(挿入異常)。
league_entries の各行は「3つの binary テーブルから機械的に計算できる結果」に過ぎない。独立した情報を持っていないのに管理コストだけ発生する。これが5NFの違反。
修正例
league_entries を廃止し、3つの2項テーブルだけで管理する。「誰がどのポケモンでどのリーグに参加するか」の組み合わせが必要なときはJOINして求める。
SELECT tl.trainer_id, tp.pokemon_id, tl.league_id
FROM trainer_leagues tl
JOIN trainer_pokemons tp ON tl.trainer_id = tp.trainer_id
JOIN pokemon_leagues pl ON tp.pokemon_id = pl.pokemon_id
AND tl.league_id = pl.league_id
このクエリの結果は元の league_entries と完全に一致する。3項テーブルを持たなくても同じ情報が得られる。
trainer_leagues テーブル
| trainer_id | league_id |
|------------|-----------|
| 1 | A |
| 1 | B |
| 2 | A |
pokemon_leagues テーブル
| pokemon_id | league_id |
|------------|-----------|
| 25 | A |
| 25 | B |
| 6 | A |
| 6 | B |
trainer_pokemons テーブル
| trainer_id | pokemon_id |
|------------|------------|
| 1 | 25 |
| 1 | 6 |
| 2 | 25 |
erDiagram
trainers {
int trainer_id PK
}
pokemons {
int pokemon_id PK
}
leagues {
string league_id PK
}
trainer_leagues {
int trainer_id FK
string league_id FK
}
pokemon_leagues {
int pokemon_id FK
string league_id FK
}
trainer_pokemons {
int trainer_id FK
int pokemon_id FK
}
trainers ||--o{ trainer_leagues : "登録"
leagues ||--o{ trainer_leagues : ""
pokemons ||--o{ pokemon_leagues : "使用可能"
leagues ||--o{ pokemon_leagues : ""
trainers ||--o{ trainer_pokemons : "手持ち"
pokemons ||--o{ trainer_pokemons : ""
5NFの違反は実務で遭遇することは少ないが、知識として持っておくことで複雑な多対多の関係を設計するときに選択肢が広がる。
どこまで正規化するか
実務では以下が目安になる。
- OLTPシステム(Online Transaction Processing:ECサイトや業務システムなど、更新・挿入が頻繁に起きるシステム):第3正規形またはBCNFを目指す
- DWH(Data Warehouse:分析・レポート用にデータを集約したデータ基盤。読み取り主体):意図的に非正規化してJOINを減らすことが多い
- ログ・イベントテーブル:追記専用であれば非正規化で問題ないことが多い
非正規化する場合は「なぜ非正規化するか」をコメントや設計ドキュメントに残しておく。理由のない非正規化が積み重なると、後から設計意図が読めなくなる。
参考
SQLアンチパターン(Bill Karwin 著) — 正規化の問題を含む25のアンチパターンを解説している。「知っているつもりのアンチパターン」を体系的に整理したい人に向いている。