テーブル設計のアンチパターンとは、作った時点では動くものの、データが増えたり仕様が増えたりしたときに「検索が遅い」「矛盾したデータが入る」「変更に弱い」という形で困りやすい設計の型のことです。 名前を知っていれば設計レビューで「これは○○のパターンですね」と共有でき、直し方も決まっているので、覚えておくと設計が速く正確になります。

この記事では、現場で出会う頻度の高い15パターンを、「起きること → NGのDDL → 改善後のDDL → 改善後のSQL」の順に、PostgreSQL 16.13とMySQL 8.0.46で実際に実行した結果つきで解説します。さらに、カンマ区切り列とEAVについては1万・10万・100万件で検索時間を実測し、「どの規模から差が出るか」を数字で示しました。最後に、設計レビューでそのまま使えるチェック表を載せています。パターン名の一部(ジェイウォーク、EAV、ポリモーフィック関連など)はBill Karwin著『SQLアンチパターン』で広まった呼び名ですが、本記事は書籍の要約ではなく、独自の表と実測で構成しています。正規化そのもの、NULLの扱い、インデックスが効かない書き方は別記事で扱っているため、ここでは設計の型に集中します。

テーブル設計アンチパターン15選の早見表

# パターン こうなる こうする
1 カンマ区切りで複数の値を入れる 検索が全件走査になり、外部キーも張れない 中間テーブル(必要なら配列型+GIN、JSON複数値インデックス)
2 属性を行にする(EAV) 型が守れず、条件が増えるたびに自己結合が増える 必ずある属性は列、可変部分だけ jsonb / JSON
3 ポリモーフィック関連 target_type + target_id に外部キーが張れない 対象ごとの関連テーブル、または共通の親テーブル
4 繰り返し列(tag1, tag2, tag3) 検索がORの連続になり、4つ目で ALTER TABLE 1行1値の従属テーブル
5 万能ID(自然キーの一意性を忘れる) 同じ組み合わせが何行も入る 複合主キー、または id + UNIQUE
6 変わる値を主キーにする メールアドレス変更で参照先ごと更新が必要 代理キー + 自然キーは UNIQUE
7 NULLだらけの列 <> や集計で行が消え、必須項目も守れない 種類ごとの属性を別テーブルに分け、共通列は NOT NULL
8 履歴を上書きする 値上げすると過去の注文金額まで変わる 明細に注文時の値を持つ、有効期間つきの履歴テーブル
9 日付を文字列で持つ 書式が混ざり、範囲検索とソートが崩れる DATE / TIMESTAMP 型
10 金額を浮動小数で持つ 0.1を10回足すと 0.9999999999999999 NUMERIC / DECIMAL
11 削除フラグ 退会者と同じ値で再登録できない、全クエリに条件が要る deleted_at + 部分ユニークインデックス(MySQLは生成列)
12 ツリーを親IDだけで持ち固定段数で結合 4階層目が取れない 再帰CTE、参照が多ければ閉包テーブル
13 外部キーを付けない 存在しない担当者IDが入り、結合で行が消える REFERENCES + ON DELETE の方針
14 マジックナンバー status = 9 の意味が誰にもわからない 参照テーブル、固定なら CHECK
15 万能コードマスタ 種類をまたいだ値が入り、外部キーが張れない 種類ごとの参照テーブル

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「テーブル設計アンチパターン15選|NG例と改善DDL・実測付き」

検証環境と、この記事の読み方

コードはすべてPostgreSQL 16.13とMySQL 8.0.46(sql_mode は既定の STRICT_TRANS_TABLES を含む設定)で実行し、出力をそのまま載せています。 本文のDDLはPostgreSQL構文で書き、MySQLで書き換えが必要な箇所は次の対応表と各パターンの注記で補います。実行結果の見出しに「MySQL」とあるものはMySQLの出力です。

PostgreSQL MySQL 8.0 備考
text VARCHAR(n) MySQLで主キー・ユニークにする列は長さ指定が必要
bigint GENERATED ALWAYS AS IDENTITY BIGINT AUTO_INCREMENT 代理キーの自動採番
timestamptz DATETIME(6) または TIMESTAMP(6) タイムゾーンの扱いは別記事で解説
numeric(12,2) DECIMAL(12,2) 金額はこちら
jsonb JSON 可変属性の置き場
部分インデックス WHERE deleted_at IS NULL 生成列 + UNIQUE パターン11で使用
CHECK 制約 CHECK 制約(8.0.16以降で実際に検査) パターン2・7・14で使用

1. カンマ区切りで複数の値を入れる(ジェイウォーク)

1つの列に '1,5,12' のように複数のIDを入れると、検索はLIKEの全件走査になり、存在しないIDが入っても止められません。 まず「記事とタグ」で起きることを見ます。

-- NG
CREATE TABLE articles_ng (id int PRIMARY KEY, title text NOT NULL, tag_ids text);
INSERT INTO articles_ng VALUES (1,'入門','1,5,12'),(2,'応用','5,120'),(3,'雑記','12');

-- タグ 12 を含む記事を探したい
SELECT id, title FROM articles_ng WHERE tag_ids LIKE '%12%';
 id | title
----+-------
  1 | 入門
  2 | 応用      ← 120 が引っかかっている
  3 | 雑記

区切り文字を前後に足せば正確にはなりますが(',' || tag_ids || ',' を LIKE '%,12,%')、この形はインデックスが使えず、後述の実測どおり件数に比例して遅くなります。

-- 改善:中間テーブル
CREATE TABLE articles (id int PRIMARY KEY, title text NOT NULL);
CREATE TABLE tags (id int PRIMARY KEY, name text NOT NULL UNIQUE);
CREATE TABLE article_tags (
  article_id int NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
  tag_id     int NOT NULL REFERENCES tags(id),
  PRIMARY KEY (article_id, tag_id)
);
CREATE INDEX ON article_tags (tag_id);

