第11章 データベース正規化 — 第1〜第3正規形と非正規化の判断
この章で学ぶこと
- 正規化の目的 (更新異常・挿入異常・削除異常の排除) を自分の言葉で説明できる
- 与えられた未正規化テーブルを 第 3 正規形 まで段階的に分解できる
- 部分関数従属 / 推移的関数従属を区別できる
- 性能や運用を理由とした選択的な非正規化の判断ができる
第 10 章 キー概念 を理解していること。本章は本ガイドの中核で、設計の核となる考え方を扱います。
正規化とは
正規化 (normalization) は、データの重複と矛盾を排除するための設計手法です。一連のルール (正規形) に従ってテーブルを分解していくことで、データを安全に管理できる状態にします。
正規化が必要な理由 — 3 つの異常
正規化されていない (非正規形の) テーブルでは、次の 3 つの異常が起きやすくなります。
問題のある設計の例
CREATE TABLE sales_unnormalized (
sale_id INTEGER PRIMARY KEY,
sale_date DATE NOT NULL,
customer_name VARCHAR(100) NOT NULL,
customer_address VARCHAR(200),
customer_phone VARCHAR(20),
product_name VARCHAR(100) NOT NULL,
product_price NUMERIC(10, 2) NOT NULL,
quantity INTEGER NOT NULL,
total_amount NUMERIC(12, 2) NOT NULL
);
INSERT INTO sales_unnormalized VALUES
(1, '2026-01-01', '山田 太郎', '東京都新宿区', '090-1111-1111', 'ノート PC', 89800, 1, 89800),
(2, '2026-01-02', '山田 太郎', '東京都新宿区', '090-1111-1111', 'マウス', 2980, 2, 5960),
(3, '2026-01-03', '鈴木 花子', '大阪府大阪市', '080-2222-2222', 'ノート PC', 89800, 1, 89800);
起きる 3 つの異常
| 異常 | 例 |
|---|---|
| 更新異常 | 山田さんが引っ越したら、山田さんの全行 (上記なら 2 行) を更新する必要がある。1 行だけ更新し忘れると、同じ「山田 太郎」さんの住所が 2 つになる |
| 挿入異常 | まだ商品を注文していない新規顧客を「顧客」として登録できない (sales_unnormalized は売上テーブルなので、sale_id がないと登録できない) |
| 削除異常 | 山田さんの最後の売上を削除すると、山田さんの住所・電話の情報も失われる (顧客情報がこのテーブルにしか存在しなかった場合) |
これらの異常を排除するため、テーブルを正規形に従って分解します。
関数従属性 — 正規化の言葉
正規化を理解する鍵は 関数従属性 (functional dependency) です。
A → B (「A が決まると B も一意に決まる」関係)
「社員番号が分かれば、社員名が一意に決まる」(同じ社員番号で違う社員名は存在しない) ということを 社員番号 → 社員名 と書きます。
部分関数従属と推移的関数従属
| 種類 | 説明 | 例 |
|---|---|---|
| 完全関数従属 | 主キー全体に依存する | (order_id, product_id) → quantity |
| 部分関数従属 | 主キーの一部だけで決まる | 主キーが (order_id, product_id) で customer_name は order_id だけで決まる |
| 推移的関数従属 | A → B → C のとき、A → C が成り立つ | 社員ID → 部署ID → 部署名 のとき 社員ID → 部署名 |
部分関数従属と推移的関数従属は、それぞれ第 2 正規形 / 第 3 正規形で排除する対象です。
第 1 正規形 (1NF) — セルには 1 つの値だけ
第 1 正規形は、次の条件を満たすことを要求します。
- 各セル (列の値) に原子的な値だけが入っている (それ以上分割できない)
- 繰り返しグループが存在しない (1 行に「複数の商品」のような繰り返しがない)
- 各行が一意 (主キーが存在)
第 1 正規化の例
CREATE TABLE orders_1nf (
order_id INTEGER NOT NULL,
customer_name VARCHAR(100) NOT NULL,
product_name VARCHAR(100) NOT NULL,
quantity INTEGER NOT NULL,
unit_price NUMERIC(10, 2) NOT NULL,
PRIMARY KEY (order_id, product_name)
);
PostgreSQL の INTEGER[] 配列型や JSONB 型を使うと、1 セルに複数値が入るように見えます。これは厳密には第 1 正規形違反ですが、「半構造化データ」を扱う現代の現実的な妥協としてしばしば使われます。正規化の理論を理解した上で、意図して使う分には問題ありません。逆に、初学者が「カラム数を減らしたいから」と何でも JSON に詰め込むのは避けてください。検索・JOIN が困難になります。
第 2 正規形 (2NF) — 部分関数従属の排除
第 2 正規形は、「第 1 正規形 + 部分関数従属が存在しない」状態です。
主キーが単一カラムなら、第 1 正規形を満たした時点で自動的に第 2 正規形も満たします (部分関数従属は複合主キーでしか起きない)。問題になるのは 複合主キーのケース です。
部分関数従属の例
orders_1nf テーブルの主キーは (order_id, product_name) の複合主キーです。ここで customer_name は order_id だけで決まります (1 つの注文には 1 人の顧客しかいないため)。これが部分関数従属です。
第 2 正規化の手順
-- 注文テーブル (order_id だけで決まる属性を集約)
CREATE TABLE orders_2nf (
order_id INTEGER PRIMARY KEY,
customer_name VARCHAR(100) NOT NULL,
customer_address VARCHAR(200),
order_date DATE NOT NULL
);
-- 注文明細テーブル ((order_id, product_name) の複合主キーに完全関数従属するもの)
CREATE TABLE order_items_2nf (
order_id INTEGER NOT NULL REFERENCES orders_2nf(order_id),
product_name VARCHAR(100) NOT NULL,
quantity INTEGER NOT NULL,
unit_price NUMERIC(10, 2) NOT NULL,
PRIMARY KEY (order_id, product_name)
);
customer_name が orders_2nf (主キー = order_id 単独) に移動したため、部分関数従属が解消されました。
第 3 正規形 (3NF) — 推移的関数従属の排除
第 3 正規形は、「第 2 正規形 + 推移的関数従属が存在しない」状態です。
推移的関数従属の例
employees テーブルが employee_id を主キーとし、department_id と department_name を持っているとします。
employee_id → department_id → department_name の関係から、employee_id → department_name が推移的に成り立ちます。この場合、department_name を employees テーブルから取り除き、departments テーブルに分離します。
第 3 正規化の手順
-- 社員テーブル (部署名は持たない)
CREATE TABLE employees_3nf (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
department_id INTEGER NOT NULL REFERENCES departments_3nf(id),
hire_date DATE NOT NULL
);
-- 部署テーブル (部署名はここに保管)
CREATE TABLE departments_3nf (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
location VARCHAR(100)
);
departments_3nf.name を変更しても、employees_3nf には影響しません (部署名を取得するには JOIN が必要)。これで「部署名を 100 人分書き換える」更新異常が解消されます。
段階的正規化の実践例 — EC サイトの注文
非正規形から第 3 正規形までを 1 つの例で見てみましょう。
Step 0: 非正規形 (1 つのカラムに全部詰め込む)
order_data カラム: "注文1: 山田太郎(東京都), 商品: PC×1, マウス×2"
これは検索も集計もできないため、まず構造化する必要があります。
Step 1: 第 1 正規形
CREATE TABLE orders_1nf (
order_id INTEGER,
customer_name VARCHAR(100),
customer_address VARCHAR(200),
product_name VARCHAR(100),
quantity INTEGER,
unit_price NUMERIC(10, 2),
PRIMARY KEY (order_id, product_name)
);
1 セル 1 値にし、繰り返しグループ (商品の配列) を行に展開しました。
Step 2: 第 2 正規形 (部分関数従属の解消)
-- 注文テーブル (order_id で決まる属性をまとめる)
CREATE TABLE orders_2nf (
order_id INTEGER PRIMARY KEY,
customer_name VARCHAR(100) NOT NULL,
customer_address VARCHAR(200),
order_date DATE NOT NULL
);
-- 注文明細テーブル
CREATE TABLE order_items_2nf (
order_id INTEGER NOT NULL REFERENCES orders_2nf(order_id),
product_name VARCHAR(100) NOT NULL,
quantity INTEGER NOT NULL,
unit_price NUMERIC(10, 2) NOT NULL,
PRIMARY KEY (order_id, product_name)
);
Step 3: 第 3 正規形 (推移的関数従属の解消)
-- 顧客テーブル (注文から分離)
CREATE TABLE customers_3nf (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
address VARCHAR(200)
);
-- 商品テーブル (注文明細から分離)
CREATE TABLE products_3nf (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
unit_price NUMERIC(10, 2) NOT NULL
);
-- 注文テーブル (顧客への外部キー)
CREATE TABLE orders_3nf (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers_3nf(id),
order_date DATE NOT NULL
);
-- 注文明細テーブル (注文と商品への外部キー、価格はスナップショット)
CREATE TABLE order_items_3nf (
order_id INTEGER NOT NULL REFERENCES orders_3nf(id),
product_id INTEGER NOT NULL REFERENCES products_3nf(id),
quantity INTEGER NOT NULL,
unit_price NUMERIC(10, 2) NOT NULL, -- 注文時の価格 (スナップショット)
PRIMARY KEY (order_id, product_id)
);
ここまで分解すると、顧客の引っ越しは customers_3nf の 1 行を更新するだけ、商品の価格改定は products_3nf の 1 行 (将来の注文に反映、過去の order_items_3nf は注文時価格のまま) という運用が成り立ちます。
BCNF (Boyce-Codd Normal Form) — 3NF の上位
BCNF は 3NF を満たし、かつより厳しい条件を加えたもので「すべての非自明な関数従属性の左辺がスーパーキーである」状態です (Wikipedia: Boyce–Codd normal form 参照)。
多くの業務アプリは 3NF で十分です。BCNF は「複数の候補キーがあり、その関数従属性が複雑な特殊なケース」で必要になります。ただし BCNF への分解は dependency-preserving (関数従属性の保存) ができないケースがあり、それ自体がトレードオフです。Jr が遭遇する場面は限定的なので、本ガイドでは詳細な分解手順は扱わず、「3NF を目指し、複雑なケースで BCNF を検討する」と覚えておけば十分です。
BCNF が必要になる例 (参考)
社員が複数の勤務地を持ち (同じ都市には 1 勤務地まで)、勤務地を郵便番号で記録する場合を考えます。1 つの郵便番号は 1 つの都市に属します (郵便番号 → 都市)。
| 社員 | 都市 | 郵便番号 |
|---|---|---|
| 田中 | 千代田区 | 100-0001 |
| 田中 | 大阪市北区 | 530-0001 |
| 鈴木 | 千代田区 | 100-0001 |
関数従属性は (社員, 都市) → 郵便番号 と 郵便番号 → 都市 の 2 つ。候補キーは (社員, 都市) と (社員, 郵便番号) の2 つあり、全属性がいずれかの候補キーに含まれます (すべて素属性)。3NF は「候補キーに含まれる属性 (素属性) への依存」を例外として許すため、このテーブルは 3NF を満たします。しかし 郵便番号 → 都市 の左辺 郵便番号 はスーパーキーでないため BCNF は満たしません — 実害として、同じ郵便番号の行が増えるたびに「郵便番号と都市の対応」が重複保存され、表記修正時に複数行の更新が必要になります。BCNF に分解するなら (社員, 郵便番号) テーブルと (郵便番号, 都市) テーブルに分けます。
正規化のメリット・デメリット
性能のための選択的非正規化
正規化は理想形ですが、性能要求や運用都合で意図的に非正規化 (denormalization) することがあります。現代ベストプラクティス (oneuptime denormalization 2026-01) は 「まず正規化、その後で測定ベースに選択的非正規化」 です。
非正規化の例
CREATE TABLE sales_summary (
id BIGSERIAL PRIMARY KEY,
order_id INTEGER NOT NULL,
order_date DATE NOT NULL,
customer_name VARCHAR(100) NOT NULL, -- 冗長: customers.name の複製
product_name VARCHAR(100) NOT NULL, -- 冗長: products.name の複製
quantity INTEGER NOT NULL,
unit_price NUMERIC(10, 2) NOT NULL,
total_amount NUMERIC(12, 2) NOT NULL, -- 冗長: 計算で導ける
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_summary_date ON sales_summary(order_date);
CREATE INDEX idx_summary_customer ON sales_summary(customer_name);
-- 高速な集計クエリ (JOIN なしで動く)
SELECT customer_name, SUM(total_amount) AS total_sales
FROM sales_summary
WHERE order_date BETWEEN '2026-01-01' AND '2026-12-31'
GROUP BY customer_name;
このテーブルは「過去の集計を高速に取り出す」目的に特化しています。元の正規化されたテーブルから日次バッチで同期する運用が一般的です。
非正規化の判断軸
| 軸 | 正規化を維持 | 非正規化を検討 |
|---|---|---|
| 更新頻度 | 高い (毎秒更新) | 低い (日次更新) |
| 参照頻度 | 低い | 非常に高い (集計レポート) |
| データ整合性の要求 | 強い | レポート用なら多少のズレ許容 |
| クエリの JOIN 数 | 少ない (2-3 個) | 多い (10 個以上) |
- 最初から非正規化しない: 正規化を起点に、測定で性能ボトルネックが見えてから対処
- 二重管理になることを意識: 正規化テーブルと非正規化テーブルの整合性を保つ仕組みが必要
- 古典的なやり方: バッチで同期 / マテリアライズドビュー / イベント駆動の更新
- トリガーは慎重に: ロック競合・デッドロックのリスク (第 14 章 多対多 で扱う)
演習
次のテーブルはどの正規形までを満たしているでしょうか? その理由とともに答えてください。
attendances
+---------+---------+--------------+-------------+-----------+
| 社員ID | 出社日 | 部署ID | 部署名 | 勤務時間 |
+---------+---------+--------------+-------------+-----------+
| 1001 | 1/15 | 10 | 営業部 | 8.0 |
| 1001 | 1/16 | 10 | 営業部 | 7.5 |
| 1002 | 1/15 | 20 | 開発部 | 8.5 |
+---------+---------+--------------+-------------+-----------+
主キー: (社員ID, 出社日)
解答例
このテーブルは 第 1 正規形は満たすが、第 2 正規形も第 3 正規形も満たさない。
第 1 正規形 ◯: 各セルが原子的な値で、繰り返しグループなし、主キーがある。
第 2 正規形 ✕: 主キーは複合 (社員ID, 出社日)。部署ID は 社員ID だけで決まる (出社日に依存しない) ため、部分関数従属が存在。同様に 部署名 も 社員ID だけで決まる。
第 3 正規形 ✕: 仮に第 2 正規形を満たしていたとしても、社員ID → 部署ID → 部署名 の推移的関数従属がある。
正規化後:
-- 第 3 正規形まで分解
CREATE TABLE departments (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL
);
CREATE TABLE employees (
id SERIAL PRIMARY KEY,
department_id INTEGER NOT NULL REFERENCES departments(id)
);
CREATE TABLE attendances (
employee_id INTEGER NOT NULL REFERENCES employees(id),
date DATE NOT NULL,
work_hours NUMERIC(4, 2) NOT NULL,
PRIMARY KEY (employee_id, date)
);
ありがちな間違い: 「部署ID と 部署名 を一緒に持っているから第 2 正規形違反」と単純に判断すること。正確には「主キーの一部 社員ID で 部署ID 部署名 が決まる」という部分関数従属が問題です。
別解 (転属を扱う場合): 社員が部署を移ることがあるなら、attendances テーブルに「その日の部署 ID」をスナップショットで持つ設計もあります。これは正規化の理論的には冗長ですが、「その日にどの部署にいたか」を後から正確に知るために意図的なスナップショット で、注文明細に単価をスナップショットで持つのと同じ考え方です。
次のテーブルを、第 1 正規形 → 第 2 正規形 → 第 3 正規形 と段階的に分解してください。各段階で何を排除したかを明記すること。
courses
+----------+----------+----------+---------------+----------+----------+--------------+
| 学生ID | 学生名 | 講座ID | 講座名 | 講師ID | 講師名 | 受講料 |
+----------+----------+----------+---------------+----------+----------+--------------+
| 1 | 山田 | C101 | データベース | T01 | 田中 | 10000 |
| 1 | 山田 | C102 | プログラミング | T02 | 鈴木 | 15000 |
| 2 | 佐藤 | C101 | データベース | T01 | 田中 | 10000 |
+----------+----------+----------+---------------+----------+----------+--------------+
主キー: (学生ID, 講座ID)
仮定: 1 講座には 1 講師、1 講座の受講料は固定
解答例
Step 0 (元): 第 1 正規形を満たすが、第 2/3 正規形は満たさない。
Step 1: 第 2 正規形に分解 (部分関数従属の排除)
主キー (学生ID, 講座ID) のうち:
学生名は学生IDだけで決まる → 部分関数従属講座名/講師ID/講師名/受講料は講座IDだけで決まる → 部分関数従属
分解:
-- 学生テーブル (学生IDで決まる属性)
CREATE TABLE students_2nf (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
-- 講座テーブル (講座IDで決まる属性、まだ講師名と受講料を持つ)
CREATE TABLE courses_2nf (
id VARCHAR(10) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
teacher_id VARCHAR(10) NOT NULL,
teacher_name VARCHAR(100) NOT NULL,
fee NUMERIC(10, 2) NOT NULL
);
-- 受講テーブル (複合主キーに完全関数従属するもののみ)
CREATE TABLE enrollments_2nf (
student_id INTEGER NOT NULL REFERENCES students_2nf(id),
course_id VARCHAR(10) NOT NULL REFERENCES courses_2nf(id),
PRIMARY KEY (student_id, course_id)
);
Step 2: 第 3 正規形に分解 (推移的関数従属の排除)
courses_2nf で 講座ID → 講師ID → 講師名 の推移的関数従属がある (1 講座 → 1 講師、1 講師 → 1 講師名)。
-- 講師テーブル (講師IDで決まる属性を分離)
CREATE TABLE teachers_3nf (
id VARCHAR(10) PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
-- 講座テーブル (講師名を持たない)
CREATE TABLE courses_3nf (
id VARCHAR(10) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
teacher_id VARCHAR(10) NOT NULL REFERENCES teachers_3nf(id),
fee NUMERIC(10, 2) NOT NULL
);
-- 学生・受講テーブルは変わらず
最終形:
students_3nf(id, name)courses_3nf(id, name, teacher_id, fee)teachers_3nf(id, name)enrollments_3nf(student_id, course_id) ※主キーは複合
ありがちな間違い:
受講料をenrollmentsに置く: 「1 講座の受講料は固定」という仮定があるので、coursesに置くのが正しい。逆に「学生ごとに受講料が違う」(割引などある) ならenrollmentsに置く判断もあり得る学生名をenrollmentsにも残す: 第 2 正規形に違反する。学生情報はstudentsに集約する
別解 (実務向け): 講座の受講料が時期で変わるなら、enrollments に「受講申込時の受講料」をスナップショットで持つ。これは「正規化を犠牲にしてでも、時系列の正確性を取る」設計判断で、注文明細の単価と同じパターン。
まとめ
この章で学んだことを整理します。
- 正規化はデータの重複と矛盾 (更新異常・挿入異常・削除異常) を排除する設計手法
- 第 1 正規形: セルは原子値・繰り返しグループなし・主キーあり
- 第 2 正規形: 第 1NF + 部分関数従属を排除 (複合主キーの一部で決まる属性を分離)
- 第 3 正規形: 第 2NF + 推移的関数従属を排除 (連鎖する依存を分離)
- 業務アプリでは 3NF で十分なことが多い (BCNF は複雑な特殊ケース)
- 性能要求で意図的に非正規化することはあるが、まず正規化を起点に、測定ベースで判断する
次章では、設計を視覚的に表現する E-R 図の作り方を学びます。