メインコンテンツまでスキップ

第11章 データベース正規化 — 第1〜第3正規形と非正規化の判断

この章で学ぶこと

  • 正規化の目的 (更新異常・挿入異常・削除異常の排除) を自分の言葉で説明できる
  • 与えられた未正規化テーブルを 第 3 正規形 まで段階的に分解できる
  • 部分関数従属 / 推移的関数従属を区別できる
  • 性能や運用を理由とした選択的な非正規化の判断ができる
前提知識

第 10 章 キー概念 を理解していること。本章は本ガイドの中核で、設計の核となる考え方を扱います。

正規化とは

正規化 (normalization) は、データの重複と矛盾を排除するための設計手法です。一連のルール (正規形) に従ってテーブルを分解していくことで、データを安全に管理できる状態にします。

正規化が必要な理由 — 3 つの異常

正規化されていない (非正規形の) テーブルでは、次の 3 つの異常が起きやすくなります。

問題のある設計の例

売上を 1 つのテーブルに詰め込んだ設計
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_nameorder_id だけで決まる
推移的関数従属A → B → C のとき、A → C が成り立つ社員ID → 部署ID → 部署名 のとき 社員ID → 部署名

部分関数従属と推移的関数従属は、それぞれ第 2 正規形 / 第 3 正規形で排除する対象です。

第 1 正規形 (1NF) — セルには 1 つの値だけ

第 1 正規形は、次の条件を満たすことを要求します。

  1. 各セル (列の値) に原子的な値だけが入っている (それ以上分割できない)
  2. 繰り返しグループが存在しない (1 行に「複数の商品」のような繰り返しがない)
  3. 各行が一意 (主キーが存在)

第 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)
);
配列カラム・JSON カラムは第 1 正規形に違反?

PostgreSQL の INTEGER[] 配列型や JSONB 型を使うと、1 セルに複数値が入るように見えます。これは厳密には第 1 正規形違反ですが、「半構造化データ」を扱う現代の現実的な妥協としてしばしば使われます。正規化の理論を理解した上で、意図して使う分には問題ありません。逆に、初学者が「カラム数を減らしたいから」と何でも JSON に詰め込むのは避けてください。検索・JOIN が困難になります。

第 2 正規形 (2NF) — 部分関数従属の排除

第 2 正規形は、「第 1 正規形 + 部分関数従属が存在しない」状態です。

主キーが単一カラムなら、第 1 正規形を満たした時点で自動的に第 2 正規形も満たします (部分関数従属は複合主キーでしか起きない)。問題になるのは 複合主キーのケース です。

部分関数従属の例

orders_1nf テーブルの主キーは (order_id, product_name) の複合主キーです。ここで customer_nameorder_id だけで決まります (1 つの注文には 1 人の顧客しかいないため)。これが部分関数従属です。

第 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
);

-- 注文明細テーブル ((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_nameorders_2nf (主キー = order_id 単独) に移動したため、部分関数従属が解消されました。

第 3 正規形 (3NF) — 推移的関数従属の排除

第 3 正規形は、「第 2 正規形 + 推移的関数従属が存在しない」状態です。

推移的関数従属の例

employees テーブルが employee_id を主キーとし、department_iddepartment_name を持っているとします。

employee_id → department_id → department_name の関係から、employee_id → department_name が推移的に成り立ちます。この場合、department_nameemployees テーブルから取り除き、departments テーブルに分離します。

第 3 正規化の手順

第 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 で十分なことが多い

多くの業務アプリは 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 章 多対多 で扱う)

演習

演習 1: 正規化判定クイズ

次のテーブルはどの正規形までを満たしているでしょうか? その理由とともに答えてください。

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」をスナップショットで持つ設計もあります。これは正規化の理論的には冗長ですが、「その日にどの部署にいたか」を後から正確に知るために意図的なスナップショット で、注文明細に単価をスナップショットで持つのと同じ考え方です。

演習 2: 段階的正規化

次のテーブルを、第 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) ※主キーは複合

ありがちな間違い:

  1. 受講料enrollments に置く: 「1 講座の受講料は固定」という仮定があるので、courses に置くのが正しい。逆に「学生ごとに受講料が違う」(割引などある) なら enrollments に置く判断もあり得る
  2. 学生名enrollments にも残す: 第 2 正規形に違反する。学生情報は students に集約する

別解 (実務向け): 講座の受講料が時期で変わるなら、enrollments に「受講申込時の受講料」をスナップショットで持つ。これは「正規化を犠牲にしてでも、時系列の正確性を取る」設計判断で、注文明細の単価と同じパターン。

まとめ

この章で学んだことを整理します。

  • 正規化はデータの重複と矛盾 (更新異常・挿入異常・削除異常) を排除する設計手法
  • 第 1 正規形: セルは原子値・繰り返しグループなし・主キーあり
  • 第 2 正規形: 第 1NF + 部分関数従属を排除 (複合主キーの一部で決まる属性を分離)
  • 第 3 正規形: 第 2NF + 推移的関数従属を排除 (連鎖する依存を分離)
  • 業務アプリでは 3NF で十分なことが多い (BCNF は複雑な特殊ケース)
  • 性能要求で意図的に非正規化することはあるが、まず正規化を起点に、測定ベースで判断する

次章では、設計を視覚的に表現する E-R 図の作り方を学びます。