-- 改善後のSQL:タグ 12 を含む記事
SELECT a.id, a.title
FROM articles a JOIN article_tags at ON at.article_id = a.id
WHERE at.tag_id = 12;

-- 存在しないタグは入らない
INSERT INTO article_tags VALUES (1, 999);
 id | title
----+-------
  1 | 入門
  3 | 雑記

ERROR:  insert or update on table "article_tags" violates foreign key constraint "article_tags_tag_id_fkey"
DETAIL:  Key (tag_id)=(999) is not present in table "tags".

タグ別の記事数のような集計も GROUP BY だけで書けます。 MySQLでは FIND_IN_SET('12', tag_ids) で正確な検索はできますが、こちらも全件走査です。PostgreSQLの int[] + GINインデックス、MySQL 8.0.17以降のJSON複数値インデックス(CAST(tag_ids AS UNSIGNED ARRAY))は「タグを持つ側からしか検索しない」場合の代替になります(実測は後述)。

2. 属性を行にする(EAV)

EAV(Entity-Attribute-Value)は「項目名」と「値」を行として持つ設計で、列を増やさずに何でも保存できる代わりに、型が守れず、条件が1つ増えるごとに自己結合が1つ増えます。

-- NG
CREATE TABLE item_attrs_ng (item_id int, attr text, value text);
INSERT INTO item_attrs_ng VALUES
 (1,'color','red'),(1,'weight','1200'),(1,'size','M'),
 (2,'color','blue'),(2,'weight','800'),
 (3,'color','red'),(3,'weight','abc');   -- 数値でない値も入ってしまう

-- 「色が red で重さ 1000 超」
SELECT c.item_id
FROM item_attrs_ng c
JOIN item_attrs_ng w ON w.item_id = c.item_id AND w.attr = 'weight'
WHERE c.attr = 'color' AND c.value = 'red' AND w.value::int > 1000;
ERROR:  invalid input syntax for type integer: "abc"

PostgreSQLでは、たった1行の 'abc' のせいで検索そのものが失敗します。MySQLでは CAST(w.value AS SIGNED) が警告つきで0になり、エラーにならないまま結果から消えるため、気づくのがさらに遅れます。

(MySQL)
+---------+
| item_id |
+---------+
|       1 |
+---------+
Warning (Code 1292): Truncated incorrect INTEGER value: 'abc'
-- 改善:必ずある属性は列に、可変部分だけ jsonb(MySQL は JSON)
CREATE TABLE items (
  id        int PRIMARY KEY,
  color     text NOT NULL,
  weight_g  int  NOT NULL CHECK (weight_g > 0),
  extra     jsonb NOT NULL DEFAULT '{}'
);
INSERT INTO items VALUES (1,'red',1200,'{"size":"M"}'),(2,'blue',800,'{}');
INSERT INTO items VALUES (3,'red','abc','{}');       -- 型で止まる

SELECT id FROM items WHERE color = 'red' AND weight_g > 1000;
ERROR:  invalid input syntax for type integer: "abc"
LINE 1: INSERT INTO items VALUES (3,'red','abc','{}');

 id
----
  1

「後から項目が増えるかもしれない」という理由でEAVにする前に、増える項目が「検索条件になるか」を考えます。 検索条件になる項目は列に、表示するだけの項目は jsonb に、が目安です。

実測:カンマ区切りとEAVは、どの規模から遅くなるか

結論:1万件ではどの設計も数ミリ秒で差を体感できませんが、10万件で10倍以上、100万件では数十〜数百倍の差になります。 記事100万件(各5タグ、タグは1〜1000からランダム)で「タグ42を含む記事数」、アイテム100万件(色・重さ・サイズの3属性)で「色がredで重さ4000g超の件数」を数えました。時間はPython(psycopg 3 / PyMySQL)から見た往復時間で、ウォームアップ1回のあと5回実行した中央値です。

PostgreSQL 16.13

件数 カンマ区切り + LIKE 中間テーブル int[] + GIN EAV 自己結合 列に持つ jsonb + GIN
1万 2.09 ms 0.13 ms 0.14 ms 2.27 ms 0.36 ms 0.90 ms
10万 17.28 ms 0.66 ms 0.46 ms 15.25 ms 0.75 ms 4.88 ms
100万 91.06 ms 4.34 ms(VACUUM後 0.84 ms) 3.85 ms 261.84 ms 0.99 ms 77.08 ms

MySQL 8.0.46

件数 FIND_IN_SET 中間テーブル JSON 複数値インデックス EAV 自己結合 列に持つ
1万 2.85 ms 0.33 ms 0.49 ms 1.59 ms 0.23 ms
10万 25.14 ms 0.17 ms 1.97 ms 15.26 ms 0.74 ms
100万 196.79 ms 0.64 ms 16.46 ms 235.12 ms 4.40 ms

実行計画を見ると理由がはっきりします。カンマ区切りは全件を読んで捨てており、EAVは「重さ > 4000」を文字列の列に対して評価するため attr = 'weight_g' の100万行をすべて読んでから絞り込んでいます。

-- PostgreSQL 100万件:カンマ区切り + LIKE
Parallel Seq Scan on csv_articles (actual rows=1710 loops=3)
  Filter: (((','::text || tag_ids) || ','::text) ~~ '%,42,%'::text)
  Rows Removed by Filter: 331623

-- PostgreSQL 100万件:EAV 自己結合(抜粋)
Parallel Hash Join
  ->  Parallel Bitmap Heap Scan on eav w
        Filter: ((value)::integer > 4000)
        Rows Removed by Filter: 265163
        ->  Bitmap Index Scan on eav_attr_value_idx (actual rows=1000000 loops=1)
              Index Cond: (attr = 'weight_g'::text)

