プログラマのための IT 教科書

SQL の書き方

この部の 4 / 13 章 ・ 全体で 46 / 76 章 ・ 読了目安 50 分

この章を読むとできるようになること
  • JOIN による集計の重複に気づける
  • NULL の挙動を説明できる
  • 本番の更新を安全な手順で行える

前章で SQL の基本形を見ました。この章は、実務で書く SQL の話です。

SQL には、他の言語と違う難しさがあります。

□ 間違っていても、エラーにならずに「それらしい結果」が返る
□ 少し書き方を変えるだけで、実行時間が1000倍変わる
□ 1文字の書き間違いで、全件を更新できてしまう

「動いた」が「正しい」を意味しない——ここが最大の落とし穴です。

実行される順番を知る

書く順番と、実行される順番は違います。

書く順番:   SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT

実行順序:   FROM      どのテーブルから
         → WHERE     行を絞る
         → GROUP BY  まとめる
         → HAVING    まとめた後で絞る
         → SELECT    列を選ぶ・計算する      ← ここで別名が付く
         → ORDER BY  並べる
         → LIMIT     件数を絞る

これを知っていると、次の疑問が全部解けます。

-- なぜ WHERE で別名が使えないのか
SELECT price * 1.1 AS with_tax FROM items WHERE with_tax > 1000;
-- ✕ WHERE の時点では、まだ with_tax は存在しない
 
-- ORDER BY では使える(SELECT の後だから)
SELECT price * 1.1 AS with_tax FROM items ORDER BY with_tax;
-- ○
-- WHERE と HAVING の違いも、これで説明できる
SELECT user_id, COUNT(*) AS cnt
FROM orders
WHERE created_at >= '2026-08-01'   -- 集計する「前」に行を絞る
GROUP BY user_id
HAVING COUNT(*) >= 3;              -- 集計した「後」に絞る
迷ったら「絞るのは前か後か」
個々の行の条件      → WHERE(速い。集計対象そのものが減る)
集計結果に対する条件 → HAVING

WHERE で絞れるものを HAVING に書くと、無駄に全件を集計します。

JOIN — 最も事故が起きる場所

種類

-- INNER JOIN: 両方にある行だけ
SELECT o.id, u.name FROM orders o INNER JOIN users u ON o.user_id = u.id;
 
-- LEFT JOIN: 左は全部残す。右が無ければ NULL
SELECT u.name, o.id FROM users u LEFT JOIN orders o ON u.id = o.user_id;

「注文したことがないユーザーも含めたい」なら LEFT JOIN です。

LEFT JOIN の条件を WHERE に書くと INNER JOIN になる
-- 意図: 全ユーザーと、あれば2026年の注文
SELECT u.name, o.id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.created_at >= '2026-01-01';   -- ✕ 注文が無いユーザーが消える

o.created_at が NULL(注文が無い行)は、この条件で弾かれます。 結果として INNER JOIN と同じになります。

-- 正しい: 結合の条件は ON に書く
LEFT JOIN orders o ON u.id = o.user_id AND o.created_at >= '2026-01-01'

LEFT JOIN した表の列を WHERE に書いたら、そこで一度立ち止まってください。

集計が二重になる罠

実務で最も多く、最も気づきにくい間違いです。

-- 注文には「明細」と「配送履歴」がぶら下がっている
SELECT o.id, SUM(i.amount) AS total
FROM orders o
JOIN order_items i ON o.id = i.order_id
JOIN shipments s ON o.id = s.order_id      -- ← ここで行が増える
GROUP BY o.id;
注文1件に、明細3行 と 配送2行 がある
→ JOIN の結果は 3 × 2 = 6行
→ SUM(i.amount) は、明細の金額を2回ずつ足してしまう

エラーは出ません。金額が2倍になった結果が、静かに返ってきます。

-- 対策1: それぞれを先に集計してから結合する
SELECT o.id, i.total, s.cnt
FROM orders o
LEFT JOIN (SELECT order_id, SUM(amount) AS total FROM order_items GROUP BY order_id) i
       ON o.id = i.order_id
