- データベース
- 設計
テーブル継承の設計パターン|STI・CTI・具象テーブル継承の違い
テーブル継承の設計パターンとは、「共通の項目を持ちつつ、種類ごとに固有の項目もある」データをリレーショナルデータベースのテーブルに落とす方法で、単一テーブル継承(STI)・クラステーブル継承(CTI)・具象テーブル継承の3つがあります。 選び方は、サブタイプ固有の列が少なく一覧表示が多ければ単一テーブル継承、固有列が多く制約を効かせたければクラステーブル継承、共通の一覧が不要なら具象テーブル継承です。
この記事では、「記事・質問・イベント告知」という3種類の投稿を題材に、同じデータを3方式の DDL で作り、登録・一覧・種別の追加を実行して結果を横に並べます。あわせて、type 列と id 列で参照先を切り替えるポリモーフィック関連で外部キーが張れない状態を再現し、2つの代替を DDL で示します。
確認環境:PostgreSQL 18.4、MySQL 8.0.46(InnoDB)。Linux 上で 2026-10-06 に実行しました。特に断りのない SQL は両方で同じ文を実行しています。出力例のデータベース名は appdb に置き換えています。
この記事の結論
| 方式 | テーブルの切り方 | 選ぶ条件 |
|---|---|---|
| 単一テーブル継承(STI) | 全種類を1テーブルに入れ、種別列で区別 | 固有列が少ない(目安として種類ごとに1〜3列)。全種類の一覧・検索が多い |
| クラステーブル継承(CTI) | 共通列の親テーブル+種類ごとの子テーブル | 固有列が多い。NOT NULL や外部キーを効かせたい。全種類を指す関連(コメントなど)がある |
| 具象テーブル継承 | 種類ごとに独立したテーブル(共通列も各テーブルに持つ) | 全種類をまとめた一覧が不要。種類ごとに画面も処理も別 |
- 今回の題材(3種類・3行)では、STI は固有列18セルのうち11セルが NULL、CTI と具象テーブル継承は NULL が0でした。
- 全種類からコメントや「いいね」を付けたいなら、共通の親テーブルを持つ CTI が素直です。親の id に外部キーを張れます。
3方式の比較早見表
| 観点 | 単一テーブル継承(STI) | クラステーブル継承(CTI) | 具象テーブル継承 |
|---|---|---|---|
| テーブル数(3種類のとき) | 1 | 4(親1+子3) | 3 |
| 全種類の一覧 | 1テーブルの SELECT | 親テーブルの SELECT | UNION ALL(またはビュー) |
| 1種類の詳細取得 | WHERE で種別を絞る | 親と子を JOIN | 1テーブルの SELECT |
| 登録 | INSERT 1回 | INSERT 2回(親→子) | INSERT 1回 |
| 固有列の NOT NULL | 列には付けられない(CHECK で書く) | 付けられる | 付けられる |
| 他種類の列が NULL で並ぶ | 並ぶ | 並ばない | 並ばない |
| 全種類を指す外部キー | 張れる | 張れる(親へ) | 張れない |
| id の重複 | 起きない | 起きない | 種類をまたいで重なる |
| 共通列の変更 | 1か所 | 1か所(親) | 全テーブル |
| 種別の追加 | 列追加+CHECK の変更 | 子テーブル追加+種別 CHECK の変更 | テーブル追加+一覧ビューの変更 |
表計算ソフトに貼れるタブ区切り版です。
観点 単一テーブル継承(STI) クラステーブル継承(CTI) 具象テーブル継承
テーブル数(3種類) 1 4(親1+子3) 3
全種類の一覧 1テーブルのSELECT 親テーブルのSELECT UNION ALL
1種類の詳細取得 WHEREで種別を絞る 親と子をJOIN 1テーブルのSELECT
登録 INSERT 1回 INSERT 2回 INSERT 1回
固有列のNOT NULL CHECKで書く 付けられる 付けられる
他種類の列のNULL 並ぶ 並ばない 並ばない
全種類を指す外部キー 張れる 張れる(親へ) 張れない
idの重複 起きない 起きない 種類をまたいで重なる
共通列の変更 1か所 1か所(親) 全テーブル
種別の追加 列追加+CHECK変更 子テーブル追加+CHECK変更 テーブル追加+ビュー変更
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「テーブル継承の設計パターン|STI・CTI・具象テーブル継承の違い」
どの方式を選ぶかの判断表
上から順に当てはめ、最初に合った行の方式を候補にします。
| 条件 | 選ぶ方式 |
|---|---|
| 全種類をまとめた一覧・検索が不要で、他のテーブルから「全種類のどれか」を指すこともない | 具象テーブル継承 |
| 全種類を指す関連(コメント、タグ、閲覧履歴など)があり、固有列に NOT NULL や外部キーを付けたい | クラステーブル継承 |
| 固有列が多い(種類ごとに4列以上が目安)、または種類ごとに別の関連テーブルがぶら下がる | クラステーブル継承 |
| 固有列が少なく、ほとんどの画面が全種類の一覧 | 単一テーブル継承 |
| 種類の違いが「値の違い」だけで固有列が無い | 継承にせず、区分列1つで足りる |
列数の目安は判断の出発点です。固有列が少なくても「必ず入っていてほしい」列が多いなら、CHECK の量が増えるので CTI が読みやすくなります。
題材:3種類の投稿
社内の情報共有サイトを想定した架空の題材です。
| 種類 | 共通の列 | 固有の列 |
|---|---|---|
| 記事(article) | タイトル、投稿者、投稿日 | 本文、要約 |
| 質問(question) | タイトル、投稿者、投稿日 | 本文、解決したかどうかのフラグ |
| イベント告知(event) | タイトル、投稿者、投稿日 | 開催日、場所、定員 |
単一テーブル継承(STI)
全種類を1テーブルに入れ、post_type 列で種類を区別します。固有列は、他の種類の行では NULL になります。
CREATE TABLE sti_posts (
id INT PRIMARY KEY,
post_type VARCHAR(10) NOT NULL,
title VARCHAR(100) NOT NULL,
author_name VARCHAR(50) NOT NULL,
created_on DATE NOT NULL,
body TEXT,
summary VARCHAR(200),
is_solved BOOLEAN,
held_on DATE,
place VARCHAR(100),
capacity INT,
CONSTRAINT sti_type_chk CHECK (post_type IN ('article','question','event')),
CONSTRAINT sti_article_chk CHECK (post_type <> 'article'
OR (body IS NOT NULL AND summary IS NOT NULL AND is_solved IS NULL
AND held_on IS NULL AND place IS NULL AND capacity IS NULL)),
CONSTRAINT sti_question_chk CHECK (post_type <> 'question'
OR (body IS NOT NULL AND is_solved IS NOT NULL AND summary IS NULL
AND held_on IS NULL AND place IS NULL AND capacity IS NULL)),
CONSTRAINT sti_event_chk CHECK (post_type <> 'event'
OR (held_on IS NOT NULL AND place IS NOT NULL AND capacity IS NOT NULL
AND body IS NULL AND summary IS NULL AND is_solved IS NULL))
);
CHECK制約で種別と列の組み合わせを守る
STI では固有列に NOT NULL を付けられないので、「この種別なら、この列は必須で、他の種類の列は空」を CHECK で書きます。 種別 <> 'x' OR (条件) は「種別が x なら条件を満たすこと」という意味です。
INSERT INTO sti_posts (id, post_type, title, author_name, created_on, body, summary)
VALUES (1, 'article', 'SQL入門メモ', 'sato', '2026-10-01', '本文A', '要約A');
INSERT INTO sti_posts (id, post_type, title, author_name, created_on, body, is_solved)
VALUES (2, 'question', 'JOINの書き方', 'suzuki', '2026-10-02', '本文Q', FALSE);
INSERT INTO sti_posts (id, post_type, title, author_name, created_on, held_on, place, capacity)
VALUES (3, 'event', '読書会', 'tanaka', '2026-10-03', '2026-11-01', '会議室A', 20);
組み合わせが合わない行は止まります。記事に定員を入れた場合と、開催日の無いイベントの場合です。
INSERT INTO sti_posts (id, post_type, title, author_name, created_on, body, summary, capacity)
VALUES (4, 'article', '定員つき記事', 'ito', '2026-10-04', '本文', '要約', 10);
INSERT INTO sti_posts (id, post_type, title, author_name, created_on)
VALUES (5, 'event', '日付なしイベント', 'ito', '2026-10-04');
-- PostgreSQL 18.4
ERROR: new row for relation "sti_posts" violates check constraint "sti_article_chk"
DETAIL: Failing row contains (4, article, 定員つき記事, ito, 2026-10-04, 本文, 要約, null, null, null, 10).
ERROR: new row for relation "sti_posts" violates check constraint "sti_event_chk"
DETAIL: Failing row contains (5, event, 日付なしイベント, ito, 2026-10-04, null, null, null, null, null, null).
-- MySQL 8.0.46
ERROR 3819 (HY000): Check constraint 'sti_article_chk' is violated.
ERROR 3819 (HY000): Check constraint 'sti_event_chk' is violated.
制約に名前を付けておくと、どの種別の決まりに合わなかったかがエラー文から分かります。
一覧とNULLの数
SELECT id, post_type, title, author_name, created_on
FROM sti_posts ORDER BY created_on DESC;
id | post_type | title | author_name | created_on
----+-----------+--------------+-------------+------------
3 | event | 読書会 | tanaka | 2026-10-03
2 | question | JOINの書き方 | suzuki | 2026-10-02
1 | article | SQL入門メモ | sato | 2026-10-01
固有列の NULL を数えます。
SELECT COUNT(*) AS row_count,
SUM((CASE WHEN body IS NULL THEN 1 ELSE 0 END) + (CASE WHEN summary IS NULL THEN 1 ELSE 0 END)
+ (CASE WHEN is_solved IS NULL THEN 1 ELSE 0 END) + (CASE WHEN held_on IS NULL THEN 1 ELSE 0 END)
+ (CASE WHEN place IS NULL THEN 1 ELSE 0 END) + (CASE WHEN capacity IS NULL THEN 1 ELSE 0 END)) AS null_cells
FROM sti_posts;
row_count | null_cells
-----------+------------
3 | 11
3行×固有6列=18セルのうち、11セルが NULL です。種類が増えるほど、この割合は上がります。
クラステーブル継承(CTI)
共通列を親テーブルに、固有列を種類ごとの子テーブルに分けます。子テーブルの主キーは親の id と同じ値で、1対1でつながります。
CREATE TABLE cti_posts (
id INT PRIMARY KEY,
post_type VARCHAR(10) NOT NULL,
title VARCHAR(100) NOT NULL,
author_name VARCHAR(50) NOT NULL,
created_on DATE NOT NULL,
CONSTRAINT cti_type_chk CHECK (post_type IN ('article','question','event')),
UNIQUE (id, post_type)
);
CREATE TABLE cti_articles (
post_id INT PRIMARY KEY,
post_type VARCHAR(10) NOT NULL DEFAULT 'article' CHECK (post_type = 'article'),
body TEXT NOT NULL,
summary VARCHAR(200) NOT NULL,
FOREIGN KEY (post_id, post_type) REFERENCES cti_posts (id, post_type)
);
CREATE TABLE cti_questions (
post_id INT PRIMARY KEY,
post_type VARCHAR(10) NOT NULL DEFAULT 'question' CHECK (post_type = 'question'),
body TEXT NOT NULL,
is_solved BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY (post_id, post_type) REFERENCES cti_posts (id, post_type)
);
CREATE TABLE cti_events (
post_id INT PRIMARY KEY,
post_type VARCHAR(10) NOT NULL DEFAULT 'event' CHECK (post_type = 'event'),
held_on DATE NOT NULL,
place VARCHAR(100) NOT NULL,
capacity INT NOT NULL CHECK (capacity > 0),
FOREIGN KEY (post_id, post_type) REFERENCES cti_posts (id, post_type)
);
子テーブルにも post_type を固定値で持たせ、(id, post_type) の組で親を参照するのがポイントです。これで「親では記事なのに、イベントの子テーブルに行がある」という食い違いを外部キーで止められます。
INSERT INTO cti_posts VALUES (1, 'article', 'SQL入門メモ', 'sato', '2026-10-01');
INSERT INTO cti_articles (post_id, body, summary) VALUES (1, '本文A', '要約A');
INSERT INTO cti_posts VALUES (2, 'question', 'JOINの書き方', 'suzuki', '2026-10-02');
INSERT INTO cti_questions (post_id, body) VALUES (2, '本文Q');
INSERT INTO cti_posts VALUES (3, 'event', '読書会', 'tanaka', '2026-10-03');
INSERT INTO cti_events (post_id, held_on, place, capacity) VALUES (3, '2026-11-01', '会議室A', 20);
登録は親→子の2回になるので、アプリケーションでは1つのトランザクションにまとめます。記事(id=1)をイベントとして登録しようとした場合と、定員を省いた場合の結果です。
INSERT INTO cti_events (post_id, held_on, place, capacity) VALUES (1, '2026-11-01', '会議室B', 5);
INSERT INTO cti_events (post_id, held_on, place) VALUES (3, '2026-11-01', '会議室A');
-- PostgreSQL 18.4
ERROR: insert or update on table "cti_events" violates foreign key constraint "cti_events_post_id_post_type_fkey"
DETAIL: Key (post_id, post_type)=(1, event) is not present in table "cti_posts".
ERROR: null value in column "capacity" of relation "cti_events" violates not-null constraint
-- MySQL 8.0.46
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`appdb`.`cti_events`, CONSTRAINT `cti_events_ibfk_1` FOREIGN KEY (`post_id`, `post_type`) REFERENCES `cti_posts` (`id`, `post_type`))
ERROR 1364 (HY000): Field 'capacity' doesn't have a default value
STI では CHECK に書いた「必須」が、CTI では列の NOT NULL だけで済みます。
一覧は親テーブルだけで取れ、STI と同じ3行が返りました。詳細は JOIN します。
SELECT id, post_type, title, author_name, created_on
FROM cti_posts ORDER BY created_on DESC;
SELECT p.id, p.title, e.held_on, e.place, e.capacity
FROM cti_posts p JOIN cti_events e ON e.post_id = p.id;
id | title | held_on | place | capacity
----+--------+------------+---------+----------
3 | 読書会 | 2026-11-01 | 会議室A | 20
具象テーブル継承
種類ごとに独立したテーブルを作り、共通列もそれぞれに持たせます。
CREATE TABLE cc_articles (
id INT PRIMARY KEY, title VARCHAR(100) NOT NULL,
author_name VARCHAR(50) NOT NULL, created_on DATE NOT NULL,
body TEXT NOT NULL, summary VARCHAR(200) NOT NULL
);
CREATE TABLE cc_questions (
id INT PRIMARY KEY, title VARCHAR(100) NOT NULL,
author_name VARCHAR(50) NOT NULL, created_on DATE NOT NULL,
body TEXT NOT NULL, is_solved BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE cc_events (
id INT PRIMARY KEY, title VARCHAR(100) NOT NULL,
author_name VARCHAR(50) NOT NULL, created_on DATE NOT NULL,
held_on DATE NOT NULL, place VARCHAR(100) NOT NULL, capacity INT NOT NULL CHECK (capacity > 0)
);
INSERT INTO cc_articles VALUES (1, 'SQL入門メモ', 'sato', '2026-10-01', '本文A', '要約A');
INSERT INTO cc_questions (id, title, author_name, created_on, body)
VALUES (1, 'JOINの書き方', 'suzuki', '2026-10-02', '本文Q');
INSERT INTO cc_events VALUES (1, '読書会', 'tanaka', '2026-10-03', '2026-11-01', '会議室A', 20);
1種類だけを扱う処理は、JOIN も種別の条件も要らず最も単純です。全種類の一覧が必要になったら UNION ALL でつなぎます。
SELECT 'article' AS post_type, id, title, author_name, created_on FROM cc_articles
UNION ALL
SELECT 'question', id, title, author_name, created_on FROM cc_questions
UNION ALL
SELECT 'event', id, title, author_name, created_on FROM cc_events
ORDER BY created_on DESC;
post_type | id | title | author_name | created_on
-----------+----+--------------+-------------+------------
event | 1 | 読書会 | tanaka | 2026-10-03
question | 1 | JOINの書き方 | suzuki | 2026-10-02
article | 1 | SQL入門メモ | sato | 2026-10-01
id が3行とも 1 です。 テーブルごとに採番するので、「投稿ID 1」だけではどの行か決まらず、種類と id の組で扱うことになります。これが、次に説明するポリモーフィック関連につながります。
同じデータでの結果を横に並べる
| 項目 | STI | CTI | 具象テーブル継承 |
|---|---|---|---|
| 3件の登録に使った INSERT | 3回 | 6回 | 3回 |
| 一覧の SQL | 1テーブル | 1テーブル(親) | 3テーブルの UNION ALL |
| 一覧の結果 | 3行(id は 1・2・3) | 3行(id は 1・2・3) | 3行(id はすべて 1) |
| 固有列の NULL セル | 11(18セル中) | 0 | 0 |
| 「記事に定員」を止めた仕組み | CHECK(sti_article_chk) | 列が存在しない | 列が存在しない |
| 「定員なしのイベント」を止めた仕組み | CHECK(sti_event_chk) | NOT NULL | NOT NULL |
PostgreSQL 18.4 と MySQL 8.0.46 で、行数・NULL の数・止まる箇所は同じでした。
種別を追加するときのSQL
4種類目として「投票(poll):締切日を持つ」を足します。
STI:列を足し、CHECKを書き換える
ALTER TABLE sti_posts ADD COLUMN closes_on DATE;
ALTER TABLE sti_posts DROP CONSTRAINT sti_type_chk;
ALTER TABLE sti_posts ADD CONSTRAINT sti_type_chk
CHECK (post_type IN ('article','question','event','poll'));
ALTER TABLE sti_posts ADD CONSTRAINT sti_poll_chk CHECK (
(post_type = 'poll' AND closes_on IS NOT NULL AND body IS NULL AND summary IS NULL
AND is_solved IS NULL AND held_on IS NULL AND place IS NULL AND capacity IS NULL)
OR (post_type <> 'poll' AND closes_on IS NULL));
INSERT INTO sti_posts (id, post_type, title, author_name, created_on, closes_on)
VALUES (4, 'poll', '次回テーマ投票', 'ito', '2026-10-04', '2026-10-20');
既存のテーブルを変更するので、行数が多いと ALTER TABLE の時間とロックを考える必要があります。追加後に NULL を数え直すと、4行×固有7列=28セルのうち20セルが NULL でした(追加前は18セル中11)。
CTI:子テーブルを足し、親の種別CHECKだけ変える
ALTER TABLE cti_posts DROP CONSTRAINT cti_type_chk;
ALTER TABLE cti_posts ADD CONSTRAINT cti_type_chk
CHECK (post_type IN ('article','question','event','poll'));
CREATE TABLE cti_polls (
post_id INT PRIMARY KEY,
post_type VARCHAR(10) NOT NULL DEFAULT 'poll' CHECK (post_type = 'poll'),
closes_on DATE NOT NULL,
FOREIGN KEY (post_id, post_type) REFERENCES cti_posts (id, post_type)
);
INSERT INTO cti_posts VALUES (4, 'poll', '次回テーマ投票', 'ito', '2026-10-04');
INSERT INTO cti_polls (post_id, closes_on) VALUES (4, '2026-10-20');
既存の子テーブルには手を入れません。親の一覧 SQL も変更なしで、4行目に投票が出ました。
具象テーブル継承:テーブルを足し、一覧のビューを変える
CREATE TABLE cc_polls (
id INT PRIMARY KEY, title VARCHAR(100) NOT NULL,
author_name VARCHAR(50) NOT NULL, created_on DATE NOT NULL,
closes_on DATE NOT NULL
);
INSERT INTO cc_polls VALUES (1, '次回テーマ投票', 'ito', '2026-10-04', '2026-10-20');
CREATE VIEW cc_all_posts AS
SELECT 'article' AS post_type, id, title, author_name, created_on FROM cc_articles
UNION ALL SELECT 'question', id, title, author_name, created_on FROM cc_questions
UNION ALL SELECT 'event', id, title, author_name, created_on FROM cc_events
UNION ALL SELECT 'poll', id, title, author_name, created_on FROM cc_polls;
SELECT id, COUNT(*) AS n FROM cc_all_posts GROUP BY id;
id | n
----+---
1 | 4
既存テーブルは変更なしですが、UNION ALL を書いている箇所すべてに1行ずつ足す必要があります。一覧はビューにまとめておくと、直す場所が1か所になります。
| 種別追加の作業 | STI | CTI | 具象テーブル継承 |
|---|---|---|---|
| 既存テーブルの ALTER | 列追加+CHECK 2本 | 親の CHECK 1本 | なし |
| 新しいテーブル | なし | 1つ | 1つ |
| 一覧 SQL の変更 | なし | なし | UNION ALL に1つ追加 |
ポリモーフィック関連で外部キーが張れない状態と2つの代替
ポリモーフィック関連は、「どのテーブルを指すか」を表す type 列と、その id 列の組で参照先を切り替える書き方です。具象テーブル継承で「記事にもイベントにもコメントを付けたい」となったときに出てきます。
CREATE TABLE poly_comments (
id INT PRIMARY KEY,
target_type VARCHAR(10) NOT NULL,
target_id INT NOT NULL,
body VARCHAR(200) NOT NULL
);
target_id の参照先は行ごとに変わるので、FOREIGN KEY (target_id) REFERENCES … に書くテーブルを1つに決められません。その結果、次の3つがどれも通ります。
INSERT INTO poly_comments VALUES (1, 'article', 1, '参考になりました');
INSERT INTO poly_comments VALUES (2, 'article', 999, '存在しない記事へのコメント');
INSERT INTO poly_comments VALUES (3, 'aritcle', 1, '種別のつづり違い');
DELETE FROM cc_articles WHERE id = 1;
SELECT c.id, c.target_type, c.target_id, a.title
FROM poly_comments c
LEFT JOIN cc_articles a ON c.target_type = 'article' AND a.id = c.target_id
ORDER BY c.id;
id | target_type | target_id | title
----+-------------+-----------+-------
1 | article | 1 |
2 | article | 999 |
3 | aritcle | 1 |
存在しない記事へのコメント、つづりの違う種別、コメントが付いた記事の削除がすべて通り、3件とも参照先が見つからない行になりました。MySQL 8.0.46 でも同じ結果です。
代替1:交差テーブル(種類ごとに関連テーブルを作る)
コメント本体は1テーブルにし、「どの記事のコメントか」「どのイベントのコメントか」を種類ごとの交差テーブルに分けます。どちらの列にも外部キーを張れます。
CREATE TABLE x_comments (id INT PRIMARY KEY, body VARCHAR(200) NOT NULL);
CREATE TABLE x_article_comments (
comment_id INT PRIMARY KEY,
article_id INT NOT NULL,
FOREIGN KEY (comment_id) REFERENCES x_comments (id),
FOREIGN KEY (article_id) REFERENCES cc_articles (id)
);
CREATE TABLE x_event_comments (
comment_id INT PRIMARY KEY,
event_id INT NOT NULL,
FOREIGN KEY (comment_id) REFERENCES x_comments (id),
FOREIGN KEY (event_id) REFERENCES cc_events (id)
);
INSERT INTO x_comments VALUES (1, '参考になりました');
INSERT INTO x_article_comments VALUES (1, 1);
INSERT INTO x_comments VALUES (2, '存在しない記事へのコメント');
INSERT INTO x_article_comments VALUES (2, 999);
DELETE FROM cc_articles WHERE id = 1;
-- PostgreSQL 18.4
ERROR: insert or update on table "x_article_comments" violates foreign key constraint "x_article_comments_article_id_fkey"
DETAIL: Key (article_id)=(999) is not present in table "cc_articles".
ERROR: update or delete on table "cc_articles" violates foreign key constraint "x_article_comments_article_id_fkey" on table "x_article_comments"
DETAIL: Key (id)=(1) is still referenced from table "x_article_comments".
-- MySQL 8.0.46
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`appdb`.`x_article_comments`, CONSTRAINT `x_article_comments_ibfk_2` FOREIGN KEY (`article_id`) REFERENCES `cc_articles` (`id`))
ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`appdb`.`x_article_comments`, CONSTRAINT `x_article_comments_ibfk_2` FOREIGN KEY (`article_id`) REFERENCES `cc_articles` (`id`))
comment_id を主キーにしているので、1つのコメントが同じ交差テーブル内で2つの記事に付くことはありません。既存の種類別テーブルを変えずに足せるのが利点で、種類が増えるたびに交差テーブルが1つ増えます。
代替2:共通親テーブル(CTIの親を指す)
全種類に共通の親テーブルがあれば、コメントは親の id を指すだけで済みます。CTI の cti_posts をそのまま使えます。
CREATE TABLE cti_comments (
id INT PRIMARY KEY,
post_id INT NOT NULL,
body VARCHAR(200) NOT NULL,
FOREIGN KEY (post_id) REFERENCES cti_posts (id)
);
INSERT INTO cti_comments VALUES (1, 1, '参考になりました');
INSERT INTO cti_comments VALUES (2, 3, '参加します');
INSERT INTO cti_comments VALUES (3, 999, '存在しない投稿へのコメント');
-- PostgreSQL 18.4
ERROR: insert or update on table "cti_comments" violates foreign key constraint "cti_comments_post_id_fkey"
DETAIL: Key (post_id)=(999) is not present in table "cti_posts".
SELECT c.id, p.post_type, p.title, c.body
FROM cti_comments c JOIN cti_posts p ON p.id = c.post_id ORDER BY c.id;
id | post_type | title | body
----+-----------+-------------+------------------
1 | article | SQL入門メモ | 参考になりました
2 | event | 読書会 | 参加します
種類が増えてもコメント側は変更不要です。
| 条件 | 選ぶ代替 |
|---|---|
| 種類別テーブルがすでにあり、構成を変えたくない | 交差テーブル |
| 関連を付ける種類が2〜3個で、今後もあまり増えない | 交差テーブル |
| コメント・タグ・履歴など、全種類を指す関連が複数ある | 共通親テーブル(CTI) |
| 種類が今後も増える | 共通親テーブル(CTI) |
PostgreSQLのINHERITS・JSON列との違い
INHERITSは外部キーと一意制約が子に届かない
PostgreSQL には、テーブル定義を引き継ぐ INHERITS があります。親を SELECT すると子の行も返るので、CTI に似て見えます。
CREATE TABLE inh_posts (
id INT PRIMARY KEY, title VARCHAR(100) NOT NULL,
author_name VARCHAR(50) NOT NULL, created_on DATE NOT NULL
);
CREATE TABLE inh_articles (body TEXT NOT NULL, summary VARCHAR(200) NOT NULL)
INHERITS (inh_posts);
CREATE TABLE inh_events (held_on DATE NOT NULL, place VARCHAR(100) NOT NULL, capacity INT NOT NULL)
INHERITS (inh_posts);
INSERT INTO inh_articles VALUES (1, 'SQL入門メモ', 'sato', '2026-10-01', '本文A', '要約A');
INSERT INTO inh_events VALUES (1, '読書会', 'tanaka', '2026-10-03', '2026-11-01', '会議室A', 20);
SELECT tableoid::regclass AS source, id, title FROM inh_posts ORDER BY created_on;
source | id | title
--------------+----+-------------
inh_articles | 1 | SQL入門メモ
inh_events | 1 | 読書会
親に PRIMARY KEY を付けていても、子テーブルどうしで id=1 が重なって入りました。 さらに、親を指す外部キーは子の行を見つけられません。
CREATE TABLE inh_comments (
id INT PRIMARY KEY,
post_id INT NOT NULL REFERENCES inh_posts (id),
body VARCHAR(200) NOT NULL
);
INSERT INTO inh_comments VALUES (1, 1, '参考になりました');
ERROR: insert or update on table "inh_comments" violates foreign key constraint "inh_comments_post_id_fkey"
DETAIL: Key (post_id)=(1) is not present in table "inh_posts".
PostgreSQL 18 のドキュメント(5.11 Inheritance の Caveats)にも、インデックス(一意制約を含む)と外部キー制約は単一のテーブルにだけ適用され、継承した子には適用されない、と書かれています。全種類を指す外部キーが要る設計では、INHERITS ではなく CTI を自分で組みます。
JSON列は制約を自分で書く
固有の項目を JSON 列1つにまとめる方法もあります。
CREATE TABLE js_posts (
id INT PRIMARY KEY, post_type VARCHAR(10) NOT NULL,
title VARCHAR(100) NOT NULL, attrs JSON NOT NULL
);
INSERT INTO js_posts VALUES (3, 'event', '読書会',
'{"held_on":"2026-11-01","place":"会議室A","capacity":20}');
INSERT INTO js_posts VALUES (4, 'event', '勉強会',
'{"held_on":"11月ごろ","capasity":"たくさん"}');
2行目はキーのつづり違い(capasity)と、日付でも数値でもない値を含みますが、PostgreSQL 18.4 でも MySQL 8.0.46 でも登録できました。列の追加が要らない代わりに、型と必須の検査は CHECK やアプリケーションで書くことになります。
似た方法との違い
| 方法 | テーブル数 | 固有項目の型・必須の検査 | 全種類を指す外部キー | 向く場面 |
|---|---|---|---|---|
| STI | 1 | CHECK で書く | 張れる | 固有列が少なく、一覧が中心 |
| CTI | 親1+子n | 列の型と NOT NULL | 張れる | 固有列が多く、制約を効かせたい |
| 具象テーブル継承 | n | 列の型と NOT NULL | 張れない | 種類ごとに独立して扱う |
| INHERITS(PostgreSQL) | 親1+子n | 列の型と NOT NULL | 張れない(子の行に届かない) | 制約より定義の共有を優先する場合 |
| JSON 列 | 1 | 自分で書く | 張れる | 項目が頻繁に変わり、検索や集計に使わない付加情報 |
確かめて分かったこと
- STI の CHECK 制約は、PostgreSQL 18.4 と MySQL 8.0.46 で同じ文のまま動きました。 MySQL でも
ALTER TABLE … DROP CONSTRAINTで CHECK を外せます。 - CTI で子テーブルに種別の固定列を持たせ、(id, 種別) の複合外部キーにすると、「記事をイベントの子テーブルに入れる」登録が両方のデータベースで止まりました。 親の id だけを参照する形では、この食い違いは止まりません。
- 具象テーブル継承の一覧では、3種類とも id=1 が返りました。 画面のリンクや API では種類と id の組が必要です。
- ポリモーフィック関連では、存在しない id・つづりの違う種別・参照されている行の削除がすべて通りました。
- PostgreSQL の INHERITS は、親に主キーがあっても子どうしで id が重なり、親を指す外部キーは子の行を見つけられませんでした。
- 種別を1つ足したとき、既存テーブルの ALTER が必要だったのは STI(列追加と CHECK)と CTI(親の CHECK のみ)で、具象テーブル継承は既存テーブルの変更がありませんでした。
よくある質問
Q. 単一テーブル継承とクラステーブル継承の違いは? A. 固有列を同じテーブルに持つか、種類ごとの子テーブルに分けるかの違いです。単一テーブル継承は JOIN が要らない代わりに他の種類の列が NULL で並び、クラステーブル継承は JOIN が要る代わりに固有列へ NOT NULL や外部キーを付けられます。
Q. ポリモーフィック関連とは? 何が困るのか? A. 参照先のテーブル名を表す列と id 列の組で、行ごとに参照先を切り替える書き方です。外部キー制約を張れないので、存在しない行を指すデータをデータベースが止められません。交差テーブルか共通親テーブルに置き換えると外部キーが使えます。
Q. STI で NULL だらけになるのを避けるには? A. 固有列が増えてきたら、その種類の固有列だけを子テーブルに切り出します。全部を一度に移さなくても、列の多い種類から CTI の形にできます。STI のまま使う間は、種別と列の組み合わせを CHECK 制約で守ります。
Q. 具象テーブル継承で全種類の一覧が必要になったときは? A. UNION ALL のビューを1つ作り、種類名の列を足して返します。id は種類をまたいで重なるので、ビューの利用側では種類と id の組で行を特定します。一覧や全種類を指す関連が増えてきたら、共通親テーブルを足して CTI に寄せます。
まとめ
- 固有列が少なく一覧が多ければ単一テーブル継承、固有列が多く制約を効かせたければクラステーブル継承、共通の一覧が不要なら具象テーブル継承です。
- STI は種別と列の組み合わせを CHECK 制約で守ります。 制約に名前を付けると、エラー文から原因が分かります。
- CTI は (id, 種別) の複合外部キーで、親と子の種別の食い違いを止められます。
- 具象テーブル継承は id が種類をまたいで重なるので、一覧はビューにまとめ、種類と id の組で扱います。
- ポリモーフィック関連は外部キーが張れません。 交差テーブルか共通親テーブルに置き換えます。
- PostgreSQL の INHERITS は一意制約と外部キーが子に届かず、JSON 列は型と必須を自分で検査します。
- 種類が増える見込みと、全種類を指す関連の有無を先に確認すると、方式が決めやすくなります。
参考・出典
- PostgreSQL 18 Documentation「5.11. Inheritance」 https://www.postgresql.org/docs/18/ddl-inherit.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「テーブル継承の設計パターン|STI・CTI・具象テーブル継承の違い」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。