-- PostgreSQL 100万件:列に持つ
Index Only Scan using items_col_color_weight_g_idx on items_col (actual rows=20460 loops=1)
  Index Cond: ((color = 'red'::text) AND (weight_g > 4000))
-- MySQL 100万件:FIND_IN_SET
-> Filter: (0 <> find_in_set('42',csv_articles.tag_ids))  (actual time=0.0827..257 rows=4962 loops=1)
    -> Table scan on csv_articles  (actual time=0.0608..110 rows=1e+6 loops=1)

-- MySQL 100万件:中間テーブル
-> Covering index lookup on art_tags using tag_id (tag_id=42)  (actual time=0.0387..0.528 rows=4962 loops=1)

サイズの面では中間テーブルにも代償があります。 100万記事(500万行)の article_tags はインデックス込みで314MB、カンマ区切りの元テーブルは72MB、int[] + GINは118MBでした(PostgreSQL、pg_total_relation_size)。「タグから記事を探す」「タグごとに集計する」「タグを削除したら記事から外す」のどれか1つでもあれば中間テーブル、単に表示するだけなら配列型やJSONも選択肢、と判断するとよいでしょう。

3. ポリモーフィック関連

「コメントが記事にもタスクにも付く」を target_type + target_id の2列で表すと、参照先が動的に決まるため外部キーが書けず、存在しない種類やIDが入ります。

-- NG
CREATE TABLE comments_ng (
  id int PRIMARY KEY, target_type text NOT NULL, target_id int NOT NULL, body text NOT NULL
);
INSERT INTO comments_ng VALUES
 (1,'article',1,'参考になりました'),(2,'task',7,'対応します'),(3,'artcle',1,'typo');  -- 3 は種類名の打ち間違い
SELECT * FROM comments_ng WHERE target_type = 'article';
 id | target_type | target_id |       body
----+-------------+-----------+------------------
  1 | article     |         1 | 参考になりました

3件目は「記事へのコメント」のつもりで入れたのに、どこからも見えなくなりました。

-- 改善:対象ごとの関連テーブル(コメント本体は共通。tasks(id, name) は作成済み)
CREATE TABLE comments (id int PRIMARY KEY, body text NOT NULL);
CREATE TABLE article_comments (
  comment_id int PRIMARY KEY REFERENCES comments(id) ON DELETE CASCADE,
  article_id int NOT NULL REFERENCES articles(id) ON DELETE CASCADE
);
CREATE TABLE task_comments (
  comment_id int PRIMARY KEY REFERENCES comments(id) ON DELETE CASCADE,
  task_id    int NOT NULL REFERENCES tasks(id) ON DELETE CASCADE
);

INSERT INTO task_comments VALUES (1, 99);   -- 存在しないタスク

-- 改善後のSQL:記事とコメントを結合
SELECT a.title, c.body
FROM article_comments ac
JOIN articles a ON a.id = ac.article_id
JOIN comments c ON c.id = ac.comment_id;
ERROR:  insert or update on table "task_comments" violates foreign key constraint "task_comments_task_id_fkey"
DETAIL:  Key (task_id)=(99) is not present in table "tasks".

 title |       body
-------+------------------
 入門  | 参考になりました

参照される側の種類が多い場合は、逆に「記事もタスクも commentable という親テーブルのIDを持つ」形(共通の親テーブル)にすると、コメント側は1本の外部キーで済みます。 どちらも「外部キーが張れる」ことが判断の軸です。

4. 繰り返し列(tag1, tag2, tag3)

同じ意味の列を番号つきで並べると、検索は列の数だけORが必要になり、4つ目を入れたくなった時点で ALTER TABLE が必要になります。

-- NG
CREATE TABLE articles_tags_ng (id int PRIMARY KEY, tag1 text, tag2 text, tag3 text);
INSERT INTO articles_tags_ng VALUES (1,'SQL','設計',NULL),(2,NULL,'SQL',NULL);
SELECT id FROM articles_tags_ng WHERE tag1='SQL' OR tag2='SQL' OR tag3='SQL';

改善はパターン1と同じ中間テーブルです。既存データは unnest(MySQLは UNION ALL)で1行1値に展開して移行できます。

-- 改善後のSQL:既存の3列を1行1値に展開して、空の article_tags へ移行
INSERT INTO article_tags (article_id, tag_id)
SELECT n.id, t.id
FROM articles_tags_ng n, unnest(ARRAY[n.tag1, n.tag2, n.tag3]) AS tag
JOIN tags t ON t.name = tag;                 -- NULL は結合で自然に落ちる
SELECT * FROM article_tags ORDER BY 1, 2;
INSERT 0 3
 article_id | tag_id
------------+--------
          1 |      1
          1 |      5
          2 |      1

「列名に数字が付いたら従属テーブルのサイン」と覚えておくと、レビューで見つけやすくなります。phone1, phone2 や option_a, option_b も同じです。

5. 万能ID(自然キーの一意性を忘れる)

すべてのテーブルに id を付ける習慣自体はよいのですが、中間テーブルで id だけを主キーにすると「同じ会員が同じグループに2回入る」ような重複を止められません。

-- NG
CREATE TABLE member_groups_ng (id serial PRIMARY KEY, member_id int NOT NULL, group_id int NOT NULL);
INSERT INTO member_groups_ng (member_id, group_id) VALUES (1,10),(1,10);
SELECT * FROM member_groups_ng;
 id | member_id | group_id
----+-----------+----------
  1 |         1 |       10
  2 |         1 |       10
-- 改善:組み合わせを主キーに(他から参照されるなら id + UNIQUE (member_id, group_id))
CREATE TABLE member_groups (
  member_id int NOT NULL, group_id int NOT NULL,
  joined_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (member_id, group_id)
);
INSERT INTO member_groups (member_id, group_id) VALUES (1,10);
INSERT INTO member_groups (member_id, group_id) VALUES (1,10);
ERROR:  duplicate key value violates unique constraint "member_groups_pkey"
DETAIL:  Key (member_id, group_id)=(1, 10) already exists.