LEFT JOIN (SELECT order_id, COUNT(*) AS cnt FROM shipments GROUP BY order_id) s
       ON o.id = s.order_id;
行数を必ず確認する
-- JOIN の前後で行数が変わっていないか
SELECT COUNT(*) FROM orders;                          -- 1000
SELECT COUNT(*) FROM orders o JOIN order_items i ...; -- 3200 ← 増えている

「1対多の JOIN を2つ以上書いたら疑う」 と覚えてください。 集計値がおかしい時、まずここを確認します。

NULL — SQL 最大の罠

NULL は「値が無い」ではなく「不明」です。 だから、NULL との比較はすべて「不明」になります。

NULL = NULL      -- 真でも偽でもない(UNKNOWN)
NULL <> 1        -- UNKNOWN
NULL + 1         -- NULL
 
-- 判定にはこれを使う
WHERE col IS NULL
WHERE col IS NOT NULL
`NOT IN` と NULL
-- サブクエリの結果に1つでも NULL があると、結果は必ず0件になる
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM banned);

banned.user_id に NULL が1行でもあると、 「NULL でないことを証明できない」ため、全行が除外されます。

-- 安全な書き方
SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM banned b WHERE b.user_id = u.id);

NOT IN はサブクエリと組み合わせない。NOT EXISTS を使う—— これは覚えておく価値があります。

集約関数と NULL

COUNT(*)         -- 行数(NULL も数える)
COUNT(col)       -- col が NULL でない行数   ← 違う
SUM(col)         -- NULL は無視される
AVG(col)         -- NULL を除いた平均(0 として扱わない)
-- 「未回答を0点として平均を出す」なら明示する
AVG(COALESCE(score, 0))

COUNT(*) と COUNT(col) の差は、レビューで見るポイントです。

サブクエリと CTE

長い SQL は、CTE(WITH)で段階的に書くと読めるようになります。

WITH recent_orders AS (
  SELECT * FROM orders WHERE created_at >= '2026-08-01'
),
totals AS (
  SELECT user_id, SUM(amount) AS total
  FROM recent_orders
  GROUP BY user_id
)
SELECT u.name, t.total
FROM totals t
JOIN users u ON u.id = t.user_id
WHERE t.total >= 10000
ORDER BY t.total DESC;

入れ子のサブクエリを、名前を付けて上から並べただけです。 読む人(半年後の自分を含む)にとっての差は大きくなります。

EXISTS と IN

-- 「存在するか」だけを知りたいなら EXISTS
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)
 
-- 値の集合と比較するなら IN
WHERE status IN ('paid', 'shipped')

EXISTS は1件見つかった時点で打ち切れるので、大きなテーブルで有利なことがあります。

ウィンドウ関数 — 知らないと損をする

「グループごとの最新1件」を取りたい——実務で頻出の要求です。

-- ユーザーごとの、最新の注文を1件だけ
SELECT * FROM (
  SELECT o.*,
         ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
  FROM orders o
) t
WHERE rn = 1;
PARTITION BY  グループの分け方(GROUP BY に相当するが、行は減らない)
ORDER BY      グループ内での並び
ROW_NUMBER()  その並びでの順位(1, 2, 3...)

集約と違い、行が減りません。 各行に「順位」や「累計」を付け足せます。

SUM(amount) OVER (PARTITION BY user_id ORDER BY created_at)  -- 累計
LAG(amount) OVER (ORDER BY created_at)                        -- 前の行の値
RANK() / DENSE_RANK()                                         -- 同順位の扱いが違う
これを知らないと、アプリ側でループする

ウィンドウ関数を知らないと、

全件取得 → アプリでグループ化 → ソート → 先頭だけ取る

という実装になります。データ量が増えた瞬間に破綻します(計算量とデータ構造)。

SQL で1回のクエリで済むものを、アプリに持ち込まないでください。

更新と削除は、慎重に

WHERE を忘れた UPDATE / DELETE は、全件に効きます。

DELETE FROM users;   -- 全ユーザーが消える

実務での手順を、型として持ってください。

