🔩 ねじき教室 Go と Web の教室

複数のテーブルと JOIN

読了目安 約4分

テーブルを分ける理由と、JOIN で複数のテーブルを突き合わせて読む方法を学びます。

この章の目次

なぜ 1 枚の巨大テーブルにしないのか

掲示板のデータを 2 枚に分けず、投稿者の名前とメールアドレスを posts に直接持たせる案から考えます。

テキスト
posts(分けない案)
+----+-----------+-------------------+--------------------------+
| id | user_name | user_email        | body                     |
+----+-----------+-------------------+--------------------------+
|  1 | alice     | [email protected] | はじめまして!           |
|  2 | bob       | [email protected]   | 今日は暑いですね         |
|  3 | alice     | [email protected] | ラーメン食べたい         |
+----+-----------+-------------------+--------------------------+

この形には二つの問題があります。

  • 重複:alice の名前とメールアドレスが、alice が投稿するたびにコピーされる。1000 回投稿すれば 1000 回書かれる。
  • 更新漏れ:alice が名前を変えたら、alice の全投稿の行を書き換える必要がある。1 行でも漏れると古い名前と新しい名前が混在し、どちらが正しいのかデータからは判定できなくなる。

そこで、ユーザーの情報は users に 1 回だけ書き、posts からは番号で参照します。 posts.user_id のように、他のテーブルの主キーの値を持って参照する列を外部キーと呼びます。 「一つの事実は一つの場所にだけ書く」方向へテーブルを整理する設計手法が正規化です。

データをそろえる

前章で posts の中身をいじったので、次のブロックを丸ごと貼り付けて作り直します。 DROP TABLE は、テーブルを丸ごと消す文です。

SQL
DROP TABLE IF EXISTS posts;

CREATE TABLE posts (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  body TEXT NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO posts (user_id, body, created_at) VALUES
  (1, 'はじめまして!', '2026-03-05 09:00:00'),
  (2, '今日は暑いですね', '2026-03-05 12:00:00'),
  (1, 'ラーメン食べたい', '2026-03-06 12:30:00'),
  (3, '精進します', '2026-03-07 21:00:00'),
  (2, 'ISUCON に出たい', '2026-03-08 10:00:00'),
  (1, '眠い', '2026-03-08 23:00:00');

alice(id が 1)が 3 件、bob(2)が 2 件、carol(3)が 1 件投稿し、dave(4)と eve(5)は未投稿です。

SELECT だけでは名前が出ない

posts を SELECT すると、投稿者は user_id という番号でしか出てきません。

SQL
SELECT user_id, body FROM posts LIMIT 3;
テキスト
+---------+--------------------------+
| user_id | body                     |
+---------+--------------------------+
|       1 | はじめまして!           |
|       2 | 今日は暑いですね         |
|       1 | ラーメン食べたい         |
+---------+--------------------------+

「alice: はじめまして!」の形にするには、posts の各行を users と突き合わせる JOIN を使います。

INNER JOIN

SQL
SELECT posts.id, users.name, posts.body
FROM posts
JOIN users ON posts.user_id = users.id;
テキスト
+----+-------+--------------------------+
| id | name  | body                     |
+----+-------+--------------------------+
|  1 | alice | はじめまして!           |
|  2 | bob   | 今日は暑いですね         |
|  3 | alice | ラーメン食べたい         |
|  4 | carol | 精進します               |
|  5 | bob   | ISUCON に出たい          |
|  6 | alice | 眠い                     |
+----+-------+--------------------------+

FROM posts JOIN users でつなぎ、ON に書いた式が突き合わせの条件です。 「posts の user_id と users の id が等しい行同士を組にせよ」と読みます。

列は posts.id のように「テーブル名.列名」の形で区別します。 この JOIN は正確には INNER JOIN と呼ばれ、INNER は省略できます。

行はどう組み合わされるのか

テキスト
posts                                        users
(id=1, user_id=1, body='はじめまして!')   ─┐
(id=3, user_id=1, body='ラーメン食べたい') ─┼─→ (id=1, name='alice')
(id=6, user_id=1, body='眠い')             ─┘
(id=2, user_id=2, body='今日は暑いですね') ─┬─→ (id=2, name='bob')
(id=5, user_id=2, body='ISUCON に出たい')  ─┘
(id=4, user_id=3, body='精進します')       ───→ (id=3, name='carol')

ON の条件に合って組が見つかった行だけが返るので、投稿のない dave と eve は現れません。

LEFT JOIN

相手が見つからない行(投稿ゼロのユーザーなど)も残したいときは LEFT JOIN を使います。 FROM 側(左側)のテーブルの行はすべて残り、相手がいない行では相手側の列が NULL になります。

SQL
SELECT users.name, posts.body
FROM users
LEFT JOIN posts ON posts.user_id = users.id;
テキスト
+-------+--------------------------+
| name  | body                     |
+-------+--------------------------+
| alice | はじめまして!           |
| alice | ラーメン食べたい         |
| alice | 眠い                     |
| bob   | 今日は暑いですね         |
| bob   | ISUCON に出たい          |
| carol | 精進します               |
| dave  | NULL                     |
| eve   | NULL                     |
+-------+--------------------------+

競プロの言葉で言えば

JOIN の中身は 2 つの配列の突き合わせで、素朴に書けば二重ループの O(N × M) です。 競プロなら片方を map に入れておくところで、DB も同じ工夫をします。 その道具がインデックス、探すためにテーブルとは別に持っておくデータ構造です。

JOIN や WHERE の速さは、突き合わせる列のインデックスの有無で桁違いに変わります。 仕組みは /sql/07-index/ で扱います。