MySQLも同様に Duplicate entry '1-10' for key 'member_groups.PRIMARY' で止まります。複合主キーか代理キーかは次の表で決めると迷いません。

状況 主キー 補足
2つのIDの組み合わせだけを表す中間テーブル 複合主キー (a_id, b_id) 逆順の検索用に (b_id) のインデックスを足す
中間テーブル自体が他から参照される(例:参加履歴にコメントが付く) id + UNIQUE (a_id, b_id) 参照側の外部キーは id だけで済む
自然キーが変わりうる(メール、コード) id + UNIQUE (自然キー) パターン6
ORMの都合で単一列の主キーが必要 id + UNIQUE (a_id, b_id) UNIQUEを省かないことが要点

6. 変わる値を主キーにする

メールアドレスや社員番号など「変わることがある値」を主キーにすると、変更時に参照している全テーブルの更新が必要になります。

-- NG
CREATE TABLE members_ng (email text PRIMARY KEY, name text NOT NULL);
CREATE TABLE orders_ng (id int PRIMARY KEY, member_email text NOT NULL REFERENCES members_ng(email));
INSERT INTO members_ng VALUES ('a@example.com','佐藤');
INSERT INTO orders_ng VALUES (1,'a@example.com');
UPDATE members_ng SET email = 'b@example.com' WHERE email = 'a@example.com';
ERROR:  update or delete on table "members_ng" violates foreign key constraint "orders_ng_member_email_fkey" on table "orders_ng"
DETAIL:  Key (email)=(a@example.com) is still referenced from table "orders_ng".
-- 改善:代理キー + 自然キーは UNIQUE
CREATE TABLE members (
  id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email text NOT NULL UNIQUE,
  name  text NOT NULL
);
CREATE TABLE orders (id int PRIMARY KEY, member_id bigint NOT NULL REFERENCES members(id));
INSERT INTO members (email, name) VALUES ('a@example.com','佐藤');
INSERT INTO orders VALUES (1, 1);
UPDATE members SET email = 'b@example.com' WHERE id = 1;

SELECT o.id, m.email FROM orders o JOIN members m ON m.id = o.member_id;
UPDATE 1
 id |     email
----+---------------
  1 | b@example.com

ON UPDATE CASCADE で連鎖更新する手もありますが、参照が数テーブルに及ぶと更新量が大きくなるため、「変わりうる値は主キーにしない」を基本にするのが簡単です。 代理キーにUUIDや連番のどれを使うかは別記事で扱っています。

7. NULLだらけの列

種類の違うものを1つのテーブルに入れると、「デジタル商品にだけある列」「物理商品にだけある列」がNULLで埋まり、必須項目も守れなくなります。

-- NG
CREATE TABLE products_ng (
  id int PRIMARY KEY, name text,
  download_url text, file_size int,           -- デジタル商品だけ
  weight_g int, width_mm int, height_mm int   -- 物理商品だけ
);
INSERT INTO products_ng VALUES
 (1,'電子書籍','https://example.com/x.pdf',5,NULL,NULL,NULL),
 (2,'マグカップ',NULL,NULL,300,80,95),
 (3,NULL,NULL,NULL,NULL,NULL,NULL);          -- 名前なしでも入る
SELECT id, name, weight_g FROM products_ng WHERE weight_g <> 300;
 id | name | weight_g
----+------+----------
(0行)

<> の条件でNULLの行が消えるのはSQLの仕様どおりですが、NULLの列が多いほどこの種の「消える行」に出会う機会が増えます(NULLの3値論理は別記事で解説しています)。

-- 改善:共通列は NOT NULL、種類ごとの属性は別テーブル
CREATE TABLE products (
  id int PRIMARY KEY, name text NOT NULL,
  kind text NOT NULL CHECK (kind IN ('digital','physical'))
);
CREATE TABLE product_digital  (product_id int PRIMARY KEY REFERENCES products(id),
                               download_url text NOT NULL, file_size int NOT NULL);
CREATE TABLE product_physical (product_id int PRIMARY KEY REFERENCES products(id),
                               weight_g int NOT NULL, width_mm int NOT NULL, height_mm int NOT NULL);
INSERT INTO products VALUES (3, NULL, 'digital');

-- 改善後のSQL:物理商品の重さ
SELECT p.name, pp.weight_g FROM products p JOIN product_physical pp ON pp.product_id = p.id;
ERROR:  null value in column "name" of relation "products" violates not-null constraint
DETAIL:  Failing row contains (3, null, digital).

    name    | weight_g
------------+----------
 マグカップ |      300

MySQLでは Column 'name' cannot be null、kind に想定外の値を入れると Check constraint 'products_chk_1' is violated. で止まります。

8. 履歴を上書きする

商品マスタの単価だけを持ち、注文金額を「マスタの単価 × 数量」で計算していると、値上げした瞬間に過去の注文金額まで変わります。

-- NG
CREATE TABLE products_price_ng (id int PRIMARY KEY, name text NOT NULL, price int NOT NULL);
CREATE TABLE order_items_ng (order_id int, product_id int REFERENCES products_price_ng(id), qty int NOT NULL);
INSERT INTO products_price_ng VALUES (1,'ノート',200);
INSERT INTO order_items_ng VALUES (100,1,3);
SELECT o.order_id, p.price * o.qty AS amount FROM order_items_ng o JOIN products_price_ng p ON p.id = o.product_id;
UPDATE products_price_ng SET price = 250 WHERE id = 1;   -- 値上げ
SELECT o.order_id, p.price * o.qty AS amount FROM order_items_ng o JOIN products_price_ng p ON p.id = o.product_id;
 order_id | amount
