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 です。
-- 意図: 全ユーザーと、あれば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-- サブクエリの結果に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)
□ 一度に大量を更新しない(ロックが長引く。バッチとジョブのチャンク処理)
□ 実行内容と結果を記録に残す
□ 原則、直接触らない(スクリプト化してレビューを受ける)
□ どうしても必要なら、必ず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)が大きくずれていないか
□ 一番時間を使っているのはどのステップか
データが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対多の JOIN が複数 — 行が掛け算で増え、集計が重複する
- LEFT JOIN の条件を WHERE に書く — INNER JOIN になる
= NULLで判定 — 常に UNKNOWN。IS NULLを使うNOT IN+ サブクエリ — NULL が1件あると結果が0件になるCOUNT(*)とCOUNT(col)を混同 — NULL の扱いが違う- WHERE を忘れた UPDATE / DELETE — 全件に効く
- 本番で直接実行 — 先に SELECT、トランザクション、2人で確認
- 列に関数を適用 — インデックスが効かない
- 深い OFFSET — 後ろのページほど遅い。キーセット方式へ
ORDER BYの無いページング — 行が重複・欠落する- 開発環境の EXPLAIN で判断 — データ量が違えば計画も変わる
- 文字列結合でクエリを組む — 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件を返します。原因として最も可能性が高いのは?
本番で数万件のレコードのステータスを更新する必要があります。どう進めますか。
次の章では、行としては保存できないもの—— 画像やファイルの扱いに進みます。