-- 1. まず SELECT で、対象と件数を確認する
SELECT COUNT(*) FROM orders WHERE status = 'draft' AND created_at < '2026-01-01';
 
-- 2. トランザクションで囲む
BEGIN;
UPDATE orders SET status = 'expired'
WHERE status = 'draft' AND created_at < '2026-01-01';
-- 3. 影響行数を確認する(想定と違えば ROLLBACK)
COMMIT;
□ 本番で実行する前に、必ず SELECT で件数を見る
□ トランザクションで囲む(確認してから COMMIT)
□ 一度に大量を更新しない(ロックが長引く。バッチとジョブのチャンク処理)
□ 実行内容と結果を記録に残す
本番 DB を直接触る時のルール
□ 原則、直接触らない(スクリプト化してレビューを受ける)
□ どうしても必要なら、必ず2人以上で確認する
□ 読み取り専用の接続を既定にしておく
□ 実行前に「これを流します」を共有する

深夜に一人で本番 DB を触るのが、最も事故が起きる状況です(エンジニアとしての立ち振る舞い)。

ページネーション

-- よく使われるが、後ろのページほど遅い
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
--                                                       ↑ 10000行を読み飛ばしている
-- キーセット方式: 前ページの最後の値を起点にする
SELECT * FROM orders
WHERE (created_at, id) < ('2026-08-01 10:00:00', 5000)
ORDER BY created_at DESC, id DESC
LIMIT 20;

OFFSET は「読み飛ばす」ので、深くなるほど遅くなります。 一覧の無限スクロールや API のページングでは、キーセット方式を使います(API を設計する)。

そして見つけにくいバグのとおり、ORDER BY が無いページングは行が重複・欠落します。 並び順が一意になるまで列を足してください(created_at だけでなく id も)。

実行計画を読む

EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 123;

最低限、この2つを見分けられれば十分です。

Seq Scan     全件を上から読んでいる     ← 件数が多いと危険
Index Scan   インデックスを使っている   ← 効いている
□ 想定したインデックスが使われているか
□ 実際の行数(actual rows)と、見積もり(rows)が大きくずれていないか
□ 一番時間を使っているのはどのステップか
開発環境の EXPLAIN は当てにならない

データが100件しかなければ、全件走査のほうが速いので、 オプティマイザは Index Scan を選びません。

□ 本番相当のデータ量で確認する(バッチとジョブ・見つけにくいバグ)
□ 統計情報が古いと、誤った計画が選ばれることがある

遅い SQL のよくある原因

書き方なぜ遅いか
WHERE DATE(created_at) = '2026-08-01'列に関数を適用するとインデックスが効かない
WHERE name LIKE '%sato'前方一致でないと範囲を絞れない
WHERE user_id = '123'(列は整数)暗黙の型変換でインデックスが効かないことがある
SELECT *不要な列まで転送する。インデックスだけで完結できなくなる
相関サブクエリ行ごとに実行されることがある
OR の多用インデックスが使いにくい。UNION ALL で分けられることも
-- 関数を使わず、範囲で書く
WHERE created_at >= '2026-08-01' AND created_at < '2026-08-02'

計算量とデータ構造の「INDEX が効くのは並び順を使えるから」が、そのまま当てはまります。

文字列の比較

□ 大文字小文字を区別するかは、DB と照合順序(collation)の設定次第
□ 全角・半角、濁点の正規化(見つけにくいバグ)
□ LIKE のワイルドカード(% _)をユーザー入力に含める時はエスケープする
□ 前方一致以外はインデックスが効かない。全文検索が必要なら専用の仕組みを使う

安全な書き方

// 必ずパラメータ化する(セキュリティ)
rows, err := db.QueryContext(ctx, "SELECT * FROM users WHERE email = $1", email)
 
// 文字列結合は、絶対にしない
// "SELECT * FROM users WHERE email = '" + email + "'"   ← SQL インジェクション
テーブル名・列名はパラメータにできない
□ 値 → プレースホルダで渡せる
□ テーブル名・列名・ORDER BY の列 → 渡せない