----------+--------
      100 |    600
 order_id | amount
----------+--------
      100 |    750      ← 過去の注文なのに金額が変わった
-- 改善A:注文時の単価を明細に持つ
CREATE TABLE order_items (
  order_id int, product_id int REFERENCES products_price_ng(id),
  qty int NOT NULL CHECK (qty > 0), unit_price int NOT NULL,
  PRIMARY KEY (order_id, product_id)
);
INSERT INTO order_items VALUES (100,1,3,200);
UPDATE products_price_ng SET price = 300 WHERE id = 1;
SELECT order_id, unit_price * qty AS amount FROM order_items;

-- 改善B:価格そのものの履歴を有効開始日つきで持つ
CREATE TABLE product_prices (
  product_id int REFERENCES products_price_ng(id),
  valid_from date NOT NULL, price int NOT NULL,
  PRIMARY KEY (product_id, valid_from)
);
INSERT INTO product_prices VALUES (1,'2026-01-01',200),(1,'2026-09-01',250),(1,'2026-10-01',300);
-- 2026-09-15 時点の価格
SELECT price FROM product_prices
WHERE product_id = 1 AND valid_from <= DATE '2026-09-15'
ORDER BY valid_from DESC LIMIT 1;
 order_id | amount
----------+--------
      100 |    600

 price
-------
   250

「その時点の事実」が必要な値(単価、税率、住所、ステータス)は、事実が起きた行にコピーするか、有効期間つきで履歴に残します。 ステータスの変更履歴も同じで、tasks.status を上書きするだけでなく task_status_logs(task_id, status, changed_at) に追記すると「いつ完了したか」に答えられます。

9. 日付を文字列で持つ

VARCHAR に日付を入れると、'2026/1/5' と '2026-01-20' のような書式の違いがそのまま入り、範囲検索・ソート・日付計算が全部崩れます。

-- NG
CREATE TABLE events_ng (id int PRIMARY KEY, held_on varchar(10));
INSERT INTO events_ng VALUES (1,'2026/1/5'),(2,'2026-01-20'),(3,'2026/10/01'),(4,'2026-2-30');
SELECT * FROM events_ng WHERE held_on BETWEEN '2026-01-01' AND '2026-01-31';
SELECT * FROM events_ng ORDER BY held_on;
 id |  held_on
----+------------
  2 | 2026-01-20      ← 1月のイベントは2件あるのに1件

 id |  held_on
----+------------
  2 | 2026-01-20
  4 | 2026-2-30       ← 存在しない日付
  1 | 2026/1/5
  3 | 2026/10/01
-- 改善
CREATE TABLE events (id int PRIMARY KEY, held_on date NOT NULL);
INSERT INTO events VALUES (1,'2026-01-05'),(2,'2026-01-20'),(3,'2026-10-01');
INSERT INTO events VALUES (4,'2026-02-30');
SELECT * FROM events WHERE held_on >= DATE '2026-01-01' AND held_on < DATE '2026-02-01' ORDER BY held_on;
SELECT id, held_on + 30 AS deadline, to_char(held_on, 'YYYY/MM/DD (Dy)') FROM events;
ERROR:  date/time field value out of range: "2026-02-30"

 id |  held_on
----+------------
  1 | 2026-01-05
  2 | 2026-01-20

 id |  deadline  |     to_char
----+------------+------------------
  1 | 2026-02-04 | 2026/01/05 (Mon)
  2 | 2026-02-19 | 2026/01/20 (Tue)
  3 | 2026-10-31 | 2026/10/01 (Thu)

MySQLは Incorrect date value: '2026-02-30' で止まり、加算は held_on + INTERVAL 30 DAY、書式は DATE_FORMAT(held_on, '%Y/%m/%d (%a)') です。表示用の書式は取り出すときに付けるのが原則で、YYYYMMDD の整数で持つ方法も同じ理由で避けます。

10. 金額を浮動小数で持つ

FLOAT / DOUBLE は2進数の近似値なので、0.1を10回足すと1になりません。

-- NG
CREATE TABLE sales_ng (id int, amount float8);
INSERT INTO sales_ng SELECT g, 0.1 FROM generate_series(1,10) g;
SELECT sum(amount) FROM sales_ng;
SELECT 0.1::float8 * 3 = 0.3::float8 AS equal_float;
        sum
--------------------
 0.9999999999999999

 equal_float
-------------
 f
-- 改善
CREATE TABLE sales (id int, amount numeric(12,2) NOT NULL);
INSERT INTO sales SELECT g, 0.1 FROM generate_series(1,10) g;
SELECT sum(amount) FROM sales;
SELECT round(1234 * 0.1, 0) AS tax, round(1234 * 1.1, 0) AS total;
 sum
------
 1.00

 tax | total
-----+-------
 123 |  1357

MySQLも DOUBLE の合計は 0.9999999999999999、DECIMAL(12,2) なら 1.00 でした。割合計算の丸め方(切り捨て・四捨五入・端数の持ち方)は round() / trunc() で明示し、円のように小数がない通貨なら整数型でもかまいません。

11. 削除フラグ

deleted_flag は手軽ですが、退会者と同じメールアドレスで再登録できない、すべてのクエリに WHERE deleted_flag = false が要る、という2つの負担が続きます。

