テーブル設計で詰む前に。正規化・リレーション・インデックスの判断軸
DB設計の正規化・主キー外部キー・1対多/多対多・インデックスを、SaaSの例とコピペで動くPostgreSQLで解説。NULL多用やEAVの失敗も。
「とりあえずテーブル作って、動いたらOK」
僕も昔そうでした。SaaSの管理画面を作っていたとき、ユーザー情報を1枚の大きな表に全部突っ込んだんです。プロフィール、所属チーム、権限、通知設定、ぜんぶ1行。最初は快適でした。
破綻したのは、ユーザーが2つ目のチームに入った瞬間です。「team_id」が1つしかない設計だったので、同じ人をもう1行コピーして登録するハメになった。メールアドレスが重複し、片方だけ権限を更新するバグが出て、退会処理は親子の行を取りこぼす。動いているのにデータが信用できない、という地獄の入り口でした。
テーブル設計は、名前を決める作業じゃありません。どの情報をどの表に置き、何を一意にし、どうつなぐかを決める作業です。ここを最初に外すと、あとからのコードでは取り返せない。今日は、その判断軸を順番に整理します。
この記事の要点
- テーブル設計の核は3つ。「重複させない(正規化)」「つなぐ(主キー・外部キー)」「速く引く(インデックス)」。
- 正規化は宗教じゃない。1つの事実は1か所に置くのが原則だが、表示や集計のためにあえて崩す場面もある。
- 1対多は子テーブルに外部キー、多対多は中間テーブル。この2パターンで実務の大半は片付く。
- インデックスは
WHERE/JOIN/ORDER BYに出る列へ。書き込みは少し重くなるので「全列に貼る」は損。 - よく詰むのは NULL多用・EAV(なんでも属性テーブル)・過剰正規化の3つ。コピペで動くSQLで失敗を再現しながら確認する。
スキーマを変更する運用(マイグレーション)は別の話なので、手順は姉妹記事DBマイグレーションを安全に進めるに分けました。この記事は「最初の形をどう決めるか」に集中します。
まず用語を、最短で
抽象的な説明はいらないので、SaaSのユーザー管理を例に表で押さえます。
| 用語 | ひとことで | 実務での判断 |
|---|---|---|
| テーブル | 同じ種類のデータを入れる表 | users teams projects のように責務で分ける |
| カラム | 表の1項目 | email status created_at など。必須なら NOT NULL |
| 主キー | 1行を見分ける背番号 | だいたい id。中間テーブルでは複合主キーも使う |
| 外部キー | 別の表の行を指す約束 | projects.team_id が teams.id を指す |
| ユニーク制約 | 同じ値の重複を禁止する約束 | メール、チーム内slug、決済顧客IDに使う |
| インデックス | 検索を速くする目次 | WHERE JOIN ORDER BY に出る列へ |
この中で初心者がいちばん軽視するのが、ユニーク制約と外部キーです。「アプリ側でチェックしてるから大丈夫」と思いがちですが、同時に2つのリクエストが来た瞬間に破られます。データベース側に最後の砦を置くから、バグが出ても壊れ方が小さくなる。冒頭の僕の事故も、users のメールにユニーク制約があれば、重複行を作った時点で止まっていました。
正規化は「1つの事実を1か所に」
正規化と聞くと身構えますが、原則はひとつだけです。同じ事実を2か所に書かない。それだけ。
さっきの「ユーザー1枚岩テーブル」の何が悪かったか。チーム名を users.team_name に持つと、チーム名を変えるたびに全メンバーの行を更新しないと食い違います。1人でも更新を取りこぼせば、画面によって違うチーム名が出る。これが「同じ事実が複数の行に散らばった」状態です。
直し方はシンプルで、チームはチームの表に1行だけ置き、ユーザーはそのidを指す。チーム名を変えるのは1行の更新で済みます。
正規化の段階には1NF・2NF・3NFと名前がついていますが、現場で毎回意識するのはこの3つの問いだけで十分です。
- 1つのセルに複数の値を詰めていないか(
tags列に"a,b,c"とカンマ区切り、はアウト) - 同じ事実を複数の行で繰り返していないか(チーム名のコピー)
- キーと無関係な列が混ざっていないか(
projects表にオーナーの電話番号、はオーナー側に置く)
この3つを通すと、たいてい自然に3NFまで届きます。逆に、ここを丁寧にやらず「あとで直す」と言った設計は、ほぼ直りません。
あえて正規化を崩す判断
ただし、正規化は目的じゃなく手段です。崩したほうがいい場面も確かにある。
たとえば注文明細に unit_price(注文時点の単価)を持つのは正しい非正規化です。商品マスタの価格は将来変わるので、「注文した時いくらだったか」を明細にコピーして固定する。これは重複ではなく、時点を保存しているから別物です。
もうひとつは、検索やレポートが重すぎるとき。毎回5テーブルをJOINして集計すると遅いので、読み取り専用の集計テーブルを別に持つ。ただしこれは「元データと同期する仕組み」をセットで設計しないと、古い数字を出し続ける別のバグになります。崩すなら、崩す理由を1行で説明できることが条件です。
リレーションは2パターンで足りる
つなぎ方は、実務だとほぼ2種類に集約されます。
1対多(親1つに子が複数) は、子テーブルに親のidを外部キーで持たせます。1チームに複数プロジェクト、なら projects.team_id が teams.id を指す。親に「子の配列」を持たせる必要はありません。子が親を指すだけで、両方向にたどれます。
多対多(互いに複数) は、中間テーブルを1枚はさみます。ユーザーは複数チームに入れて、チームは複数ユーザーを持つ。これを team_members という中間テーブルにして、(team_id, user_id) の複合主キーにする。冒頭の僕の事故は、まさにこれを作らずユーザー表に team_id を1つだけ持たせたのが原因でした。中間テーブルさえあれば、同じ人が3チームに入っても行をコピーせずに済みます。
判断はこれだけ。「片方が複数か、両方が複数か」。片方なら子に外部キー、両方なら中間テーブル。1対1はめったに使いませんが、巨大な列を分離したいときやアクセス権を分けたいときに使います。
コピペで動くPostgreSQLスキーマ
説明だけだと腹落ちしないので、丸ごと動くものを置きます。ローカルのPostgreSQLにそのまま流せます。既存データを壊さないよう、専用スキーマ claude_db_design_demo だけを作り直します。1対多(チーム→プロジェクト)と多対多(ユーザー⇔チーム)の両方が入っています。
DROP SCHEMA IF EXISTS claude_db_design_demo CASCADE;
CREATE SCHEMA claude_db_design_demo;
SET search_path = claude_db_design_demo;
-- ユーザー: メールは業務上1つだけ → ユニーク制約で最後の砦を置く
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL,
display_name TEXT NOT NULL, -- NULLにすると一覧・通知が壊れるので必須
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT users_email_unique UNIQUE (email),
CONSTRAINT users_email_has_at CHECK (position('@' in email) > 1)
);
-- チーム: slugはURLに使うので全体で一意
CREATE TABLE teams (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT teams_slug_unique UNIQUE (slug)
);
-- 多対多の中間テーブル: 複合主キーで「同じ人を同じチームに二重登録」を禁止
CREATE TABLE team_members (
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (team_id, user_id)
);
-- 1対多: プロジェクトは必ず1チームに属する
CREATE TABLE projects (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
created_by BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- slugは「チーム内で一意」。全体一意にすると他社とslugが衝突して使えなくなる
CONSTRAINT projects_team_slug_unique UNIQUE (team_id, slug)
);
-- 検索・並び替えに出る列へインデックスを貼る(全列には貼らない)
CREATE INDEX idx_team_members_user_id ON team_members(user_id);
CREATE INDEX idx_projects_team_status_created
ON projects(team_id, status, created_at DESC);
ポイントは3つです。team_members の複合主キーが多対多の二重登録を止め、projects.team_id の外部キーが1対多をつなぎ、projects_team_slug_unique が「チーム内でslug重複なし/チームをまたげば同じslugでもOK」を表現しています。最後のインデックスは「チームのプロジェクト一覧を新しい順に出す」というよくあるクエリのためのもので、team_id を含むので一覧と並び替えの両方に効きます。
失敗してよかった、を確認する
スキーマが本当に守れているかは、わざと壊しに行くと分かります。次のSQLは「失敗すべきINSERT」を集めたものです。それぞれエラーで止まれば、設計が仕事をしている証拠です。
SET search_path = claude_db_design_demo;
-- 先に正常データを少し入れる
INSERT INTO users (email, display_name) VALUES
('[email protected]', 'Masa'),
('[email protected]', 'Aiko');
INSERT INTO teams (name, slug) VALUES ('ClaudeCodeLab', 'clab');
INSERT INTO team_members (team_id, user_id, role) VALUES (1, 1, 'owner');
INSERT INTO projects (team_id, name, slug, created_by) VALUES
(1, 'Schema Review', 'schema-review', 1);
-- (1) メール重複 → ユニーク制約で止まる
DO $$ BEGIN
INSERT INTO users (email, display_name)
VALUES ('[email protected]', 'Duplicate Masa');
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'OK メール重複をブロック: %', SQLERRM;
END $$;
-- (2) プロジェクト名がNULL → NOT NULLで止まる
DO $$ BEGIN
INSERT INTO projects (team_id, name, slug, created_by)
VALUES (1, NULL, 'bad-null', 1);
EXCEPTION WHEN not_null_violation THEN
RAISE NOTICE 'OK NULLの名前をブロック: %', SQLERRM;
END $$;
-- (3) 存在しないチームを参照 → 外部キーで止まる
DO $$ BEGIN
INSERT INTO team_members (team_id, user_id, role)
VALUES (999, 1, 'member');
EXCEPTION WHEN foreign_key_violation THEN
RAISE NOTICE 'OK 存在しないチームをブロック: %', SQLERRM;
END $$;
-- (4) 同じ人を同じチームに二重登録 → 複合主キーで止まる
DO $$ BEGIN
INSERT INTO team_members (team_id, user_id, role)
VALUES (1, 1, 'member');
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'OK 二重メンバーをブロック: %', SQLERRM;
END $$;
-- (5) チーム内でslug重複 → 部分的なユニーク制約で止まる
DO $$ BEGIN
INSERT INTO projects (team_id, name, slug, created_by)
VALUES (1, 'Dup Slug', 'schema-review', 1);
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'OK チーム内slug重複をブロック: %', SQLERRM;
END $$;
5つ全部が NOTICE を出して通れば成功です。ここで大事なのは、これらを「面倒なエラー」ではなく「データを守った記録」として読むこと。設計レビューは、きれいな図を眺めるより、この壊れ方を先に試すほうが穴に早く気づけます。
インデックスは「引く列」にだけ
インデックスは目次です。本に目次があると探すのは速いけれど、ページを差し替えるたびに目次も直す手間が増える。データベースも同じで、読み取りは速くなる代わりに、書き込みが少し重くなり、ストレージも食う。だから「念のため全列に貼る」は損です。
貼るべき列の見分け方は明快で、WHERE JOIN ORDER BY によく出る列です。上のスキーマで idx_projects_team_status_created を (team_id, status, created_at DESC) の複合にしたのは、「あるチームの、有効なプロジェクトを、新しい順に」という1本のクエリにまるごと効かせるためです。複合インデックスは左から順に使われるので、列の並び順にも意味があります。
効いているかは EXPLAIN で確認できます。想定したインデックスが使われず Seq Scan(全件走査)になっていたら、列の順番か条件の書き方を見直すサインです。
SET search_path = claude_db_design_demo;
EXPLAIN (COSTS OFF)
SELECT id, name
FROM projects
WHERE team_id = 1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 20;
インデックスとクエリの詰め方をもっと掘りたい人は、SQL最適化を実践するにEXPLAINの読み方とN+1対策を分けて書きました。ORMで同じ設計をやりたい場合はPrismaでのDB実装が対応します。制約とインデックスの正式な仕様は、PostgreSQL公式の制約ドキュメントで確認するのが確実です。
僕がやらかした設計の失敗3つ
正直に書きます。冒頭の1枚岩以外にも、ちゃんと踏み抜いてきました。
1つ目はNULLの多用。「とりあえずNULL許可にしておけば後で困らない」と思って、projects.name まで NULL可にしたことがあります。結果、一覧画面が空白行を出し、通知メールの件名が「(空)」になり、権限判定が role IS NULL で例外的に通る穴が空きました。NULLは悪ではありません。退会日の deleted_at のように「空であることに意味がある」列もある。ダメなのは、意味を説明できないNULLです。必須なら最初から NOT NULL にする。
2つ目はEAV(なんでも属性テーブル)。「将来どんな項目が増えるか分からないから」と、entity_attributes(entity_id, key, value) みたいな汎用テーブルを作りました。一見柔軟ですが、value が全部 TEXT なので型チェックが効かず、1件の情報を組み立てるのに何度もJOINが要る。集計は地獄。結局、よく使う属性はちゃんとした列に戻しました。本当に動的な設定だけ JSONB 1列に閉じ込めるほうが、まだ扱いやすいです。
3つ目は過剰正規化。正規化を覚えたての頃、住所を「国・都道府県・市区町村・番地」と4テーブルに割って外部キーで結びました。表示するたびに4つJOIN。検索も重い。住所はほぼ常にセットで使うので、1テーブルに列で持てば十分でした。正規化は「同じ事実の重複を消す」ためのもので、「とにかく細かく割る」ことではない、と痛感しました。
Claude Codeをレビュー相手に使う
設計は一人で抱えると盲点が残ります。僕はClaude Codeを「正解を出す神託」ではなく、抜け漏れを指摘するレビュー担当として使っています。実装させるのではなく、観点を先に渡して指摘だけ出させるのがコツです。
claude -p "このPostgreSQL DDLをレビューしてください。
初心者にも分かる言葉で、次の順に指摘してください。
1. 業務上1つだけであるべき値で、ユニーク制約が抜けている列
2. NOT NULL にすべきなのにNULL許可になっている列
3. 1対多/多対多の表現として外部キーや中間テーブルが不足している箇所
4. 同じ事実を複数の表で重複させている正規化の崩れ
5. WHERE / JOIN / ORDER BY に出るのにインデックスがない列
6. 逆に、使われていないのに貼られていそうな無駄なインデックス
各指摘には『なぜ問題か』『どう直すか』『どのSQLで検証するか』を添えてください。
正解を断定せず、根拠を示してください。"
返ってきた指摘は鵜呑みにしません。「この列にインデックスを」と言われても、実際にその列で絞り込んでいないなら不要かもしれない。最後は EXPLAIN と実データで自分で確かめます。AIの速さは観点出しに使い、判断は人間が持つ。この分担が、いちばん事故りませんでした。
よくある質問
Q. 正規化はどこまでやればいい? 3NF(さっきの3つの問いを通す)で止めれば、日常のアプリはまず困りません。それ以上の高次正規形は、特殊な多値依存があるとき以外は不要です。逆に、表示や集計が重いと分かってから、理由を添えて部分的に崩します。
Q. 主キーはオートインクリメントとUUID、どっち? 社内で完結する管理画面ならオートインクリメント(連番)で十分です。複数システムでIDを生成する、URLに連番を晒したくない、分散環境で衝突を避けたい、といった事情があるならUUIDを検討します。迷うなら連番から始めて問題が出たら移行する、で間に合うことが多いです。
Q. 多対多に「追加の情報」を持たせたいときは?
中間テーブルに普通に列を足せます。team_members に role や joined_at を持たせたように、関係そのものの属性は中間テーブルが置き場所です。これが中間テーブルを「ただの連結」ではなく実体として扱う利点です。
Q. インデックスは多いほど速い?
読み取りは速くなりますが、書き込みのたびに全インデックスを更新するので挿入・更新は遅くなり、ストレージも増えます。実際に WHERE / JOIN / ORDER BY で使う列だけに絞り、EXPLAIN で効果を確認してから足すのが正解です。
Q. enum的な値は文字列とコードテーブル、どっち?
status のように選択肢が固定で少数なら、CHECK (status IN (...)) で十分です。選択肢が頻繁に増減する、表示名や並び順を管理画面から変えたい、といった場合は別テーブル(コードテーブル)にします。
まとめ
テーブル設計でやることは、結局3つに尽きます。重複させない(正規化/ユニーク制約)、正しくつなぐ(外部キー/中間テーブル)、速く引く(必要な列だけにインデックス)。この3つを最初に外すと、あとからのコードでは埋められません。
そして最後の判断は人間が持ちます。Claude Codeに設計のたたき台と「失敗すべきINSERT」を作らせ、EXPLAIN と公式ドキュメントで裏を取る。スキーマを変える運用はマイグレーション記事に分けたので、設計が固まったら次はそちらへ。
冒頭の1枚岩テーブルの事故以来、僕が設計レビューで最初にやるのは、きれいなER図を描くことではなく「壊れるはずのINSERTを5本流す」ことになりました。重複メール、NULL、外部キー違反、二重メンバー、slug衝突。これが全部ちゃんと止まる設計は、運用に入ってからもめったに裏切りません。きれいな説明より、壊れ方を先に試す。それがいちばん効きます。
DB設計の型をチームに広げたい場合はClaude Code研修・導入相談、個人で増やしたい場合は教材一覧も使ってください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。