動的にソート列を変えたい場合は、許可リストで検証してください。

var allowed = map[string]string{"created_at": "created_at", "name": "name"}
col, ok := allowed[req.SortBy]
if !ok { return ErrInvalidSort }

ユーザー入力を、そのまま SQL の一部にしない——例外はありません。

読みやすい SQL を書く

-- 悪い
select o.id,u.name,sum(i.amount) from orders o join users u on o.user_id=u.id join order_items i on i.order_id=o.id where o.created_at>='2026-08-01' group by o.id,u.name;
 
-- 良い
SELECT
    o.id,
    u.name,
    SUM(i.amount) AS total_amount
FROM orders o
JOIN users u        ON u.id = o.user_id
JOIN order_items i  ON i.order_id = o.id
WHERE o.created_at >= '2026-08-01'
GROUP BY o.id, u.name;
□ キーワードは大文字、識別子は小文字(チームの規約に合わせる)
□ 1行1要素で縦に並べる
□ 別名は意味のあるものにする(`a` `b` ではなく `o` `u`)
□ 長いものは CTE で段階に分ける
□ なぜその条件なのかは、コメントで残す(読まれるコードを書く)

SQL はレビューされにくいので、書く側が読みやすくする責任があります。

注文ごとの合計金額を出すクエリで、金額が実際の2倍になっています。クエリは orders に order_items と shipments を JOIN しています。原因は?

実務の落とし穴まとめ

  1. 1対多の JOIN が複数 — 行が掛け算で増え、集計が重複する
  2. LEFT JOIN の条件を WHERE に書く — INNER JOIN になる
  3. = NULL で判定 — 常に UNKNOWN。IS NULL を使う
  4. NOT IN + サブクエリ — NULL が1件あると結果が0件になる
  5. COUNT(*) と COUNT(col) を混同 — NULL の扱いが違う
  6. WHERE を忘れた UPDATE / DELETE — 全件に効く
  7. 本番で直接実行 — 先に SELECT、トランザクション、2人で確認
  8. 列に関数を適用 — インデックスが効かない
  9. 深い OFFSET — 後ろのページほど遅い。キーセット方式へ
  10. ORDER BY の無いページング — 行が重複・欠落する
  11. 開発環境の EXPLAIN で判断 — データ量が違えば計画も変わる
  12. 文字列結合でクエリを組む — SQL インジェクション(セキュリティ)

まとめ

  • 実行順序(FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY)を知ると、 別名の使える場所も WHERE と HAVING の違いも説明できる
  • JOIN は最も事故が多い。1対多を2つ繋いだら行数を必ず確認する
  • NULL は「不明」。IS NULL で判定し、NOT IN は使わず NOT EXISTS
  • 長い SQL は CTE で段階的に書く
  • ウィンドウ関数を知らないと、アプリ側でループすることになる
  • 更新・削除は SELECT で確認 → トランザクション → 影響行数の確認
  • ページングは OFFSET ではなくキーセット、そして ORDER BY を一意に
  • EXPLAIN で Seq Scan と Index Scan を見分ける。本番相当のデータで
  • 列に関数を適用しない。範囲で書く
  • 必ずパラメータ化。テーブル名・列名は許可リストで検証する

公式ドキュメント

迷ったら一次情報に戻ってください。

対象リンク
PostgreSQL 公式https://www.postgresql.org/docs/
MySQL 公式(日本語)https://dev.mysql.com/doc/refman/8.0/ja/
Use The Index, Luke!(日本語)https://use-the-index-luke.com/ja
SQL Style Guide(日本語)https://www.sqlstyle.guide/ja/
PostgreSQL: EXPLAIN の使い方https://www.postgresql.org/docs/current/using-explain.html

章末問題

`SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM blocked)` が、なぜか0件を返します。原因として最も可能性が高いのは?

本番で数万件のレコードのステータスを更新する必要があります。どう進めますか。

次の章では、行としては保存できないもの—— 画像やファイルの扱いに進みます。

読み終わったら記録しておくと、目次で進み具合が分かります。