-- NG
CREATE TABLE members_del_ng (id int PRIMARY KEY, email text NOT NULL UNIQUE, deleted_flag boolean NOT NULL DEFAULT false);
INSERT INTO members_del_ng VALUES (1,'a@example.com', true);   -- 退会済み
INSERT INTO members_del_ng VALUES (2,'a@example.com', false);  -- 同じアドレスで再登録
ERROR:  duplicate key value violates unique constraint "members_del_ng_email_key"
DETAIL:  Key (email)=(a@example.com) already exists.
-- 改善:deleted_at と「有効な行だけ」の部分ユニークインデックス
CREATE TABLE members_del (id int PRIMARY KEY, email text NOT NULL, deleted_at timestamptz);
CREATE UNIQUE INDEX members_del_email_active ON members_del (email) WHERE deleted_at IS NULL;
INSERT INTO members_del VALUES (1,'a@example.com', now());  -- 退会済み
INSERT INTO members_del VALUES (2,'a@example.com', NULL);   -- 再登録できる
INSERT INTO members_del VALUES (3,'a@example.com', NULL);   -- 有効な重複は止まる
CREATE VIEW active_members AS SELECT id, email FROM members_del WHERE deleted_at IS NULL;
SELECT * FROM active_members;
INSERT 0 1
INSERT 0 1
ERROR:  duplicate key value violates unique constraint "members_del_email_active"
DETAIL:  Key (email)=(a@example.com) already exists.

 id |     email
----+---------------
  2 | a@example.com

MySQLには部分インデックスがないので、生成列で「有効な行だけメールアドレス、削除済みはNULL」を作り、そこにUNIQUEを付けます(UNIQUEインデックスはNULLを何行でも許します)。

-- MySQL 8.0
CREATE TABLE members_del (
  id INT PRIMARY KEY, email VARCHAR(255) NOT NULL, deleted_at DATETIME NULL,
  active_email VARCHAR(255) AS (IF(deleted_at IS NULL, email, NULL)) STORED,
  UNIQUE KEY uq_active_email (active_email)
);
(MySQL)3件目:ERROR 1062 (23000): Duplicate entry 'a@example.com' for key 'members_del.uq_active_email'

削除済みデータを参照する頻度が低いなら、削除時に members_archive へ移す方法もあります。 有効行だけのテーブルになるので、すべてのクエリから削除条件が消えます。

12. ツリーを親IDだけで持ち、固定段数で結合する

parent_id を持つ隣接リスト自体は自然な設計です。困るのは「自己結合を3回書いて3階層まで」のような固定段数の取り出し方で、4階層目が増えた瞬間に取れなくなります。

CREATE TABLE categories (id int PRIMARY KEY, name text NOT NULL, parent_id int REFERENCES categories(id));
INSERT INTO categories VALUES (1,'家電',NULL),(2,'キッチン',1),(3,'炊飯器',2),(4,'冷蔵庫',2),(5,'文具',NULL),(6,'ノート',5);

-- NG:深さ固定の自己結合
SELECT c1.name AS lv1, c2.name AS lv2, c3.name AS lv3
FROM categories c1
LEFT JOIN categories c2 ON c2.parent_id = c1.id
LEFT JOIN categories c3 ON c3.parent_id = c2.id
WHERE c1.parent_id IS NULL;
-- 改善A:再帰CTE(PostgreSQL・MySQL 8.0 とも同じ構文で動く)
WITH RECURSIVE tree AS (
  SELECT id, name, parent_id, 1 AS depth, name::text AS path
  FROM categories WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.name, c.parent_id, t.depth + 1, t.path || ' > ' || c.name
  FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT depth, path FROM tree ORDER BY path;
 depth |           path
-------+--------------------------
     1 | 家電
     2 | 家電 > キッチン
     3 | 家電 > キッチン > 冷蔵庫
     3 | 家電 > キッチン > 炊飯器
     1 | 文具
     2 | 文具 > ノート

MySQLでは name::text を CAST(name AS CHAR(200))、|| を CONCAT() にすれば同じ結果になります(実行済み)。読み取りが圧倒的に多い場合は、祖先と子孫の全組み合わせを持つ閉包テーブル category_paths(ancestor_id, descendant_id, depth) を足すと、「家電配下をすべて取る」が結合1回になります(再帰CTEで初期データを作れます。実行済み)。

方式 読み取り 追加・移動 向く場面
隣接リスト + 再帰CTE 再帰1回 1行の更新 階層が浅い、更新が多い(既定の選択)
閉包テーブル 結合1回 祖先の数だけ行を追加 読み取りが圧倒的に多い、部分木の取得が頻繁
経路列挙(ltree など) 前方一致 子孫の経路をすべて更新 パンくず表示が中心

13. 外部キーを付けない

外部キーを省くと、存在しない担当者IDが入り、結合したときに行が消えたり NULL の名前が表示されたりします。「アプリで検査しているから不要」は、バッチや手作業の更新まで含めると成り立ちません。

-- NG
CREATE TABLE tasks_ng (id int PRIMARY KEY, title text NOT NULL, assignee_id int);
INSERT INTO tasks_ng VALUES (1,'設計レビュー', 999);   -- 存在しない担当者
SELECT t.title, m.name FROM tasks_ng t LEFT JOIN members m ON m.id = t.assignee_id;
    title     | name
--------------+------
 設計レビュー |
-- 改善:外部キー + 削除時の方針
CREATE TABLE tasks_fk (
  id int PRIMARY KEY, title text NOT NULL,
  assignee_id bigint REFERENCES members(id) ON DELETE SET NULL
);
INSERT INTO tasks_fk VALUES (1,'設計レビュー', 999);
DELETE FROM members WHERE id = 1;   -- 注文から参照されている会員
ERROR:  insert or update on table "tasks_fk" violates foreign key constraint "tasks_fk_assignee_id_fkey"
DETAIL:  Key (assignee_id)=(999) is not present in table "members".

ERROR:  update or delete on table "members" violates foreign key constraint "orders_member_id_fkey" on table "orders"
DETAIL:  Key (id)=(1) is still referenced from table "orders".
参照先が消えたとき 指定 使いどころ
消せない(既定) ON DELETE RESTRICT / NO ACTION 注文と会員のように、事実の記録を守る
一緒に消す ON DELETE CASCADE 記事とタグ付けのように、親がなければ意味がない行
参照をNULLにする ON DELETE SET NULL タスクの担当者のように、空でも成り立つ関係

参照する側の列(assignee_id)にはインデックスを付けます。 PostgreSQLは外部キー列に自動ではインデックスを作らないため、親の削除時に子テーブルが全件走査になります(MySQL/InnoDBは自動で作ります)。

14. マジックナンバー

status = 1, 2, 9 のような値の意味がテーブルにもコードにも書かれていないと、9 が何かを知る人がいなくなります。

-- NG
CREATE TABLE tasks_status_ng (id int PRIMARY KEY, status int NOT NULL);
INSERT INTO tasks_status_ng VALUES (1,1),(2,2),(3,9);   -- 9 は?
-- 改善A:参照テーブル(表示名と並び順も持てる)
CREATE TABLE task_statuses (code text PRIMARY KEY, label text NOT NULL, sort_no int NOT NULL);
INSERT INTO task_statuses VALUES ('todo','未着手',1),('doing','作業中',2),('done','完了',3);
CREATE TABLE tasks_status (id int PRIMARY KEY, status text NOT NULL REFERENCES task_statuses(code));
INSERT INTO tasks_status VALUES (1,'todo'),(2,'doing');
INSERT INTO tasks_status VALUES (3,'unknown');

-- 改善後のSQL:0件の状態も含めて集計
SELECT s.label, count(t.id)
FROM task_statuses s LEFT JOIN tasks_status t ON t.status = s.code
GROUP BY s.label, s.sort_no ORDER BY s.sort_no;

-- 改善B:値が固定で増えないなら CHECK 制約
CREATE TABLE tasks_status2 (id int PRIMARY KEY, status text NOT NULL CHECK (status IN ('todo','doing','done')));
INSERT INTO tasks_status2 VALUES (1,'unknown');
ERROR:  insert or update on table "tasks_status" violates foreign key constraint "tasks_status_status_fkey"
DETAIL:  Key (status)=(unknown) is not present in table "task_statuses".

 label  | count
--------+-------
 未着手 |     1
 作業中 |     1
 完了   |     0

ERROR:  new row for relation "tasks_status2" violates check constraint "tasks_status2_status_check"
DETAIL:  Failing row contains (1, unknown).
方法 値の追加 表示名・並び順 向く場面
参照テーブル INSERT だけ 持てる 画面に一覧表示する、後から増える
CHECK 制約 ALTER TABLE 持てない 値が仕様として固定(kind IN ('digital','physical'))
ENUM 型 PostgreSQLは ALTER TYPE ... ADD VALUE、MySQLは ALTER TABLE 持てない 小さく固定で、順序比較を型に任せたい

15. 万能コードマスタ

「ステータスも優先度も全部 codes(code_type, code, label) に入れる」万能マスタは、種類をまたいだ値を止められず、外部キーも張れません。

-- NG
CREATE TABLE codes_ng (code_type text, code text, label text, PRIMARY KEY (code_type, code));
INSERT INTO codes_ng VALUES ('task_status','1','未着手'),('task_status','2','作業中'),('priority','1','高'),('priority','2','中');
CREATE TABLE tasks_code_ng (id int PRIMARY KEY, status text, priority text);
INSERT INTO tasks_code_ng VALUES (1,'2','9');   -- priority 9 は存在しないが止められない
ALTER TABLE tasks_code_ng ADD FOREIGN KEY (priority) REFERENCES codes_ng(code);
ERROR:  there is no unique constraint matching given keys for referenced table "codes_ng"

MySQLも Failed to add the foreign key constraint. Missing index for constraint ... in the referenced table 'codes_ng' となり、外部キーは「code_type = 'priority' の行だけ」を参照できません。

-- 改善:種類ごとの参照テーブル(パターン14と同じ形)
CREATE TABLE priorities (code text PRIMARY KEY, label text NOT NULL, sort_no int NOT NULL);
INSERT INTO priorities VALUES ('high','高',1),('mid','中',2),('low','低',3);
CREATE TABLE tasks_code (
  id int PRIMARY KEY,
  status   text NOT NULL REFERENCES task_statuses(code),
  priority text NOT NULL REFERENCES priorities(code)
);
INSERT INTO tasks_code VALUES (1,'doing','urgent');
INSERT INTO tasks_code VALUES (1,'doing','high');
SELECT t.id, s.label, p.label FROM tasks_code t
JOIN task_statuses s ON s.code = t.status JOIN priorities p ON p.code = t.priority;
ERROR:  insert or update on table "tasks_code" violates foreign key constraint "tasks_code_priority_fkey"
DETAIL:  Key (priority)=(urgent) is not present in table "priorities".

 id | label  | label
----+--------+-------
  1 | 作業中 | 高

テーブルが増えるのが気になる場合も、「種類ごとに外部キーが張れるか」を優先します。 同じ考え方で、settings(key, value) に何でも入れる万能設定テーブルや、100列を超える「何でも入る」大テーブルも、検索条件になる項目から順に列やテーブルへ分けていくと扱いやすくなります。

設計レビュー用チェック表(コピペ用)

レビューでは「パターン名」ではなく「起きること」で確認すると、設計者と同じ目線で話せます。 各行の「確認する質問」に「はい」と答えられれば通過です。

# 確認する質問 見つけ方(DDL・データのサイン) 対応
1 1つの列に複数の値を入れていないか text 列に , や ; が入る、LIKE '%..%' で検索している 中間テーブル
2 項目名を行として持っていないか attr / key と value の列がある 列 + jsonb
3 参照先が動的に決まる列はないか xxx_type + xxx_id の組 対象ごとの関連テーブル
4 番号つきの同じ列はないか tag1, tag2、phone1, phone2 従属テーブル
5 中間テーブルに組み合わせのUNIQUEがあるか id だけが主キー 複合主キーか UNIQUE
6 変わりうる値を主キーにしていないか メール・コード・名前が主キー 代理キー + UNIQUE
7 半分以上がNULLの列はないか 種類によって使う列が違う 種類ごとのテーブル
8 「その時点の値」が必要な項目をコピーしているか 明細に単価がない、ステータスに履歴がない 明細にコピー、履歴テーブル
9 日付・時刻が日付型か varchar(10)、YYYYMMDD の整数 date / timestamp
10 金額・数量が numeric / decimal か float / double numeric(p,s)
11 論理削除で一意性と再登録が両立しているか deleted_flag + UNIQUE deleted_at + 部分ユニーク
12 階層の深さを固定していないか 自己結合を3回書いている 再帰CTE・閉包テーブル
13 すべての参照列に外部キーとインデックスがあるか xxx_id に REFERENCES がない 外部キー + ON DELETE 方針
14 コード値の意味がDBに書かれているか status int で参照先がない 参照テーブル・CHECK
15 種類ごとに参照テーブルが分かれているか code_type 列を持つ万能マスタ 種類ごとのテーブル

レビュー用のSQL:怪しい列をDBから見つける

-- PostgreSQL:NULL率の高い列を探す(ANALYZE 後の統計から)
SELECT tablename, attname, round(null_frac * 100) AS null_pct
FROM pg_stats
WHERE schemaname = 'public' AND null_frac > 0.5
ORDER BY null_frac DESC;

-- PostgreSQL:外部キーがあるのにインデックスがない列
SELECT c.conrelid::regclass AS table_name, a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)
WHERE c.contype = 'f'
  AND NOT EXISTS (
    SELECT 1 FROM pg_index i
    WHERE i.indrelid = c.conrelid AND (i.indkey::int2[])[0] = a.attnum
  );

この記事のテーブルに対して実行すると、パターン7のNG例がそのまま浮かび上がります。

    tablename     |   attname    | null_pct
------------------+--------------+----------
 articles_tags_ng | tag3         |      100
 products_ng      | download_url |       67
 products_ng      | file_size    |       67
 products_ng      | weight_g     |       67
(略)

    table_name    |  column_name
------------------+---------------
 tasks_fk         | assignee_id
 orders           | member_id
 categories       | parent_id
(略)
-- MySQL:外部キーを持たない xxx_id 列の候補
SELECT c.TABLE_NAME, c.COLUMN_NAME
FROM information_schema.COLUMNS c
LEFT JOIN information_schema.KEY_COLUMN_USAGE k
  ON k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME
 AND k.COLUMN_NAME = c.COLUMN_NAME AND k.REFERENCED_TABLE_NAME IS NOT NULL
WHERE c.TABLE_SCHEMA = DATABASE() AND c.COLUMN_NAME LIKE '%\_id'
  AND c.COLUMN_KEY <> 'PRI' AND k.COLUMN_NAME IS NULL;
-- この記事のNGテーブルでは item_attrs_ng.item_id、member_groups_ng.member_id / group_id、tasks_ng.assignee_id が出ます

よくある質問

Q. 正規化すればアンチパターンは全部防げますか? A. 半分くらいです。カンマ区切り(1)、繰り返し列(4)、EAV(2)、万能ID(5)は正規化の考え方で防げますが、日付を文字列で持つ(9)、金額を浮動小数(10)、削除フラグ(11)、外部キーなし(13)、マジックナンバー(14)は「型と制約の選び方」の問題です。正規化とあわせて「列の型」「制約」「履歴の持ち方」を確認するとよいでしょう。

Q. 実測では1万件なら差が出ていません。小さいシステムなら気にしなくてよいですか? A. 速度だけならそうですが、外部キーが張れない・型が守れないという整合性の問題は1件目から起きます。また、1万件のときに正しい設計にしておくのと、100万件になってから中間テーブルへ移行するのとでは、移行の手間がまったく違います。速度は「あとから効いてくる理由」、整合性は「最初から効く理由」と分けて考えるのがおすすめです。

Q. jsonb / JSON 列を使うのはアンチパターンですか? A. 使い方次第です。実測のとおり、jsonb + GINは「色が red」のような等価検索なら中間案として十分速く、EAVより扱いやすいです。目安は「検索条件・集計・外部キーの対象になる項目は列に、表示するだけの可変項目はJSONに」です。JSONの中の値を毎回 CAST して範囲検索している列があれば、それは列に昇格させる合図です。

Q. 既存の本番テーブルがカンマ区切りです。どう移行すればよいですか? A. 「新テーブルを追加 → string_to_array / UNION ALL で既存データを投入 → アプリの書き込みを両方に → 読み取りを新テーブルへ切り替え → 旧列を削除」の順で、1ステップずつ本番で確認しながら進めると安全です。パターン4に載せた unnest の移行SQLがそのまま使えます。

まとめ

  • アンチパターンは「起きること」で覚える:検索が全件走査になる、外部キーが張れない、型が守れない、過去の値が変わる、の4つに集約できる
  • カンマ区切りとEAVは1万件で数ms、10万件で10倍以上、100万件で数十〜数百倍の差(PostgreSQL 16・MySQL 8.0で実測)
  • 複数の値は中間テーブル、可変属性は「検索する項目は列、表示だけならJSON」
  • 主キーは「変わらない値」。中間テーブルは複合主キーか id + UNIQUE
  • 日付は日付型、金額は numeric / decimal、コード値は参照テーブルか CHECK
  • 論理削除は deleted_at + 部分ユニーク(MySQLは生成列)で、再登録と一意性を両立する
  • 外部キーは ON DELETE の方針とセットで付け、参照列にはインデックスを足す

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「テーブル設計アンチパターン15選|NG例と改善DDL・実測付き」

株式会社RJCでは、テーブル設計のレビューを「起きること」と実測した数字をもとにプロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?