🐿 こども経済新聞 ゼロから学ぶPython18歳までに読む本声で覚える英語英語の試験ガイド
段階
0 / 14 ステップ

まず、全体の地図

どこまで行けば「実務で使える」のかを先に知っておく

SQLは、他のプログラミング言語にくらべて覚えることが少なく、覚えた分だけすぐ役に立つという珍しい言語です。 事務職・営業・企画・マーケティングなど、開発職でない人が最初に身につけると効果が大きいのもSQLです。 一方で、入門書を1冊読んだだけの人と実務で戦力になる人の差もはっきりしています。 その差は「複数の表を正しくつなげるか」「集計の単位を取りちがえないか」「重いクエリを直せるか」の3点にほぼ集約されます。 このページは、そこまでを4つの段階に分けて並べています。

段階ステップ身につくこと目安この段階を終えると
準備 STEP 0 データベースの考え方、練習環境の用意、サンプルデータの投入 1〜2日 自分の手元でSQLを実行して結果を見られる
基礎 STEP 1〜4 SELECT、WHERE、並べ替え、集計とGROUP BY 2〜4週間 1つの表から必要な数字を自力で出せる
応用 STEP 5〜8 JOIN、サブクエリ、CASE式とNULL処理、データの更新 3〜6週間 複数の表を組み合わせて、実データの分析ができる
実務 STEP 9〜13 テーブル設計、ウィンドウ関数、CTE、性能改善、現場の作法 1〜3か月 依頼された集計を、正確に・速く・安全に出せる

※ 目安は1日1時間ほど学習した場合です。SQLは「読める」と「書ける」の差が特に大きい言語です。例を写すだけで終わらせず、必ず自分で条件を変えて実行してください。

目次(クリックでジャンプ)

14ステップの学習ロードマップ

STEP 0 準備

データベースを用意して、1本のSQLを実行する

目安 1〜2日
このステップの到達点 ── 自分のパソコンでSQLを打ち込み、結果が表として返ってくる。このページ全体で使う練習用データが入った状態になる。

データベースとSQLとは何か

データベースとは、大量のデータをきちんと整理してためておき、必要なぶんだけ取り出せるようにした仕組みです。とくに、行と列からなる表(テーブル)の形でデータを持つものをリレーショナルデータベースといい、いまも業務システムの中心にあります。Excelの表とよく似ていますが、何億行あっても壊れず、何人が同時に使っても矛盾しない点が決定的に違います。

SQL(エスキューエル、またはシークェル)は、そのデータベースに命令を出すための言語です。「売上テーブルから、今月の、東京都の分だけ、金額の大きい順に取り出せ」といった指示を、たった数行で書けます。手順ではなく「ほしい結果」を書くだけで、実際にどう探すかはデータベース側が考えてくれる、という点が他の言語と大きく異なります。

SQLを学ぶと何が変わるか 「この数字を出してほしい」と誰かに依頼して待つ側から、自分で出す側に変わります。集計の依頼待ちが数日から数分になり、しかも条件を少し変えて何度でも試せます。事務・営業・企画の仕事でSQLの費用対効果が高いのは、この一点です。

1. 練習環境を用意する

学習の最初にサーバーを立てる必要はありません。SQLite(エスキューライト)という、ファイル1つでデータベースになる仕組みを使えば、数分で始められます。次のどれかを選んでください。

方法入手先向いている人備考
DB Browser for SQLitesqlitebrowser.org(無料)ほとんどの人におすすめ画面でSQLを書いて実行できる。結果も表で見える
ブラウザで動くSQLsqliteonline.com など今すぐ試したい人インストール不要。ただし保存は自己責任
コマンドの sqlite3Macは標準搭載黒い画面に慣れている人ターミナルで sqlite3 shop.db
PostgreSQL / MySQL公式サイト職場で使う製品が決まっている人本格的だが準備の手間は増える

ここではDB Browser for SQLiteを前提に説明します。起動したら「新しいデータベース」を押し、shop.db という名前で保存してください。テーブル作成のダイアログが出たら、いったん「キャンセル」で閉じ、上部の「SQL実行」タブを開きます。ここがSQLを書く場所です。

2. 練習用データを作る

次のSQLをまるごとコピーして貼り付け、実行してください(DB Browserでは Ctrl+Enter / ⌘+Enter)。架空の雑貨店「みどり商店」の、顧客・商品・注文のデータが入ります。このページのすべての例は、このデータを前提にしています。

setup.sql ── 最初に1回だけ実行
-- 顧客テーブル
CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  name        TEXT    NOT NULL,
  prefecture  TEXT,
  created_at  TEXT    NOT NULL
);

-- 商品テーブル
CREATE TABLE products (
  product_id INTEGER PRIMARY KEY,
  name       TEXT    NOT NULL,
  category   TEXT    NOT NULL,
  price      INTEGER NOT NULL
);

-- 注文テーブル(1回の注文=1行)
CREATE TABLE orders (
  order_id    INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL,
  ordered_at  TEXT    NOT NULL,
  status      TEXT    NOT NULL
);

-- 注文明細テーブル(注文の中の1商品=1行)
CREATE TABLE order_items (
  order_item_id INTEGER PRIMARY KEY,
  order_id      INTEGER NOT NULL,
  product_id    INTEGER NOT NULL,
  quantity      INTEGER NOT NULL,
  unit_price    INTEGER NOT NULL
);

INSERT INTO customers VALUES
 (1, '佐藤 みなみ', '東京都', '2024-01-15'),
 (2, '鈴木 けんた', '大阪府', '2024-02-03'),
 (3, '高橋 あおい', '東京都', '2024-02-20'),
 (4, '田中 りく',   '北海道', '2024-05-11'),
 (5, '伊藤 さくら', '福岡県', '2024-07-02'),
 (6, '渡辺 そうた', '大阪府', '2025-01-09');

INSERT INTO products VALUES
 (1, 'ノート',       '文具', 180),
 (2, 'ボールペン',   '文具', 120),
 (3, '消しゴム',     '文具',  90),
 (4, '緑茶',         '飲料', 150),
 (5, 'コーヒー',     '飲料', 220),
 (6, 'クッキー',     '食品', 380),
 (7, 'マグカップ',   '雑貨', 980),
 (8, 'トートバッグ', '雑貨', 1500);

INSERT INTO orders VALUES
 (101, 1, '2025-03-01', '完了'),
 (102, 2, '2025-03-03', '完了'),
 (103, 1, '2025-03-10', '完了'),
 (104, 3, '2025-03-15', 'キャンセル'),
 (105, 4, '2025-04-02', '完了'),
 (106, 2, '2025-04-18', '完了'),
 (107, 4, '2025-05-05', '完了'),
 (108, 1, '2025-05-20', '発送中'),
 (109, 6, '2025-06-01', '完了'),
 (110, 3, '2025-06-11', '完了');

INSERT INTO order_items VALUES
 ( 1, 101, 1,  2,  180),
 ( 2, 101, 4,  3,  150),
 ( 3, 102, 7,  1,  980),
 ( 4, 103, 2,  5,  120),
 ( 5, 103, 6,  2,  380),
 ( 6, 104, 8,  1, 1500),
 ( 7, 105, 5,  4,  220),
 ( 8, 105, 3,  3,   90),
 ( 9, 106, 1,  1,  180),
 (10, 106, 6,  1,  380),
 (11, 107, 8,  1, 1500),
 (12, 107, 4,  2,  150),
 (13, 108, 2, 10,  120),
 (14, 109, 7,  2,  980),
 (15, 109, 5,  2,  220),
 (16, 110, 1,  3,  180),
 (17, 110, 6,  3,  380),
 (18, 110, 2,  2,  120);

DB Browserを使っている場合は、実行後に画面上部の「変更を書き込み」を必ず押してください。押さないとファイルに保存されません。

3. 4つのテーブルの関係

実務のデータベースは、必ずこのように複数の表に分かれています。1枚の巨大な表にしないのは、同じ情報を何度も書かずに済ませ、書きまちがいを防ぐためです。表と表はID(番号)でつながっています。

テーブル1行が表すもの主な列他の表とのつながり
customers顧客ひとりcustomer_id, name, prefecture, created_atordersから参照される
products商品1種類product_id, name, category, priceorder_itemsから参照される
orders注文1回order_id, customer_id, ordered_at, statuscustomer_id で顧客とつながる
order_items注文の中の商品1つorder_id, product_id, quantity, unit_priceorder_id と product_id で両方とつながる

order_itemsunit_price(そのとき売った単価)があるのは意図的です。商品の値段はあとから変わるので、売れた時点の価格を注文側に残しておくのが実務の定石です。

4. はじめてのSQL

「SQL実行」の画面に、次の1行を書いて実行してください。

first.sql
SELECT * FROM products;
実行結果
product_id | name         | category | price
-----------+--------------+----------+------
         1 | ノート       | 文具     |   180
         2 | ボールペン   | 文具     |   120
         3 | 消しゴム     | 文具     |    90
         4 | 緑茶         | 飲料     |   150
         5 | コーヒー     | 飲料     |   220
         6 | クッキー     | 食品     |   380
         7 | マグカップ   | 雑貨     |   980
         8 | トートバッグ | 雑貨     |  1500

これが読めれば準備は完了です。SELECT は「取り出せ」、* は「すべての列」、FROM products は「productsという表から」という意味です。最後の ;(セミコロン)は文の終わりの印です。

結果の見た目は道具によって変わります このページの実行結果は、罫線を使って見やすく整えたものです。DB Browserでは表形式、コマンドの sqlite3 では 1|ノート|文具|180 のように縦棒区切りで表示されます。数字と並び順が同じなら正解です。
やってしまいがちな失敗; の付け忘れ(複数行を一度に実行するとき特に)② 全角スペースの混入(見た目では気づけず「構文エラー」になります)③ 文字列を囲む記号のまちがい。SQLの文字列は シングルクォート '東京都' で囲みます。ダブルクォートは製品によって「列名」の意味になるため、使わないでください。
練習問題

customersテーブルの中身を全部表示するSQLを書いてください。また、行数がいくつか数えてください。

解答を見る
answer.sql
SELECT * FROM customers;

※ 6行返ります。表の名前を変えるだけで、他はまったく同じです。SQLはこのように、同じ形をくり返し使います。

STEP 1 基礎

SELECT ── 必要な列だけを取り出す

目安 3〜5日
このステップの到達点 ── 列を選ぶ、計算した列を作る、列に別名を付ける、重複を消す。SQLの基本の形が手に馴染んでいる。

列を選ぶ

* はすべての列という意味ですが、実務では必要な列だけを名指しするのが基本です。列名はカンマで区切って並べます。

select_columns.sql
SELECT name, price
FROM products;
実行結果(8行)
name         | price
-------------+------
ノート       |   180
ボールペン   |   120
消しゴム     |    90
緑茶         |   150
コーヒー     |   220
クッキー     |   380
マグカップ   |   980
トートバッグ |  1500

並べた順に列が出ます。元の表の列順とは関係ありません。SQLは改行しても動くので、SELECTFROM で行を分けて書くのが読みやすさの定石です。

なぜ SELECT * を避けるのか ① 余計な列まで転送するので遅くなる ② 後から列が追加・削除されたときに結果が勝手に変わる ③ 読む人に「何がほしいのか」が伝わらない。中身を確かめるときは *、人に渡すSQLは列を明記と覚えてください。

計算した列を作る

SELECTには、列名だけでなく計算式も書けます。元の表は一切変わりません。取り出すときに計算されるだけです。

calc.sql
SELECT name, price, price * 1.1
FROM products;
実行結果(先頭3行)
name       | price | price * 1.1
-----------+-------+------------
ノート     |   180 |       198.0
ボールペン |   120 |       132.0
消しゴム   |    90 |        99.0

列の見出しが price * 1.1 という式そのままになってしまいました。これでは人に見せられません。別名(エイリアス)を付けます。

AS で別名を付ける

alias.sql
SELECT
  name          AS 商品名,
  price         AS 税抜price,
  price * 1.1   AS 税込price
FROM products;
実行結果(先頭3行)
商品名     | 税抜price | 税込price
-----------+-----------+----------
ノート     |       180 |     198.0
ボールペン |       120 |     132.0
消しゴム   |        90 |      99.0

AS は省略もできますが(price 税抜price)、書いたほうが読みまちがいが減るので省略しないことをおすすめします。別名に空白や記号を含めたいときは AS "税込 価格" のようにダブルクォートで囲みます。

四則演算と整数の割り算 使える演算子は + - * / と、余りを求める % です。注意すべきは割り算で、多くのデータベースでは整数どうしの割り算は整数になります7 / 23)。小数がほしいときは 7 * 1.0 / 2 のように、どちらかを小数にしてください。これは実務でも本当によく起きる事故です。

文字列をつなぐ

文字列の連結は || を使います(SQLite・PostgreSQL・Oracle)。MySQLでは CONCAT(a, b)、SQL Serverでは + です。

concat.sql
SELECT
  name || '(' || category || ')' AS 表示名,
  price || '円'                    AS 価格表示
FROM products;
実行結果(先頭3行)
表示名             | 価格表示
-------------------+---------
ノート(文具)     | 180円
ボールペン(文具) | 120円
消しゴム(文具)   | 90円

重複を消す ── DISTINCT

「どんな種類があるのか」を知りたいときは DISTINCT です。取り出した結果から、まったく同じ行をまとめて1行にします。

distinct.sql
SELECT DISTINCT category
FROM products;
実行結果
category
--------
文具
飲料
食品
雑貨

DISTINCTSELECTの直後に1つだけ書き、列ごとには付けられません。SELECT DISTINCT category, price と書くと「categoryとpriceの組み合わせ」で重複が判定されます。ここを誤解している人はとても多いので注意してください。

コメントを書く

comment.sql
-- 1行のコメント。この行は実行されない
SELECT
  name,   -- 行の途中からでも書ける
  price
/* 複数行に
   わたるコメント */
FROM products;

実務のSQLは他人が読みます。「なぜこの条件なのか」をコメントに残す習慣をいまから付けてください。何をしているかはSQLを読めばわかりますが、なぜそうしたかは書いた人にしかわかりません。

練習問題

productsから、商品名・分類・「1個買ったときの税込価格(小数点以下切り捨てでなくてよい)」の3列を、日本語の列名を付けて表示してください。

解答を見る
answer.sql
SELECT
  name              AS 商品名,
  category          AS 分類,
  price * 1.1       AS 税込価格
FROM products;

ROUND(price * 1.1) とすれば四捨五入した整数になります。関数はSTEP 7で扱います。

STEP 2 基礎

WHERE ── 条件に合う行だけに絞り込む

目安 1週間
このステップの到達点 ── 比較・AND/OR・IN・BETWEEN・LIKE・IS NULLを使い分けられる。NULLは「=」で比べられない理由を説明できる。

基本の形

WHERE は「この条件に当てはまる行だけ」という指定です。1行ずつ条件を確かめ、真になった行だけが残ります。

where_basic.sql
SELECT name, category, price
FROM products
WHERE category = '文具';
実行結果
name       | category | price
-----------+----------+------
ノート     | 文具     |   180
ボールペン | 文具     |   120
消しゴム   | 文具     |    90

文字列はシングルクォートで囲み、数値はそのまま書きます。等しいかどうかは =(イコール1つ)です。他の言語のような == ではありません。

比較の演算子

演算子意味注意点
=等しいprice = 180NULLには使えない
<> または !=等しくないstatus <> 'キャンセル'標準は <>
> >= < <=大小price >= 500文字列にも使える(辞書順)
BETWEEN a AND ba以上b以下price BETWEEN 100 AND 300両端を含む
IN (…)どれかに一致category IN ('文具','飲料')ORを並べるより読みやすい
LIKEあいまい一致name LIKE 'コ%'%は任意の文字列、_は任意の1文字
IS NULL値が入っていないprefecture IS NULL= NULL は必ず失敗する

複数の条件を組み合わせる

where_and.sql
-- 文具のうち、150円以上のもの
SELECT name, price
FROM products
WHERE category = '文具'
  AND price >= 150;
実行結果
name   | price
-------+------
ノート |   180

AND は「両方とも」、OR は「どちらか」です。混ぜて使うときは必ずかっこを付けてください。ANDのほうが先に評価されるため、かっこがないと意図しない結果になります。

where_or.sql
-- ✕ 意図とちがう:「文具かつ200円未満」または「飲料すべて」になる
SELECT name FROM products
WHERE category = '文具' AND price < 200 OR category = '飲料';

-- ○ 正しい:「文具か飲料」のうち200円未満
SELECT name, category, price FROM products
WHERE (category = '文具' OR category = '飲料')
  AND price < 200;
実行結果(下のSQL)
name       | category | price
-----------+----------+------
ノート     | 文具     |   180
ボールペン | 文具     |   120
消しゴム   | 文具     |    90
緑茶       | 飲料     |   150

INとBETWEEN

in_between.sql
-- 上のORはINで書くと短くなる
SELECT name, category
FROM products
WHERE category IN ('文具', '飲料');

-- 100円以上300円以下(両端を含む)
SELECT name, price
FROM products
WHERE price BETWEEN 100 AND 300;
実行結果(下のSQL)
name       | price
-----------+------
ノート     |   180
ボールペン |   120
緑茶       |   150
コーヒー   |   220

※ 除外したいときは NOT IN / NOT BETWEEN です。ただし NOT IN はリストにNULLが混ざると1行も返らなくなるという有名な罠があります(後述)。

あいまい検索 ── LIKE

like.sql
SELECT name FROM products WHERE name LIKE 'ノート';    -- 完全一致と同じ
SELECT name FROM products WHERE name LIKE 'コ%';       -- 「コ」で始まる
SELECT name FROM products WHERE name LIKE '%グ%';      -- 「グ」を含む
SELECT name FROM customers WHERE name LIKE '佐藤%';    -- 姓が佐藤
実行結果(3番目のSQL)
name
------------
マグカップ
LIKEの落とし穴'%○○%'(前方にも%)は索引が効かず非常に遅くなります。大きな表では避け、全文検索の仕組みを使ってください(STEP 12)。② 大文字小文字の区別は製品によって違います。③ %_ そのものを探したいときは ESCAPE 句が必要です。

NULL ── 「値が入っていない」という状態

SQLでいちばん初心者を混乱させるのがNULLです。NULLは0でも空文字でもなく、「不明」という状態を表します。そして「不明」は何とも比較できません。だから = NULL は真にも偽にもならず、その行は結果に現れません。

null.sql
-- 試しに、都道府県が未登録の顧客を1件足してみる
INSERT INTO customers VALUES (7, '山本 ゆい', NULL, '2025-02-14');

SELECT name, prefecture FROM customers WHERE prefecture = NULL;   -- ✕ 0行
SELECT name, prefecture FROM customers WHERE prefecture IS NULL;  -- ○ 1行

-- 確認できたら消しておく(このあとの例は顧客6人を前提にしています)
DELETE FROM customers WHERE customer_id = 7;
実行結果(下のSQL)
name       | prefecture
-----------+-----------
山本 ゆい  | (NULL)
もっと大事な落とし穴 WHERE prefecture <> '東京都' と書いても、都道府県がNULLの人は返ってきません。「東京都ではない」かどうかが不明だからです。実務では「東京都以外を全部出したつもりが、住所未登録の顧客が丸ごと抜けていた」という集計ミスが起こります。NULLがありうる列では WHERE prefecture <> '東京都' OR prefecture IS NULL のように明示してください。
練習問題

ordersテーブルから、2025年4月以降に発生した、キャンセル以外の注文を取り出してください(日付は '2025-04-02' のような文字列で入っています)。

解答を見る
answer.sql
SELECT order_id, ordered_at, status
FROM orders
WHERE ordered_at >= '2025-04-01'
  AND status <> 'キャンセル';
実行結果
order_id | ordered_at | status
---------+------------+--------
     105 | 2025-04-02 | 完了
     106 | 2025-04-18 | 完了
     107 | 2025-05-05 | 完了
     108 | 2025-05-20 | 発送中
     109 | 2025-06-01 | 完了
     110 | 2025-06-11 | 完了

※ 日付を YYYY-MM-DD の形で保存しておくと、文字列のまま大小比較しても日付順と一致します。これがこの書式を使う理由です。

STEP 3 基礎

並べ替えと件数制限 ── ORDER BY / LIMIT

目安 3〜5日
このステップの到達点 ── 複数の条件で並べ替えられる。「上位10件」を取り出せる。そしてSQLが実際に処理される順番を説明できる。

並べ替える ── ORDER BY

SQLの結果は、指定しないかぎり順番が保証されません。「だいたい登録順に見える」ことはありますが、それは偶然です。順番が必要なら必ず ORDER BY を書いてください。

order_by.sql
SELECT name, price
FROM products
ORDER BY price DESC;   -- DESC = 大きい順。ASC(小さい順)は省略できる
実行結果
name         | price
-------------+------
トートバッグ |  1500
マグカップ   |   980
クッキー     |   380
コーヒー     |   220
ノート       |   180
緑茶         |   150
ボールペン   |   120
消しゴム     |    90

複数のキーで並べ替える

カンマで区切ると「第1条件が同じだったら第2条件で」という並べ替えになります。並べ替えの方向(DESC)は列ごとに指定します。

order_multi.sql
SELECT order_id, customer_id, ordered_at
FROM orders
ORDER BY customer_id ASC, ordered_at DESC;
実行結果
order_id | customer_id | ordered_at
---------+-------------+-----------
     108 |           1 | 2025-05-20
     103 |           1 | 2025-03-10
     101 |           1 | 2025-03-01
     106 |           2 | 2025-04-18
     102 |           2 | 2025-03-03
     110 |           3 | 2025-06-11
     104 |           3 | 2025-03-15
     107 |           4 | 2025-05-05
     105 |           4 | 2025-04-02
     109 |           6 | 2025-06-01

顧客ごとにまとまり、その中で新しい注文が上に来ました。「顧客別の最新の注文」のような要求は、この形が出発点になります(きれいな解き方はSTEP 10のウィンドウ関数です)。

件数を絞る ── LIMIT / OFFSET

limit.sql
-- 高い順に3件だけ
SELECT name, price FROM products
ORDER BY price DESC
LIMIT 3;

-- 4位から3件(ページ送りに使う)
SELECT name, price FROM products
ORDER BY price DESC
LIMIT 3 OFFSET 3;
実行結果(下のSQL)
name     | price
---------+------
コーヒー |   220
ノート   |   180
緑茶     |   150
製品による書き方の違い LIMIT はSQLite・PostgreSQL・MySQLの書き方です。SQL Serverでは SELECT TOP 3 … または OFFSET 3 ROWS FETCH NEXT 3 ROWS ONLY、Oracleでは FETCH FIRST 3 ROWS ONLY を使います。「件数を絞る書き方だけは方言が大きい」と覚えておいてください。
LIMITはORDER BYとセットで 並べ替えずにLIMITを付けると、「どの3件が返るか分からない」SQLになります。今日と明日で結果が変わっても文句は言えません。LIMITを書いたら必ずORDER BYも書く、と決めてしまうのが安全です。

並べ替えでの注意点

NULLはどこに来るか ── 製品によって違います。SQLite・MySQLはNULLが先頭、PostgreSQL・Oracleは末尾に来ます。明示したい場合は ORDER BY prefecture NULLS LAST と書きます(PostgreSQL・Oracle・SQLite 3.30以降)。

日本語の並び順 ── ORDER BY name で日本語を並べても、多くの場合五十音順にはなりません。内部の文字コード順になるためです(漢字は特にばらばらになります)。実務では、ふりがなを入れる name_kana 列を別に用意して、そちらで並べ替えるのが定番の解決策です。

SQLが処理される順番

SQLは書く順番と実行される順番が違います。これを知っているかどうかが、エラーの意味が分かる人と分からない人の分かれ目です。

実行順やることここで使えないもの
1FROM / JOINどの表を使うか決め、つなぐ
2WHERE行を絞り込むSELECTで付けた別名、集約関数
3GROUP BY行をグループにまとめる
4HAVINGグループを絞り込む
5SELECT列を選び、計算し、別名を付ける
6ORDER BY並べ替える―(別名が使える
7LIMIT件数を絞る

この順番から、次の2つがすぐに導けます。①WHEREではSELECTで付けた別名が使えない(WHEREのほうが先に動くから)。②ORDER BYでは別名が使える(SELECTの後だから)。

order_alias.sql
-- ○ ORDER BYでは別名が使える
SELECT name, price * 1.1 AS 税込
FROM products
WHERE price >= 200          -- ここで「税込 >= 200」とは書けない
ORDER BY 税込 DESC;
実行結果
name         | 税込
-------------+--------
トートバッグ | 1650.0
マグカップ   | 1078.0
クッキー     |  418.0
コーヒー     |  242.0
練習問題

order_itemsから、「数量 × 単価」がもっとも大きい明細を3件、金額の大きい順に表示してください。列は order_id、product_id、金額の3つとします。

解答を見る
answer.sql
SELECT
  order_id,
  product_id,
  quantity * unit_price AS 金額
FROM order_items
ORDER BY 金額 DESC
LIMIT 3;
実行結果
order_id | product_id | 金額
---------+------------+------
     109 |          7 | 1960
     104 |          8 | 1500
     107 |          8 | 1500

※ 同じ1500が2つあるため、3位と4位の順序は保証されません。順序を確定させたいときは ORDER BY 金額 DESC, order_id のように、重複しない列を最後に足すのが実務の作法です。

STEP 4 基礎

集計 ── COUNT・SUM・GROUP BY・HAVING

目安 1〜2週間
このステップの到達点 ── 「〇〇別の合計・平均・件数」を自力で出せる。WHEREとHAVINGを正しく使い分けられる。ここがSQLの山場であり、いちばん役に立つ場所です。

集約関数 ── たくさんの行を1つの値にする

関数意味NULLの扱い
COUNT(*)行数NULLも数える
COUNT(列)その列がNULLでない行数NULLは数えない
COUNT(DISTINCT 列)重複を除いた種類数NULLは数えない
SUM(列)合計NULLは無視。全部NULLなら結果もNULL
AVG(列)平均NULLの行は分母にも入らない
MIN(列) / MAX(列)最小 / 最大NULLは無視。文字列や日付にも使える
aggregate.sql
SELECT
  COUNT(*)   AS 商品数,
  SUM(price) AS 価格合計,
  AVG(price) AS 平均価格,
  MIN(price) AS 最安,
  MAX(price) AS 最高
FROM products;
実行結果
商品数 | 価格合計 | 平均価格 | 最安 | 最高
-------+----------+----------+------+------
     8 |     3620 |    452.5 |   90 | 1500

集約関数を使うと、何行あっても結果は1行になります。この「行がつぶれる」感覚をつかむのが第一歩です。

COUNT(*) と COUNT(列) は別物です 「顧客数を数えたつもりが、都道府県が未登録の人だけ抜けていた」という事故はここから起きます。行数を数えたいなら必ず COUNT(*)。特定の列に値が入っている行を数えたいときだけ COUNT(列) を使ってください。AVGも同様で、NULLの行は分母から除かれます。「NULLを0とみなして平均したい」場合は AVG(COALESCE(列, 0)) と書きます(STEP 7)。

グループごとに集計する ── GROUP BY

実務で本当に必要なのは、全体の合計ではなく「分類ごと」「月ごと」「顧客ごと」の数字です。それが GROUP BY です。

group_by.sql
SELECT
  category         AS 分類,
  COUNT(*)         AS 商品数,
  AVG(price)       AS 平均価格
FROM products
GROUP BY category
ORDER BY 平均価格;
実行結果
分類 | 商品数 | 平均価格
-----+--------+---------
文具 |      3 |    130.0
飲料 |      2 |    185.0
食品 |      1 |    380.0
雑貨 |      2 |   1240.0

やっていることは単純です。①同じ値の行を束ねる ②束ごとに集約関数を計算する ③束が1行になる。この3段階を頭の中で描けるようになれば、GROUP BYは終わりです。

SELECTに書ける列のルール GROUP BYを使ったSELECTには、①GROUP BYに書いた列 ②集約関数 しか書けません。上の例で name を足すと「どの商品名を出せばいいのか」が決まらないため、PostgreSQLやSQL Serverはエラーにします。SQLiteとMySQLは黙って適当な1行を返してしまうので、かえって危険です。迷ったら「その列は、束の中で1つに決まるか?」と自問してください。

もっと実務らしい例

group_order.sql
-- 注文ごとの合計金額(明細を束ねる)
SELECT
  order_id                      AS 注文番号,
  COUNT(*)                      AS 明細数,
  SUM(quantity)                 AS 合計点数,
  SUM(quantity * unit_price)    AS 合計金額
FROM order_items
GROUP BY order_id
ORDER BY 合計金額 DESC;
実行結果
注文番号 | 明細数 | 合計点数 | 合計金額
---------+--------+----------+---------
     109 |      2 |        4 |     2400
     110 |      3 |        8 |     1920
     107 |      2 |        3 |     1800
     104 |      1 |        1 |     1500
     103 |      2 |        7 |     1360
     108 |      1 |       10 |     1200
     105 |      2 |        7 |     1150
     102 |      1 |        1 |      980
     101 |      2 |        5 |      810
     106 |      2 |        2 |      560

この「明細を注文単位に束ねる」という操作が、実務のSQLでもっとも頻繁に登場します。レシートの1行1行から、レシート全体の金額を出す作業だと思ってください。

group_customer.sql
-- 顧客ごとの注文回数と、最初・最後の注文日
SELECT
  customer_id      AS 顧客番号,
  COUNT(*)         AS 注文回数,
  MIN(ordered_at)  AS 初回注文日,
  MAX(ordered_at)  AS 最終注文日
FROM orders
GROUP BY customer_id
ORDER BY 注文回数 DESC, 顧客番号;
実行結果
顧客番号 | 注文回数 | 初回注文日 | 最終注文日
---------+----------+------------+-----------
       1 |        3 | 2025-03-01 | 2025-05-20
       2 |        2 | 2025-03-03 | 2025-04-18
       3 |        2 | 2025-03-15 | 2025-06-11
       4 |        2 | 2025-04-02 | 2025-05-05
       6 |        1 | 2025-06-01 | 2025-06-01

※ 顧客5(伊藤さくら)は1度も注文していないため、この結果にまったく現れません。ordersにデータが無いからです。「注文が0回の顧客も0と表示したい」という要求はよくありますが、それにはSTEP 5の LEFT JOIN が必要です。

グループを絞り込む ── HAVING

WHERE集計する前の行を、HAVING集計した後のグループを絞ります。「注文が2回以上の顧客」は集計後の条件なのでHAVINGです。

having.sql
SELECT
  customer_id AS 顧客番号,
  COUNT(*)    AS 注文回数
FROM orders
WHERE status <> 'キャンセル'   -- 集計する前に、キャンセルを除く
GROUP BY customer_id
HAVING COUNT(*) >= 2            -- 集計した後に、2回以上のグループだけ残す
ORDER BY 注文回数 DESC, 顧客番号;
実行結果
顧客番号 | 注文回数
---------+---------
       1 |        3
       2 |        2
       4 |        2

顧客3が消えたことに注目してください。注文は2件ありますが、うち1件(104番)がキャンセルで、WHEREの段階で除かれたため1回になったからです。WHEREとHAVINGのどちらに書くかで、結果は変わります。

使い分けの覚え方 「1行ずつ見れば判断できる条件」はWHERE、「束ねてみないと分からない条件」はHAVING。そして可能なかぎりWHEREに書くのが性能上も有利です。集計前に行が減れば、集計の仕事も減るからです。

集計の結果を使いやすくする

round.sql
SELECT
  category                                  AS 分類,
  COUNT(*)                                  AS 商品数,
  ROUND(AVG(price), 1)                      AS 平均価格,
  ROUND(AVG(price) * 1.1)                   AS 税込平均,
  COUNT(DISTINCT price)                     AS 価格の種類数
FROM products
GROUP BY category
ORDER BY 商品数 DESC, 分類;
実行結果
分類 | 商品数 | 平均価格 | 税込平均 | 価格の種類数
-----+--------+----------+----------+-------------
文具 |      3 |    130.0 |    143.0 |            3
雑貨 |      2 |   1240.0 |   1364.0 |            2
飲料 |      2 |    185.0 |    204.0 |            2
食品 |      1 |    380.0 |    418.0 |            1

※ 日本語の並び順は文字コード順になるため、同じ商品数どうしの順序は環境によって変わることがあります。

練習問題

order_itemsから、商品番号ごとの「売れた合計数量」と「売上金額の合計」を出し、売上金額の大きい順に並べてください。ただし、合計数量が5点以上の商品だけに絞ってください。

解答を見る
answer.sql
SELECT
  product_id                 AS 商品番号,
  SUM(quantity)              AS 合計数量,
  SUM(quantity * unit_price) AS 売上金額
FROM order_items
GROUP BY product_id
HAVING SUM(quantity) >= 5
ORDER BY 売上金額 DESC;
実行結果
商品番号 | 合計数量 | 売上金額
---------+----------+---------
       6 |        6 |     2280
       2 |       17 |     2040
       5 |        6 |     1320
       1 |        6 |     1080
       4 |        5 |      750

※ ここではキャンセル注文(104番)や発送中の注文も含めて集計しています。「完了した注文だけ」に限定するには、ordersテーブルと結合する必要があります。それがSTEP 5です。

STEP 5 応用

JOIN ── 複数の表をつなぐ

目安 2週間
このステップの到達点 ── INNER JOINとLEFT JOINを使い分けられる。3つ以上の表をつなげる。結合すると行が増えることを理解し、集計との併用で数字を狂わせない。ここを越えられれば、実務のSQLの8割は書けます。

なぜ結合が必要か

ordersテーブルには customer_id(顧客番号)しか入っておらず、顧客名はありません。名前はcustomersテーブルにあります。この2つを顧客番号をたよりに横につなぐのがJOINです。

join_basic.sql
SELECT
  o.order_id    AS 注文番号,
  c.name        AS 顧客名,
  o.ordered_at  AS 注文日,
  o.status      AS 状態
FROM orders AS o
JOIN customers AS c
  ON o.customer_id = c.customer_id
ORDER BY o.order_id;
実行結果
注文番号 | 顧客名      | 注文日     | 状態
---------+-------------+------------+-----------
     101 | 佐藤 みなみ | 2025-03-01 | 完了
     102 | 鈴木 けんた | 2025-03-03 | 完了
     103 | 佐藤 みなみ | 2025-03-10 | 完了
     104 | 高橋 あおい | 2025-03-15 | キャンセル
     105 | 田中 りく   | 2025-04-02 | 完了
     106 | 鈴木 けんた | 2025-04-18 | 完了
     107 | 田中 りく   | 2025-05-05 | 完了
     108 | 佐藤 みなみ | 2025-05-20 | 発送中
     109 | 渡辺 そうた | 2025-06-01 | 完了
     110 | 高橋 あおい | 2025-06-11 | 完了

読み方は3つに分けると簡単です。①FROM で軸になる表を決める ②JOIN でつなぐ表を足す ③ON で「どの列とどの列が同じなら同じものとみなすか」を書く。この ON が結合の心臓部です。

AS o / AS cテーブルの別名です。以降 o.order_id のように短く書けます。両方の表に name のような同名の列があるとき、どちらの表のものか区別するためにも必要です。結合を書くときは必ず別名を付け、すべての列に「表名.」を付けるのが実務の作法です。

結合の種類

書き方意味使う場面
INNER JOIN(=JOIN両方にある行だけ基本。注文と顧客のように必ず相手がいる場合
LEFT JOIN左の表は全部残し、右が無ければNULL「0件の人も表示したい」とき
RIGHT JOINLEFTの逆ほぼ使わない(表の順を入れ替えてLEFTで書く)
FULL OUTER JOINどちらか一方にある行も全部2つのデータの突き合わせ。SQLite・MySQLでは使えない版もある
CROSS JOIN全組み合わせカレンダー表と商品の総当たりを作るときなど
自己結合同じ表を2回使う社員と上司、親子カテゴリなど

3つ以上の表をつなぐ

JOINは何回でも並べられます。明細に商品名を付けてみましょう。

join_three.sql
SELECT
  oi.order_id                 AS 注文番号,
  p.name                      AS 商品名,
  oi.quantity                 AS 数量,
  oi.unit_price               AS 単価,
  oi.quantity * oi.unit_price AS 金額
FROM order_items AS oi
JOIN products AS p ON oi.product_id = p.product_id
WHERE oi.order_id = 110;
実行結果
注文番号 | 商品名     | 数量 | 単価 | 金額
---------+------------+------+------+------
     110 | ノート     |    3 |  180 |  540
     110 | クッキー   |    3 |  380 | 1140
     110 | ボールペン |    2 |  120 |  240

結合と集計を組み合わせる

ここからが本番です。「完了した注文だけを対象に、商品別の売上を出す」という、実際に依頼されそうな集計をやってみます。3つの表をつなぎ、条件で絞り、商品ごとに束ねるという3つの操作の合わせ技です。

join_group.sql
SELECT
  p.category                  AS 分類,
  p.name                      AS 商品名,
  SUM(oi.quantity)            AS 数量,
  SUM(oi.quantity * oi.unit_price) AS 売上
FROM order_items AS oi
JOIN orders   AS o ON oi.order_id   = o.order_id
JOIN products AS p ON oi.product_id = p.product_id
WHERE o.status = '完了'
GROUP BY p.product_id, p.category, p.name
ORDER BY 売上 DESC;
実行結果
分類 | 商品名       | 数量 | 売上
-----+--------------+------+------
雑貨 | マグカップ   |    3 | 2940
食品 | クッキー     |    6 | 2280
雑貨 | トートバッグ |    1 | 1500
飲料 | コーヒー     |    6 | 1320
文具 | ノート       |    6 | 1080
文具 | ボールペン   |    7 |  840
飲料 | 緑茶         |    5 |  750
文具 | 消しゴム     |    3 |  270

合計は10,980円です。STEP 4の練習問題(キャンセルや発送中も含めた集計)と数字が違うことを確かめてください。「どの状態の注文を含めるか」で答えは変わります。集計を頼まれたら、まずこの定義を確認するのが仕事のコツです。

LEFT JOIN ── 0件の人も残す

STEP 4で、注文が0回の顧客5が結果から消えてしまいました。LEFT JOIN なら残せます。

left_join.sql
SELECT
  c.customer_id       AS 顧客番号,
  c.name              AS 顧客名,
  COUNT(o.order_id)   AS 注文回数
FROM customers AS c
LEFT JOIN orders AS o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name
ORDER BY c.customer_id;
実行結果
顧客番号 | 顧客名      | 注文回数
---------+-------------+---------
       1 | 佐藤 みなみ |        3
       2 | 鈴木 けんた |        2
       3 | 高橋 あおい |        2
       4 | 田中 りく   |        2
       5 | 伊藤 さくら |        0
       6 | 渡辺 そうた |        1
ここで COUNT(*) と書くと顧客5が「1」になります LEFT JOINで相手が見つからなかった行も、行としては1行存在するからです(中身はNULL)。LEFT JOINして数えるときは、必ず右側の表の列を指定して COUNT(o.order_id) と書く。これはSQLの面接でも定番の確認事項です。

LEFT JOINで条件を書く場所

もうひとつ、間違いが多い点です。LEFT JOINの右側の表に対する条件をWHEREに書くと、LEFT JOINがINNER JOINに戻ってしまいます。条件は ON に書きます。

left_join_where.sql
-- ✕ 顧客5が消える:WHEREはNULLの行も落としてしまう
SELECT c.name, COUNT(o.order_id)
FROM customers AS c
LEFT JOIN orders AS o ON c.customer_id = o.customer_id
WHERE o.status = '完了'
GROUP BY c.customer_id, c.name;

-- ○ 顧客5が0で残る:絞り込みをONの中に入れる
SELECT
  c.name AS 顧客名,
  COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS 売上
FROM customers AS c
LEFT JOIN orders AS o
  ON c.customer_id = o.customer_id AND o.status = '完了'
LEFT JOIN order_items AS oi
  ON o.order_id = oi.order_id
GROUP BY c.customer_id, c.name
ORDER BY 売上 DESC;
実行結果(下のSQL)
顧客名      | 売上
------------+------
田中 りく   | 2950
渡辺 そうた | 2400
佐藤 みなみ | 2170
高橋 あおい | 1920
鈴木 けんた | 1540
伊藤 さくら |    0

COALESCE(値, 0) は「NULLだったら0にする」という関数です(STEP 7で詳しく扱います)。これがないと、伊藤さくらの売上はNULLと表示されます。

結合すると行が増える、という感覚

JOINでいちばん怖いのは、エラーにならずに数字だけが静かに狂うことです。原因のほとんどが「結合によって行が増えたこと」に気づいていないケースです。

fanout.sql
-- 注文110は1行のはずが、明細3行と結合して3行になる
SELECT o.order_id, o.ordered_at, oi.product_id, oi.quantity
FROM orders AS o
JOIN order_items AS oi ON o.order_id = oi.order_id
WHERE o.order_id = 110;
実行結果
order_id | ordered_at | product_id | quantity
---------+------------+------------+---------
     110 | 2025-06-11 |          1 |        3
     110 | 2025-06-11 |          6 |        3
     110 | 2025-06-11 |          2 |        2

この状態で COUNT(*) を取ると、それは注文件数ではなく明細件数です。「注文件数を数えたつもりが、商品をたくさん買った人ほど多く数えられていた」という事故はここから生まれます。防ぐ方法は2つです。

  • 件数は COUNT(DISTINCT o.order_id) で数える(重複を除いて数える)
  • 先に明細を集計してから結合する(STEP 6の導出テーブル、STEP 11のCTE)
結合前に、必ず「1対1か、1対多か」を確認する 注文と顧客は多対1(1人が何回も注文する)なので、注文を軸にすれば行は増えません。注文と明細は1対多なので、注文を軸にすると行が増えます。この関係を先に紙に書いてからSQLを書くと、事故が激減します。
練習問題

都道府県ごとの売上合計を出してください。対象は「完了」の注文だけ、金額は明細の 数量 × 単価 の合計とします。

解答を見る
answer.sql
SELECT
  c.prefecture                     AS 都道府県,
  COUNT(DISTINCT o.order_id)       AS 注文件数,
  SUM(oi.quantity * oi.unit_price) AS 売上
FROM orders AS o
JOIN customers   AS c  ON o.customer_id = c.customer_id
JOIN order_items AS oi ON o.order_id    = oi.order_id
WHERE o.status = '完了'
GROUP BY c.prefecture
ORDER BY 売上 DESC;
実行結果
都道府県 | 注文件数 | 売上
---------+----------+------
東京都   |        3 | 4090
大阪府   |        3 | 3940
北海道   |        2 | 2950

※ 注文件数に COUNT(DISTINCT o.order_id) を使っている点が重要です。単なる COUNT(*) だと明細の数(東京都なら7)になってしまいます。福岡県が出てこないのは、注文が1件もないためです。0と表示したい場合はcustomersを軸にしたLEFT JOINに書き換えます。

STEP 6 応用

サブクエリ ── SQLの中にSQLを書く

目安 1〜2週間
このステップの到達点 ── 「平均より高い商品」のように2段階の問いをSQLで表現できる。EXISTSで「〜したことがない人」を出せる。集計結果をさらに集計できる。

サブクエリとは

「平均価格より高い商品を出して」と頼まれたとします。平均価格は先に計算しないと分かりません。このように答えを出すのに、別の答えが先に必要なとき、SQLの中にSQLを埋め込みます。これがサブクエリ(副問い合わせ)です。

subquery_scalar.sql
SELECT name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products)   -- 内側が先に計算される
ORDER BY price DESC;
実行結果
name         | price
-------------+------
トートバッグ |  1500
マグカップ   |   980

内側の SELECT AVG(price) FROM products が先に計算されて 452.5 になり、外側はそれを普通の数値として使います。このように1行1列だけを返すサブクエリをスカラサブクエリと呼び、値が書ける場所ならどこにでも置けます。

IN ── リストとして使う

サブクエリが複数行を返す場合は、IN と組み合わせます。

subquery_in.sql
-- 「完了した注文」に一度でも含まれた商品
SELECT product_id, name, price
FROM products
WHERE product_id IN (
  SELECT oi.product_id
  FROM order_items AS oi
  JOIN orders AS o ON oi.order_id = o.order_id
  WHERE o.status = '完了'
)
ORDER BY product_id;
実行結果
product_id | name         | price
-----------+--------------+------
         1 | ノート       |   180
         2 | ボールペン   |   120
         3 | 消しゴム     |    90
         4 | 緑茶         |   150
         5 | コーヒー     |   220
         6 | クッキー     |   380
         7 | マグカップ   |   980
         8 | トートバッグ |  1500

EXISTS / NOT EXISTS ── 「あるか、ないか」

「一度も注文していない顧客」のような不在を問う条件は、NOT EXISTS が最も安全で読みやすい書き方です。

not_exists.sql
SELECT c.customer_id, c.name, c.prefecture
FROM customers AS c
WHERE NOT EXISTS (
  SELECT 1                       -- 中身は何でもよい。「行があるか」だけを見る
  FROM orders AS o
  WHERE o.customer_id = c.customer_id   -- 外側の値を参照する(相関サブクエリ)
);
実行結果
customer_id | name       | prefecture
------------+------------+-----------
          5 | 伊藤 さくら | 福岡県

内側のSQLが外側の列(c.customer_id)を参照している点に注目してください。これを相関サブクエリといい、外側の1行ごとに内側が評価されます。

NOT IN の有名な罠 同じことを WHERE customer_id NOT IN (SELECT customer_id FROM orders) と書くこともできますが、内側の結果にNULLが1つでも混ざると、結果が必ず0行になります。「不明な値と等しくないか」が判定できないためです。エラーも警告も出ません。「〜でない」を書くときはNOT EXISTSを使うと決めておけば、この事故は起きません。

FROM句のサブクエリ(導出テーブル)

集計した結果を、さらに集計したいときに使います。SQLでは集約関数を入れ子にできない(AVG(SUM(x)) は書けない)ため、いったん表にしてから外側で集計します。

derived_table.sql
-- 完了注文1件あたりの平均金額
SELECT
  COUNT(*)             AS 注文件数,
  SUM(t.合計金額)      AS 総売上,
  ROUND(AVG(t.合計金額), 1) AS 平均注文単価
FROM (
  SELECT
    o.order_id,
    SUM(oi.quantity * oi.unit_price) AS 合計金額
  FROM orders AS o
  JOIN order_items AS oi ON o.order_id = oi.order_id
  WHERE o.status = '完了'
  GROUP BY o.order_id
) AS t;
実行結果
注文件数 | 総売上 | 平均注文単価
---------+--------+-------------
       8 |  10980 |       1372.5

「内側で注文ごとに束ね、外側でそれを平均する」── この2段構えは実務で毎日のように使います。読むときは必ず内側から読んでください。なお、この書き方はSTEP 11の WITH(CTE)を使うともっと読みやすくなります。

SELECT句のサブクエリ

subquery_select.sql
SELECT
  c.name AS 顧客名,
  (SELECT COUNT(*) FROM orders AS o
    WHERE o.customer_id = c.customer_id)          AS 注文回数,
  (SELECT MAX(o.ordered_at) FROM orders AS o
    WHERE o.customer_id = c.customer_id)          AS 最終注文日
FROM customers AS c
ORDER BY c.customer_id;
実行結果
顧客名      | 注文回数 | 最終注文日
------------+----------+-----------
佐藤 みなみ |        3 | 2025-05-20
鈴木 けんた |        2 | 2025-04-18
高橋 あおい |        2 | 2025-06-11
田中 りく   |        2 | 2025-05-05
伊藤 さくら |        0 | (NULL)
渡辺 そうた |        1 | 2025-06-01

LEFT JOIN+GROUP BYと同じ結果ですが、こちらのほうが読みやすいと感じる人も多いはずです。ただし行数が多い表では遅くなりがちです(外側の1行ごとに内側が動くため)。数百行なら気にせず、数十万行ならJOINに書き換える、と覚えておいてください。

サブクエリとJOIN、どちらを使うか

やりたいことおすすめ理由
相手の表の列も表示したいJOINサブクエリでは相手の列を取り出せない
条件として存在だけ確認したいEXISTS行が増えない。重複の心配がない
「〜でない」を書きたいNOT EXISTSNOT INのNULL問題を避けられる
集計をさらに集計したい導出テーブル / CTE集約関数は入れ子にできない
1つの値と比べたいスカラサブクエリもっとも短く書ける
練習問題

「完了注文の合計金額が2,000円を超えた注文」に含まれる商品名を、重複なく表示してください。

解答を見る
answer.sql
SELECT DISTINCT p.name AS 商品名
FROM order_items AS oi
JOIN products AS p ON oi.product_id = p.product_id
WHERE oi.order_id IN (
  SELECT o.order_id
  FROM orders AS o
  JOIN order_items AS x ON o.order_id = x.order_id
  WHERE o.status = '完了'
  GROUP BY o.order_id
  HAVING SUM(x.quantity * x.unit_price) > 2000
);
実行結果
商品名
------------
マグカップ
コーヒー

※ 条件を満たすのは注文109(2,400円)だけで、その中身はマグカップとコーヒーです。サブクエリの中でGROUP BY+HAVINGを使って「条件を満たす注文番号のリスト」を作り、外側でそれを使うという構成は、実務でも頻出の型です。

STEP 7 応用

CASE式・NULL処理・日付と文字列の関数

目安 1〜2週間
このステップの到達点 ── CASE式で条件によって値を変えられる。条件付き集計(横持ち集計)が書ける。NULLとゼロ除算を安全に処理できる。日付を月単位でまとめられる。

CASE式 ── SQLの「もし〜なら」

case_basic.sql
SELECT
  name  AS 商品名,
  price AS 価格,
  CASE
    WHEN price >= 1000 THEN '高価格帯'
    WHEN price >=  300 THEN '中価格帯'
    ELSE                    '低価格帯'
  END   AS 価格帯
FROM products
ORDER BY price DESC;
実行結果
商品名       | 価格 | 価格帯
-------------+------+---------
トートバッグ | 1500 | 高価格帯
マグカップ   |  980 | 中価格帯
クッキー     |  380 | 中価格帯
コーヒー     |  220 | 低価格帯
ノート       |  180 | 低価格帯
緑茶         |  150 | 低価格帯
ボールペン   |  120 | 低価格帯
消しゴム     |   90 | 低価格帯

CASEは上から順に判定し、最初に当てはまったところで止まります。だから条件は「厳しいものから順に」書きます。上の例で1000以上と300以上の順を逆にすると、1500円の商品も「中価格帯」になってしまいます。ELSE を書かないと、どれにも当てはまらない行はNULLになります。

条件付き集計 ── CASEと集約関数の合わせ技

ここがCASE式の本当の使いどころです。「状態ごとの件数を横に並べたい」という、Excelのクロス集計のような要求に対応できます。

pivot.sql
SELECT
  COUNT(*)                                              AS 全注文,
  SUM(CASE WHEN status = '完了'       THEN 1 ELSE 0 END) AS 完了,
  SUM(CASE WHEN status = '発送中'     THEN 1 ELSE 0 END) AS 発送中,
  SUM(CASE WHEN status = 'キャンセル' THEN 1 ELSE 0 END) AS キャンセル,
  ROUND(
    SUM(CASE WHEN status = 'キャンセル' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)
  , 1)                                                   AS キャンセル率
FROM orders;
実行結果
全注文 | 完了 | 発送中 | キャンセル | キャンセル率
-------+------+--------+------------+-------------
    10 |    8 |      1 |          1 |         10.0
この型は丸ごと覚える価値があります SUM(CASE WHEN 条件 THEN 1 ELSE 0 END) は「条件に当てはまる行数」、SUM(CASE WHEN 条件 THEN 金額 ELSE 0 END) は「条件に当てはまる行の金額合計」です。GROUP BYと組み合わせれば、行方向にも列方向にも分類した表が1本のSQLで作れます。割合を出すときに 100.0 と小数で書いているのは、整数どうしの割り算で0になるのを防ぐためです(STEP 1)。
pivot_group.sql
-- 都道府県 × 状態 のクロス集計
SELECT
  c.prefecture                                            AS 都道府県,
  COUNT(*)                                                AS 注文数,
  SUM(CASE WHEN o.status = '完了'       THEN 1 ELSE 0 END) AS 完了,
  SUM(CASE WHEN o.status = 'キャンセル' THEN 1 ELSE 0 END) AS キャンセル
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.customer_id
GROUP BY c.prefecture
ORDER BY 注文数 DESC, 都道府県;
実行結果
都道府県 | 注文数 | 完了 | キャンセル
---------+--------+------+-----------
東京都   |      5 |    3 |          1
大阪府   |      3 |    3 |          0
北海道   |      2 |    2 |          0

NULLを安全に扱う

関数意味使いどころ
COALESCE(a, b, …)最初にNULLでない値を返す標準SQL。どの製品でも使える
IFNULL(a, b)aがNULLならbSQLite・MySQL専用
NULLIF(a, b)aとbが等しければNULLゼロ除算を防ぐのに使う
null_func.sql
-- 割る数が0のときにエラーや無限大にならないようにする
SELECT
  c.name AS 顧客名,
  COUNT(o.order_id)                    AS 注文回数,
  COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS 売上,
  ROUND(
    COALESCE(SUM(oi.quantity * oi.unit_price), 0) * 1.0
    / NULLIF(COUNT(DISTINCT o.order_id), 0)
  , 1)                                 AS 平均単価
FROM customers AS c
LEFT JOIN orders      AS o  ON c.customer_id = o.customer_id AND o.status = '完了'
LEFT JOIN order_items AS oi ON o.order_id    = oi.order_id
GROUP BY c.customer_id, c.name
ORDER BY 売上 DESC;
実行結果
顧客名      | 注文回数 | 売上 | 平均単価
------------+----------+------+---------
田中 りく   |        4 | 2950 |   1475.0
渡辺 そうた |        2 | 2400 |   2400.0
佐藤 みなみ |        4 | 2170 |   1085.0
高橋 あおい |        3 | 1920 |   1920.0
鈴木 けんた |        3 | 1540 |    770.0
伊藤 さくら |        0 |    0 |   (NULL)

注文回数の列に注目してください。田中りくは完了注文2件のはずが4になっています。これは明細と結合したことで行が増えたためです(STEP 5の罠)。正しく数えるには COUNT(DISTINCT o.order_id) と書く必要があります。平均単価のほうは正しくDISTINCTを使っているので、2950 ÷ 2 = 1475.0 になっています。結合したあとのCOUNTは必ず疑ってください。

伊藤さくらの平均単価がNULLなのは、NULLIF(0, 0) がNULLになり、0で割らずに済んだからです。エラーで止まるより、NULLとして扱うほうが安全です。

日付を扱う

日付の関数は製品ごとの差がもっとも大きい部分です。まずSQLiteでの書き方を覚え、職場の製品に読み替えてください。

date_func.sql
-- 月ごとの売上(完了のみ)
SELECT
  strftime('%Y-%m', o.ordered_at)   AS 年月,
  COUNT(DISTINCT o.order_id)        AS 注文件数,
  SUM(oi.quantity * oi.unit_price)  AS 売上
FROM orders AS o
JOIN order_items AS oi ON o.order_id = oi.order_id
WHERE o.status = '完了'
GROUP BY strftime('%Y-%m', o.ordered_at)
ORDER BY 年月;
実行結果
年月    | 注文件数 | 売上
--------+----------+------
2025-03 |        3 | 3150
2025-04 |        2 | 1710
2025-05 |        1 | 1800
2025-06 |        2 | 4320

※ 日付が YYYY-MM-DD 形式の文字列なら、SUBSTR(ordered_at, 1, 7) でも同じ結果になり、こちらはどの製品でも動きます。

date_diff.sql
-- 顧客ごとの、初回注文から最終注文までの日数
SELECT
  customer_id                                    AS 顧客番号,
  MIN(ordered_at)                                AS 初回,
  MAX(ordered_at)                                AS 最終,
  julianday(MAX(ordered_at)) - julianday(MIN(ordered_at)) AS 経過日数
FROM orders
GROUP BY customer_id
ORDER BY 経過日数 DESC;
実行結果
顧客番号 | 初回       | 最終       | 経過日数
---------+------------+------------+---------
       3 | 2025-03-15 | 2025-06-11 |     88.0
       1 | 2025-03-01 | 2025-05-20 |     80.0
       2 | 2025-03-03 | 2025-04-18 |     46.0
       4 | 2025-04-02 | 2025-05-05 |     33.0
       6 | 2025-06-01 | 2025-06-01 |      0.0
やりたいことSQLitePostgreSQLMySQL
今日の日付date('now')CURRENT_DATECURDATE()
年月を取り出すstrftime('%Y-%m', d)to_char(d,'YYYY-MM')DATE_FORMAT(d,'%Y-%m')
月初に丸めるdate(d,'start of month')date_trunc('month', d)DATE_FORMAT(d,'%Y-%m-01')
7日後date(d,'+7 day')d + INTERVAL '7 day'DATE_ADD(d,INTERVAL 7 DAY)
日数の差julianday(a)-julianday(b)a - bDATEDIFF(a, b)
日付は必ず日付として保存する SQLiteには日付型が無いため文字列で持ちますが、他の製品には DATE / TIMESTAMP 型があります。「20250611」のような数値や、「2025/6/11」のような表記ゆれのある文字列で保存してはいけません。並べ替えも期間指定もできなくなり、後から直すのは大変な作業になります。

文字列を扱う

string_func.sql
SELECT
  name                        AS 商品名,
  LENGTH(name)                AS 文字数,
  UPPER(category)             AS 大文字,
  SUBSTR(name, 1, 2)          AS 先頭2文字,
  REPLACE(name, 'ー', '-')    AS 記号置換,
  TRIM('  余白あり  ')        AS 前後の空白除去
FROM products
LIMIT 3;
実行結果
商品名     | 文字数 | 大文字 | 先頭2文字 | 記号置換   | 前後の空白除去
-----------+--------+--------+-----------+------------+---------------
ノート     |      3 | 文具   | ノー      | ノ-ト      | 余白あり
ボールペン |      5 | 文具   | ボー      | ボ-ルペン  | 余白あり
消しゴム   |      4 | 文具   | 消し      | 消しゴム   | 余白あり

UPPER は英字にのみ効きます。LENGTH は製品によって「文字数」ではなく「バイト数」を返すことがあります(MySQLでは文字数は CHAR_LENGTH)。日本語を扱うときは必ず確認してください。

型を変換する ── CAST

cast.sql
SELECT
  CAST('2025' AS INTEGER) + 1        AS 数値にして計算,
  CAST(price AS TEXT) || '円'        AS 文字列にして連結,
  CAST(price AS REAL) / 3            AS 小数で割る
FROM products
WHERE product_id = 1;
実行結果
数値にして計算 | 文字列にして連結 | 小数で割る
---------------+------------------+-----------
          2026 | 180円            |      60.0
練習問題

分類ごとに「商品数」「1000円以上の商品数」「1000円以上の割合(%、小数第1位まで)」を出してください。

解答を見る
answer.sql
SELECT
  category                                            AS 分類,
  COUNT(*)                                            AS 商品数,
  SUM(CASE WHEN price >= 1000 THEN 1 ELSE 0 END)      AS 高額商品数,
  ROUND(
    SUM(CASE WHEN price >= 1000 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)
  , 1)                                                AS 割合
FROM products
GROUP BY category
ORDER BY 割合 DESC, 分類;
実行結果
分類 | 商品数 | 高額商品数 | 割合
-----+--------+------------+------
雑貨 |      2 |          1 |  50.0
文具 |      3 |          0 |   0.0
食品 |      1 |          0 |   0.0
飲料 |      2 |          0 |   0.0

※ 割合が同じ0.0の行の順序は、日本語の文字コード順によって変わることがあります。

STEP 8 応用

データを変える ── INSERT・UPDATE・DELETEとトランザクション

目安 1週間
このステップの到達点 ── データの登録・更新・削除ができる。トランザクションでまちがいを取り消せる。そして何より、本番のデータを壊さない手順が身についている。
先に、いちばん大事なことを ここから扱うSQLはデータを実際に書き換えます。SELECTと違い、間違えると元に戻りません。実務では、UPDATEやDELETEを実行する前に必ず①同じWHEREでSELECTして対象を確認する ②件数を確認する ③トランザクションで囲む。この3つを、例外なく毎回やってください。ベテランほど律儀にやっています。

INSERT ── 行を追加する

insert.sql
-- 列名を明記する書き方(実務ではこちらを使う)
INSERT INTO products (product_id, name, category, price)
VALUES (9, 'ふせん', '文具', 250);

-- 複数行をまとめて追加できる
INSERT INTO products (product_id, name, category, price)
VALUES
  (10, '色鉛筆',   '文具', 640),
  (11, 'ミネラル水', '飲料', 110);

SELECT * FROM products WHERE product_id >= 9;
実行結果
product_id | name       | category | price
-----------+------------+----------+------
         9 | ふせん     | 文具     |   250
        10 | 色鉛筆     | 文具     |   640
        11 | ミネラル水 | 飲料     |   110

列名を省いて INSERT INTO products VALUES (…) とも書けますが(STEP 0でそう書きました)、実務では列名を必ず書いてください。表に列が追加された瞬間、列名なしのINSERTは全部壊れるからです。

insert_select.sql
-- SELECTの結果をそのまま別の表に入れる(集計結果の保存やバックアップに使う)
CREATE TABLE products_backup AS SELECT * FROM products WHERE 1 = 0;  -- 空の同じ形の表

INSERT INTO products_backup (product_id, name, category, price)
SELECT product_id, name, category, price
FROM products
WHERE category = '文具';

SELECT COUNT(*) AS 退避件数 FROM products_backup;
実行結果
退避件数
--------
       5

WHERE 1 = 0 は「絶対に真にならない条件」で、列の形だけコピーして中身は空にする定番の書き方です。文具は先ほど追加した2件を含めて5件になっています。

UPDATE ── 値を書き換える

update.sql
-- ① まず、影響する行をSELECTで必ず確認する
SELECT product_id, name, price FROM products WHERE category = '飲料';

-- ② そのうえで、同じWHEREでUPDATEする
UPDATE products
SET price = price + 20
WHERE category = '飲料';

-- ③ 結果を確認する
SELECT product_id, name, price FROM products WHERE category = '飲料';
実行結果(③)
product_id | name       | price
-----------+------------+------
         4 | 緑茶       |   170
         5 | コーヒー   |   240
        11 | ミネラル水 |   130
WHEREを書き忘れたUPDATEは、全行を書き換えます UPDATE products SET price = 0; は、エラーにならずに全商品の価格を0にします。これは実際に何度も起きている事故です。防ぐ習慣は3つ。①WHEREを先に書いてからSETを書く ②必ずトランザクションで囲む ③本番環境では読み取り専用の権限で作業する

DELETE ── 行を消す

delete.sql
-- 練習で追加した3件を消して、元の状態に戻す
DELETE FROM products WHERE product_id >= 9;

-- 表そのものを消す(中身も定義も消える。取り消せない)
DROP TABLE products_backup;

-- 元に戻ったか確認
SELECT COUNT(*) AS 商品数, SUM(price) AS 価格合計 FROM products;
実行結果
商品数 | 価格合計
-------+---------
     8 |     3660

価格合計が3,660円になっているのは、先ほどのUPDATEで飲料2品を20円ずつ値上げしたままだからです。元に戻しておきましょう。

restore.sql
UPDATE products SET price = price - 20 WHERE category = '飲料';
SELECT SUM(price) AS 価格合計 FROM products;   -- 3620 に戻る

トランザクション ── まとめて確定、まとめて取り消し

トランザクションとは、複数の変更を「ひとかたまり」として扱う仕組みです。途中で失敗したら全部なかったことにできます。銀行の振込を思い浮かべてください。「Aから1万円引く」と「Bに1万円足す」は、両方成功するか、両方失敗するかでなければなりません。片方だけ成功すると、お金が消えます。

transaction.sql
BEGIN;                                        -- ここから

UPDATE products SET price = 999 WHERE product_id = 1;
SELECT product_id, name, price FROM products WHERE product_id = 1;   -- 999に見える

ROLLBACK;                                     -- やっぱり取り消す

SELECT product_id, name, price FROM products WHERE product_id = 1;   -- 180に戻っている
実行結果(最後のSELECT)
product_id | name   | price
-----------+--------+------
         1 | ノート |   180

COMMIT と書けば確定、ROLLBACK と書けば取り消しです。UPDATEやDELETEを実行するときは、まずBEGINを打つ。これを体に覚え込ませてください。結果を確認して問題なければCOMMIT、おかしければROLLBACK。この習慣があるだけで、致命的な事故のほとんどは防げます。

ACIDという4つの約束 データベースがトランザクションについて保証する性質を、頭文字をとってACIDと呼びます。原子性(全部やるか、全部やらないか)一貫性(ルールを壊さない)独立性(同時に動いても互いに影響しない)永続性(確定したら電源が落ちても消えない)。Excelのファイル共有では絶対に手に入らない、データベースを使う最大の理由がこれです。

あれば更新、なければ追加 ── UPSERT

upsert.sql
-- SQLite / PostgreSQL の書き方
INSERT INTO products (product_id, name, category, price)
VALUES (1, 'ノート', '文具', 200)
ON CONFLICT (product_id)
DO UPDATE SET price = excluded.price;

SELECT product_id, name, price FROM products WHERE product_id = 1;
実行結果
product_id | name   | price
-----------+--------+------
         1 | ノート |   200

※ MySQLでは INSERT … ON DUPLICATE KEY UPDATE price = VALUES(price)、SQL Serverでは MERGE 文を使います。日次でデータを取り込むバッチ処理では必須の書き方です。確認できたら UPDATE products SET price = 180 WHERE product_id = 1; で元に戻しておいてください。

練習問題

注文108(発送中)を「完了」に変えたいとします。安全な手順でSQLを書いてください。

解答を見る
answer.sql
-- ① 対象を確認(1件だけか?)
SELECT order_id, status FROM orders WHERE order_id = 108;

-- ② トランザクションを開始してから更新
BEGIN;
UPDATE orders SET status = '完了' WHERE order_id = 108;

-- ③ 結果を確認
SELECT order_id, status FROM orders WHERE order_id = 108;

-- ④ 問題なければ確定。おかしければ ROLLBACK;
ROLLBACK;   -- ← このページの以降の例は「発送中」のままを前提にしているので戻す

※ 実務なら④は COMMIT; です。ここでは以降の集計結果を合わせるため ROLLBACK; にしています。「WHEREで1件だと確認してから更新する」という順番そのものが答えだと思ってください。

STEP 9 実務

テーブル設計 ── 壊れないデータの入れ物を作る

目安 2週間
このステップの到達点 ── なぜ表を分けるのかを説明できる。主キー・外部キー・制約を付けたテーブルを設計できる。データ型の選択で将来の事故を防げる

1枚の表にまとめると、何が起きるか

もし注文データを、次のような1枚の表で持っていたらどうなるでしょう。

注文番号顧客名都道府県商品名単価数量
101佐藤 みなみ東京都ノート1802
101佐藤 みなみ東京都緑茶1503
103佐藤 みなみ東京都ボールペン1205
  • 同じ情報を何度も書く ── 佐藤さんの名前と住所が、注文するたびに増えていく
  • 直し忘れが起きる ── 引っ越しのとき、何百行も全部直さないと矛盾する(更新異常)
  • 注文がないと記録できない ── 顧客登録だけした人をこの表に置けない(挿入異常)
  • 消すと消えすぎる ── 注文を消したら顧客情報まで消える(削除異常)

これを防ぐため、「1つの事実は1か所にだけ書く」ように表を分けます。これが正規化です。難しい理論もありますが、実務では次の考え方でほぼ足ります。

正規化の実践的な要点くり返し項目を別の表にする(注文と明細を分ける)②主キーの一部にしか関係しない項目を別の表にする(顧客名は注文ではなく顧客の情報)③他の項目から決まる項目を別の表にする(都道府県は郵便番号から決まる)。この3つが第1〜第3正規形にほぼ対応します。

制約 ── データベースに「ルール」を教える

制約とは、そもそも間違ったデータが入らないようにする仕組みです。アプリ側のチェックはすり抜けることがありますが、データベースの制約は絶対に破れません。

制約意味
PRIMARY KEY行を1つに特定する列。重複もNULLも不可customer_id
FOREIGN KEY他の表に存在する値しか入れられない存在しない顧客の注文を防ぐ
NOT NULL空を許さない商品名、注文日
UNIQUE重複を許さないメールアドレス、社員番号
CHECK値の範囲や条件を決めるprice >= 0、数量は1以上
DEFAULT省略時の初期値登録日時、状態「未処理」
design.sql ── 制約をきちんと付けた設計
CREATE TABLE customers_v2 (
  customer_id INTEGER PRIMARY KEY,
  name        TEXT    NOT NULL,
  email       TEXT    NOT NULL UNIQUE,          -- 重複登録を防ぐ
  prefecture  TEXT,                             -- 未入力を許す列
  created_at  TEXT    NOT NULL DEFAULT (date('now'))
);

CREATE TABLE orders_v2 (
  order_id    INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL,
  ordered_at  TEXT    NOT NULL,
  status      TEXT    NOT NULL DEFAULT '受付'
              CHECK (status IN ('受付','発送中','完了','キャンセル')),
  FOREIGN KEY (customer_id) REFERENCES customers_v2(customer_id)
);

-- 存在しない顧客の注文は入らない(外部キー制約を有効にした場合)
PRAGMA foreign_keys = ON;
INSERT INTO orders_v2 (order_id, customer_id, ordered_at)
VALUES (1, 999, '2025-07-01');
実行結果
Runtime error: FOREIGN KEY constraint failed (19)

エラーになりました。これは成功です。矛盾したデータが入る前に、データベースが止めてくれました。CHECK のおかげで、状態に「かんりょう」「done」といった表記ゆれが混入することもありません。

SQLiteでは外部キーが既定で無効です 接続のたびに PRAGMA foreign_keys = ON; が必要です。PostgreSQLやMySQL(InnoDB)では既定で有効です。「制約は後で付ければいい」は、ほぼ必ず後悔します。データが増えてからでは、すでに矛盾したデータが入っていて制約を付けられません。

データ型の選び方

入れるもの選ぶ型やってはいけないこと
金額整数(円単位)か DECIMALFLOATで持つ。0.1+0.2が0.3にならず、集計が1円ずれる
日付・日時DATE / TIMESTAMP「2025/6/1」「令和7年6月1日」など文字列で持つ
電話番号・郵便番号文字列数値で持つ(先頭の0が消える)
区分・状態文字列+CHECK、またはコード表意味が分からない数字(1=完了?)だけを入れる
はい/いいえBOOLEAN か 0/1「はい」「Y」「1」が混在する

命名の作法

  • テーブル名は複数形customers)、列名は小文字とアンダースコアcreated_at
  • 主キーは テーブル名の単数形_idcustomer_id)。外部キーも同じ名前にすると結合が読みやすい
  • 予約語(ordergroupuser)を単体で使わない
  • 日本語の列名は避ける(このページでは分かりやすさのため別名にだけ使っています)
  • フラグ列は is_deleted のように is_ で始め、意味を否定形にしない

インデックス ── 検索を速くする索引

インデックスは、本の巻末にある索引と同じものです。無ければ1ページ目から順に全部読む(全表走査)ことになり、無いのと比べて数百倍の差が出ることもあります。

index.sql
-- よく絞り込みに使う列、よく結合に使う列に付ける
CREATE INDEX idx_orders_customer   ON orders(customer_id);
CREATE INDEX idx_orders_ordered_at ON orders(ordered_at);
CREATE INDEX idx_items_order       ON order_items(order_id);

-- 複数列のインデックス(左から順に効く)
CREATE INDEX idx_orders_status_date ON orders(status, ordered_at);
インデックスは万能ではありません更新が遅くなる(INSERT・UPDATEのたびに索引も更新される)②ディスクを消費する ③種類の少ない列(状態が3種類しかないなど)には効きにくい ④関数で加工した列には効かないWHERE strftime('%Y', ordered_at) = '2025' は索引が使えません。WHERE ordered_at >= '2025-01-01' AND ordered_at < '2026-01-01' と書けば使えます)。詳しくはSTEP 12で扱います。
練習問題

社内の備品貸出を管理するテーブルを設計してください。必要な情報は「誰が」「どの備品を」「いつ借りて」「いつ返したか」です。返却前のものは返却日が空になります。

解答を見る
answer.sql
CREATE TABLE employees (
  employee_id INTEGER PRIMARY KEY,
  name        TEXT NOT NULL,
  department  TEXT NOT NULL
);

CREATE TABLE equipments (
  equipment_id INTEGER PRIMARY KEY,
  name         TEXT NOT NULL,
  asset_no     TEXT NOT NULL UNIQUE          -- 資産番号は重複しない
);

CREATE TABLE loans (
  loan_id      INTEGER PRIMARY KEY,
  employee_id  INTEGER NOT NULL,
  equipment_id INTEGER NOT NULL,
  lent_on      TEXT    NOT NULL,
  returned_on  TEXT,                          -- 返却前はNULL
  CHECK (returned_on IS NULL OR returned_on >= lent_on),
  FOREIGN KEY (employee_id)  REFERENCES employees(employee_id),
  FOREIGN KEY (equipment_id) REFERENCES equipments(equipment_id)
);

-- 貸出中の一覧
SELECT e.name AS 社員, q.name AS 備品, l.lent_on AS 貸出日
FROM loans AS l
JOIN employees  AS e ON l.employee_id  = e.employee_id
JOIN equipments AS q ON l.equipment_id = q.equipment_id
WHERE l.returned_on IS NULL;

※ 要点は3つです。①人・物・貸出の3つに分ける(人と物は貸出のたびに増えない)②未返却をNULLで表す(「9999-12-31」などの架空の日付を入れない)③返却日が貸出日より前にならないようCHECKする。1つの表に全部入れると、備品名を直すのに全履歴を書き換えることになります。

STEP 10 実務

ウィンドウ関数 ── 行を残したまま集計する

目安 2〜3週間
このステップの到達点 ── 構成比・累計・順位・前月比を1本のSQLで出せる。「グループごとの最新1件」を取り出せる。ここができると、周りから「SQLが書ける人」として扱われます。

GROUP BYとの決定的な違い

GROUP BYは、集計すると元の行が消えます。商品ごとの売上を出せば、8行の結果になり、個々の明細は見えません。一方ウィンドウ関数は、元の行を残したまま、その行の隣に集計値を並べます。「明細を見せつつ、同時に全体に占める割合も出したい」という要求に応えられるのはこちらだけです。

window_ratio.sql
SELECT
  p.name                              AS 商品名,
  SUM(oi.quantity * oi.unit_price)    AS 売上,
  SUM(SUM(oi.quantity * oi.unit_price)) OVER ()  AS 全体売上,
  ROUND(
    SUM(oi.quantity * oi.unit_price) * 100.0
    / SUM(SUM(oi.quantity * oi.unit_price)) OVER ()
  , 1)                                AS 構成比
FROM order_items AS oi
JOIN orders   AS o ON oi.order_id   = o.order_id
JOIN products AS p ON oi.product_id = p.product_id
WHERE o.status = '完了'
GROUP BY p.product_id, p.name
ORDER BY 売上 DESC;
実行結果
商品名       | 売上 | 全体売上 | 構成比
-------------+------+----------+-------
マグカップ   | 2940 |    10980 |   26.8
クッキー     | 2280 |    10980 |   20.8
トートバッグ | 1500 |    10980 |   13.7
コーヒー     | 1320 |    10980 |   12.0
ノート       | 1080 |    10980 |    9.8
ボールペン   |  840 |    10980 |    7.7
緑茶         |  750 |    10980 |    6.8
消しゴム     |  270 |    10980 |    2.5

OVER () が付くと「集計はするが、行はつぶさない」という意味になります。かっこの中が空なら結果全体が集計の範囲(=ウィンドウ)です。構成比の計算は、これまでなら2本のSQLか導出テーブルが必要でした。

書き方の型

window_syntax.sql
関数(引数) OVER (
  PARTITION BY 区切りたい列     -- どの単位で区切るか(省略すると全体)
  ORDER BY     並べたい列       -- 順位・累計・前後の参照に使う
  ROWS BETWEEN … AND …          -- さらに細かい範囲指定(省略可)
)
関数意味典型的な用途
ROW_NUMBER()1,2,3… 同点でも別番号グループごとの最新1件、重複排除
RANK()同点は同順位、次は飛ぶ(1,2,2,4)売上順位
DENSE_RANK()同点は同順位、次は飛ばない(1,2,2,3)等級・ランク分け
SUM() OVER(ORDER BY …)累計累計売上、在庫推移
LAG() / LEAD()前の行 / 次の行の値前月比、前回来店からの間隔
AVG() OVER(…)移動平均など7日移動平均
NTILE(n)n等分したときの区分上位20%の顧客抽出

順位を付ける ── 3つの違いを見る

window_rank.sql
SELECT
  product_id                                  AS 商品番号,
  SUM(quantity)                               AS 数量,
  ROW_NUMBER() OVER (ORDER BY SUM(quantity) DESC, product_id) AS 行番号,
  RANK()       OVER (ORDER BY SUM(quantity) DESC) AS 順位,
  DENSE_RANK() OVER (ORDER BY SUM(quantity) DESC) AS 密順位
FROM order_items
GROUP BY product_id
ORDER BY 数量 DESC, 商品番号;
実行結果
商品番号 | 数量 | 行番号 | 順位 | 密順位
---------+------+--------+------+-------
       2 |   17 |      1 |    1 |      1
       1 |    6 |      2 |    2 |      2
       5 |    6 |      3 |    2 |      2
       6 |    6 |      4 |    2 |      2
       4 |    5 |      5 |    5 |      3
       3 |    3 |      6 |    6 |      4
       7 |    3 |      7 |    6 |      4
       8 |    2 |      8 |    8 |      5

数量6が3つ並んだところを見てください。ROW_NUMBERは無理やり別番号を振り、RANKは同じ2位にして次を5位に飛ばし、DENSE_RANKは飛ばさず3位を続けます。「売上ベスト3」と言われたとき、同率をどう扱うかで使う関数が変わります。

※ 行番号のほうは ORDER BY SUM(quantity) DESC, product_id順序が一意に決まる列を足しています。これを省くと、数量が同じ3商品にどの番号が振られるかは実行のたびに変わりえます。ROW_NUMBERを使うときは、必ず順序が確定するようにしてください。

グループごとの最新1件 ── 実務で最頻出

「顧客ごとの最新の注文だけ一覧にして」という依頼は、あらゆる現場で発生します。ウィンドウ関数を知らないと非常に苦労しますが、知っていれば定型です。

latest_per_group.sql
SELECT 顧客番号, 顧客名, 注文番号, 注文日
FROM (
  SELECT
    c.customer_id AS 顧客番号,
    c.name        AS 顧客名,
    o.order_id    AS 注文番号,
    o.ordered_at  AS 注文日,
    ROW_NUMBER() OVER (
      PARTITION BY o.customer_id        -- 顧客ごとに区切って
      ORDER BY o.ordered_at DESC        -- 新しい順に番号を振る
    ) AS rn
  FROM orders AS o
  JOIN customers AS c ON o.customer_id = c.customer_id
)
WHERE rn = 1                             -- 各顧客の1番目=最新だけ残す
ORDER BY 顧客番号;
実行結果
顧客番号 | 顧客名      | 注文番号 | 注文日
---------+-------------+----------+-----------
       1 | 佐藤 みなみ |      108 | 2025-05-20
       2 | 鈴木 けんた |      106 | 2025-04-18
       3 | 高橋 あおい |      110 | 2025-06-11
       4 | 田中 りく   |      107 | 2025-05-05
       6 | 渡辺 そうた |      109 | 2025-06-01
なぜ入れ子にする必要があるのか ウィンドウ関数はWHEREの後に計算されるため、WHERE ROW_NUMBER() OVER (…) = 1 とは書けません(STEP 3の実行順序)。いったんサブクエリやCTEで番号を振り、外側で絞る。この「番号を振ってから絞る」型は丸暗記してよいほど頻出します。

累計と前月比

window_cumulative.sql
SELECT
  年月,
  売上,
  SUM(売上) OVER (ORDER BY 年月)          AS 累計売上,
  LAG(売上) OVER (ORDER BY 年月)          AS 前月売上,
  売上 - LAG(売上) OVER (ORDER BY 年月)   AS 前月差
FROM (
  SELECT
    strftime('%Y-%m', o.ordered_at)  AS 年月,
    SUM(oi.quantity * oi.unit_price) AS 売上
  FROM orders AS o
  JOIN order_items AS oi ON o.order_id = oi.order_id
  WHERE o.status = '完了'
  GROUP BY strftime('%Y-%m', o.ordered_at)
)
ORDER BY 年月;
実行結果
年月    | 売上 | 累計売上 | 前月売上 | 前月差
--------+------+----------+----------+--------
2025-03 | 3150 |     3150 |   (NULL) | (NULL)
2025-04 | 1710 |     4860 |     3150 |  -1440
2025-05 | 1800 |     6660 |     1710 |     90
2025-06 | 4320 |    10980 |     1800 |   2520

ORDER BY をOVERの中に書くと、「先頭からその行まで」が集計範囲になり、自動的に累計になります。LAG は1つ前の行の値を持ってくる関数で、最初の行には前がないためNULLです。前年同月と比べたいなら LAG(売上, 12) のように、いくつ前かを指定できます。

練習問題

完了注文について、分類ごとの売上と、その分類が全体に占める割合、および売上順位を1本のSQLで出してください。

解答を見る
answer.sql
SELECT
  p.category                       AS 分類,
  SUM(oi.quantity * oi.unit_price) AS 売上,
  RANK() OVER (ORDER BY SUM(oi.quantity * oi.unit_price) DESC) AS 順位,
  ROUND(
    SUM(oi.quantity * oi.unit_price) * 100.0
    / SUM(SUM(oi.quantity * oi.unit_price)) OVER ()
  , 1)                             AS 構成比
FROM order_items AS oi
JOIN orders   AS o ON oi.order_id   = o.order_id
JOIN products AS p ON oi.product_id = p.product_id
WHERE o.status = '完了'
GROUP BY p.category
ORDER BY 売上 DESC;
実行結果
分類 | 売上 | 順位 | 構成比
-----+------+------+-------
雑貨 | 4440 |    1 |   40.4
食品 | 2280 |    2 |   20.8
文具 | 2190 |    3 |   19.9
飲料 | 2070 |    4 |   18.9

※ GROUP BYで束ねた「後」の結果に対して、ウィンドウ関数がさらに働いています。集約関数の外側にもう一段SUMを重ねるSUM(SUM(…)) OVER ())という形に最初は戸惑いますが、「グループの合計をさらに全部足す」と読めば自然です。

STEP 11 実務

CTE(WITH句)── 長いSQLを人が読める形に組み立てる

目安 1〜2週間
このステップの到達点 ── WITH句で処理を段階に分けて書ける。複雑な集計を、小さな問いの積み重ねとして組み立てられる。再帰CTEで日付の連番を作れる。

入れ子のサブクエリは読めなくなる

STEP 6の導出テーブルは便利ですが、2段3段と深くなると、内側から読まないと意味が分からない「読めないSQL」になります。WITH を使うと、上から順に読める形に書き換えられます。処理内容は同じです。

cte_basic.sql
WITH order_totals AS (
  -- ① 注文ごとの金額を出す
  SELECT
    o.order_id,
    o.customer_id,
    SUM(oi.quantity * oi.unit_price) AS 合計金額
  FROM orders AS o
  JOIN order_items AS oi ON o.order_id = oi.order_id
  WHERE o.status = '完了'
  GROUP BY o.order_id, o.customer_id
)
-- ② ①の結果を、普通の表のように使う
SELECT
  COUNT(*)                  AS 注文件数,
  SUM(合計金額)             AS 総売上,
  ROUND(AVG(合計金額), 1)   AS 平均単価,
  MAX(合計金額)             AS 最高額
FROM order_totals;
実行結果
注文件数 | 総売上 | 平均単価 | 最高額
---------+--------+----------+--------
       8 |  10980 |   1372.5 |   2400

複数のCTEを並べる

CTEはカンマで区切っていくつでも並べられ、後のCTEから前のCTEを参照できます。これによって、複雑な集計を「小さな問いの連なり」として書けます。

cte_multi.sql
WITH order_totals AS (
  -- ① 完了注文ごとの金額
  SELECT o.order_id, o.customer_id,
         SUM(oi.quantity * oi.unit_price) AS 金額
  FROM orders AS o
  JOIN order_items AS oi ON o.order_id = oi.order_id
  WHERE o.status = '完了'
  GROUP BY o.order_id, o.customer_id
),
customer_summary AS (
  -- ② 顧客ごとにまとめる(①を使う)
  SELECT c.customer_id, c.name, c.prefecture,
         COUNT(t.order_id)          AS 注文回数,
         COALESCE(SUM(t.金額), 0)   AS 売上,
         MAX(o.ordered_at)          AS 最終注文日
  FROM customers AS c
  LEFT JOIN order_totals AS t ON c.customer_id = t.customer_id
  LEFT JOIN orders       AS o ON t.order_id    = o.order_id
  GROUP BY c.customer_id, c.name, c.prefecture
)
-- ③ 最後に、ランク分けして表示する(②を使う)
SELECT
  name       AS 顧客名,
  prefecture AS 都道府県,
  注文回数,
  売上,
  最終注文日,
  CASE
    WHEN 売上 >= 2000 THEN '優良'
    WHEN 売上 >= 1000 THEN '一般'
    WHEN 売上 >      0 THEN 'ライト'
    ELSE                   '未購入'
  END        AS 区分
FROM customer_summary
ORDER BY 売上 DESC;
実行結果
顧客名      | 都道府県 | 注文回数 | 売上 | 最終注文日 | 区分
------------+----------+----------+------+------------+-------
田中 りく   | 北海道   |        2 | 2950 | 2025-05-05 | 優良
渡辺 そうた | 大阪府   |        1 | 2400 | 2025-06-01 | 優良
佐藤 みなみ | 東京都   |        2 | 2170 | 2025-03-10 | 優良
高橋 あおい | 東京都   |        1 | 1920 | 2025-06-11 | 一般
鈴木 けんた | 大阪府   |        2 | 1540 | 2025-04-18 | 一般
伊藤 さくら | 福岡県   |        0 |    0 | (NULL)     | 未購入

佐藤みなみの最終注文日が5月20日ではなく3月10日になっている点に注目してください。5月20日の注文108は「発送中」なので、①の段階で除かれているからです。これは不具合ではなく、「完了した注文だけを見る」という定義どおりの結果です。依頼者と定義をすり合わせるとき、この違いを説明できることが実務では重要になります。

複雑なSQLを書く手順

いきなり完成形を書こうとしてはいけません。ベテランほど、小さく作って確かめながら積み上げます。

  1. 日本語で問いを分解する ── 「顧客別の売上ランクを出す」=「①注文の金額を出す→②顧客ごとに合計する→③ランクを付ける」
  2. ①だけを書いて実行し、結果を目で確認する(件数は妥当か、NULLはないか)
  3. ①をCTEにして、②を書き足す。また実行して確認する
  4. ③を足す。各段階で必ず実行する
  5. 最後に、既知の数字と突き合わせる(総売上が別の方法で出した値と一致するか)
検算の習慣が信頼を生みます 集計を頼まれたら、必ず別の切り口で合計を出して一致を確かめてください。商品別の合計と、月別の合計と、都道府県別の合計は、すべて10,980円になるはずです。ここが合わない場合、どこかで行が重複しているか、条件が漏れています。「数字が合わないまま提出しない」ことが、SQLを書く人の最低限の責任です。

再帰CTE ── データがない期間も埋める

「売上が0の月も表に出したい」という要求はよくあります。データベースには売上0の月のデータがそもそも無いので、期間の一覧を自分で作って左結合します。

recursive_cte.sql
WITH RECURSIVE months(年月) AS (
  SELECT '2025-01'                                          -- 開始点
  UNION ALL
  SELECT strftime('%Y-%m', date(年月 || '-01', '+1 month')) -- 1か月ずつ進める
  FROM months
  WHERE 年月 < '2025-06'                                    -- 終了条件(必須)
),
monthly_sales AS (
  SELECT strftime('%Y-%m', o.ordered_at)  AS 年月,
         SUM(oi.quantity * oi.unit_price) AS 売上
  FROM orders AS o
  JOIN order_items AS oi ON o.order_id = oi.order_id
  WHERE o.status = '完了'
  GROUP BY strftime('%Y-%m', o.ordered_at)
)
SELECT
  m.年月,
  COALESCE(s.売上, 0) AS 売上
FROM months AS m
LEFT JOIN monthly_sales AS s ON m.年月 = s.年月
ORDER BY m.年月;
実行結果
年月    | 売上
--------+------
2025-01 |    0
2025-02 |    0
2025-03 | 3150
2025-04 | 1710
2025-05 | 1800
2025-06 | 4320
再帰CTEには必ず終了条件を WHERE 年月 < '2025-06' を書き忘れると、止まらないSQLになります。実行前に、終了条件が必ず満たされるかを指差し確認してください。再帰は他にも、組織図(上司をたどる)やカテゴリの親子関係をたどるのに使います。
練習問題

CTEを使って、「1回あたりの平均購入額が全顧客の平均を上回っている顧客」を出してください(対象は完了注文のみ)。

解答を見る
answer.sql
WITH order_totals AS (
  SELECT o.order_id, o.customer_id,
         SUM(oi.quantity * oi.unit_price) AS 金額
  FROM orders AS o
  JOIN order_items AS oi ON o.order_id = oi.order_id
  WHERE o.status = '完了'
  GROUP BY o.order_id, o.customer_id
),
per_customer AS (
  SELECT c.name AS 顧客名,
         ROUND(AVG(t.金額), 1) AS 平均購入額
  FROM order_totals AS t
  JOIN customers AS c ON t.customer_id = c.customer_id
  GROUP BY t.customer_id, c.name
)
SELECT *
FROM per_customer
WHERE 平均購入額 > (SELECT AVG(金額) FROM order_totals)
ORDER BY 平均購入額 DESC;
実行結果
顧客名      | 平均購入額
------------+-----------
渡辺 そうた |     2400.0
高橋 あおい |     1920.0
田中 りく   |     1475.0

※ 全注文の平均は1,372.5円です。これを上回るのは3人。「顧客ごとの平均」を先に出してから「全体の平均」と比べるという2段階を、CTEで素直に表現できています。

STEP 12 実務

遅いSQLを速くする ── 実行計画とインデックス

目安 2〜3週間
このステップの到達点 ── 実行計画を見て「全表走査かどうか」を判断できる。索引が効かない書き方を知っていて、避けられる。遅いクエリを自分で書き直せる。

なぜ性能が実務の必須科目なのか

練習用の10行の表なら、どんな書き方をしても一瞬で終わります。しかし実務のテーブルは数百万行から数億行あります。同じ結果を返すSQLでも、書き方しだいで0.01秒と10分ほどの差がつきます。しかも遅いSQLは自分が待たされるだけでなく、データベース全体を止めて他の人の仕事も止めます。SQLが書けることと、実務で使えることの差は、多くの場合ここです。

実行計画を見る

データベースは、SQLを受け取ると「どう探すか」の作戦を立てます。それを表示させるのが EXPLAIN です。

explain.sql
-- ① インデックスが無い状態
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE customer_id = 1;
実行結果(例)
QUERY PLAN
`--SCAN orders
explain2.sql
-- ② インデックスを作ってから、もう一度
CREATE INDEX idx_orders_customer ON orders(customer_id);

EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE customer_id = 1;
実行結果(例)
QUERY PLAN
`--SEARCH orders USING INDEX idx_orders_customer (customer_id=?)

SCAN は「全部読んだ」、SEARCH … USING INDEX は「索引で直行した」という意味です。この2語を見分けられるだけで、遅い原因のかなりの部分が特定できます。PostgreSQLでは Seq Scan(全表走査)と Index Scan、MySQLでは type: ALLtype: ref が同じ役割の言葉です。

全表走査がいつも悪いわけではありません 表の大部分を返すクエリなら、索引をたどるより全部読むほうが速いこともあります。データベースはそれも考慮して作戦を決めています。問題なのは、「10行しか返さないのに100万行読んでいる」ような場合です。実行計画は「読んだ行数」と「返した行数」の差に注目して見てください。

索引が効かなくなる書き方

インデックスがあっても、SQLの書き方しだいで使われなくなります。これを避けられるかどうかが、実務での差になります。

効かない書き方直し方理由
WHERE strftime('%Y',d)='2025'WHERE d >= '2025-01-01' AND d < '2026-01-01'列を関数で加工すると索引の順序が使えない
WHERE price * 1.1 > 1000WHERE price > 1000 / 1.1同上。計算は列ではなく値の側で
WHERE name LIKE '%ノート%'前方一致にする/全文検索を使う先頭が不明だと索引をたどれない
WHERE CAST(id AS TEXT) = '1'型を合わせて WHERE id = 1暗黙の型変換でも索引は無効になる
WHERE a = 1 OR b = 2UNION ALL で2本に分けるORは索引を使いにくい(製品による)
SELECT *必要な列だけ書く索引だけで完結できる場合がある(カバリング)

書き方を変えて速くする

tuning.sql
-- △ 全部結合してから絞る(大きな表では無駄が大きい)
SELECT c.name, SUM(oi.quantity * oi.unit_price) AS 売上
FROM customers AS c
JOIN orders      AS o  ON c.customer_id = o.customer_id
JOIN order_items AS oi ON o.order_id    = oi.order_id
WHERE o.ordered_at >= '2025-06-01'
GROUP BY c.customer_id, c.name;

-- ○ 先に絞って小さくしてから結合する
WITH target_orders AS (
  SELECT order_id, customer_id
  FROM orders
  WHERE ordered_at >= '2025-06-01' AND status = '完了'
),
totals AS (
  SELECT t.customer_id, SUM(oi.quantity * oi.unit_price) AS 売上
  FROM target_orders AS t
  JOIN order_items AS oi ON t.order_id = oi.order_id
  GROUP BY t.customer_id
)
SELECT c.name AS 顧客名, t.売上
FROM totals AS t
JOIN customers AS c ON t.customer_id = c.customer_id
ORDER BY t.売上 DESC;
実行結果(下のSQL)
顧客名      | 売上
------------+------
渡辺 そうた | 2400
高橋 あおい | 1920

原則はいつも同じです。「できるだけ早い段階で、できるだけ行を減らす」。結合してから絞るのではなく、絞ってから結合する。この順番を意識するだけで、大きな表では劇的に変わります。

実務でよく出会う遅さの原因

  • N+1問題 ── アプリが「一覧を取る」SQLを1回、そのあと1行ごとに詳細を取るSQLを100回発行する。1本のJOINにまとめるのが解決策で、Webアプリの遅さで最も多い原因です
  • 不要なDISTINCT ── 重複が出るのは結合が間違っているサインです。DISTINCTで隠すと、原因が残ったまま遅くなります
  • ORDER BYのための巨大な並べ替え ── 並べ替える列にインデックスがあると回避できることがあります
  • 件数を確認せずに実行する ── まず SELECT COUNT(*) で規模を測る癖をつけてください
  • 統計情報が古い ── データベースは統計をもとに作戦を立てます。大量投入の後は ANALYZE を実行します
推測せずに、計測する 「たぶんここが遅い」で書き直しても、たいてい外れます。①実行計画を見る ②実行時間を測る ③1か所だけ直す ④また測る。この順番を守ってください。多くのデータベースには「遅いクエリのログ」(スロークエリログ)があり、どのSQLが問題かはそこに書いてあります。
練習問題

次のSQLは、注文日にインデックスがあっても遅いままです。理由を述べ、書き直してください。

slow.sql
SELECT * FROM orders
WHERE strftime('%Y-%m', ordered_at) = '2025-06';
解答を見る

理由 ── 列 ordered_at を関数で加工しているため、インデックスに並んでいる順序が使えず、全行に対して関数を実行して比べることになるからです。

answer.sql
SELECT order_id, customer_id, ordered_at, status
FROM orders
WHERE ordered_at >= '2025-06-01'
  AND ordered_at <  '2025-07-01';   -- 翌月の1日「未満」にすると月末の時刻まで漏れなく入る

BETWEEN '2025-06-01' AND '2025-06-30' と書くと、日時型の場合に「6月30日 12時」のデータが漏れます。期間は「以上・未満」で書くのが実務の鉄則です。SELECT * をやめている点も、わずかですが効きます。

STEP 13 実務

現場の作法 ── 方言・安全・レビュー・その先

目安 2週間〜
このステップの到達点 ── 製品ごとの違いに戸惑わない。本番データを安全に扱える。人に見せられるSQLが書ける。ここまで来れば、実務で通用します。

製品ごとの方言

SQLには標準規格がありますが、実際には製品ごとに差があります。基本のSELECT・JOIN・GROUP BYはほぼ共通で、差が出るのは主に関数と細かい構文です。1つ覚えれば、他は差分を調べるだけで済みます。

製品よく使われる場所特徴
SQLiteスマホアプリ、学習、小規模ファイル1つ。型がゆるい。学習用に最適
PostgreSQLWebサービス全般標準への準拠度が高く、機能が豊富。迷ったらこれ
MySQL / MariaDBWebサービス、CMS利用者が多く情報も多い。細かな挙動に癖がある
SQL Server企業の業務システムWindows環境と相性がよい。TOPなど独自構文
Oracle大企業の基幹システム歴史が長く高機能。独自機能が多い
BigQuery / Snowflakeデータ分析基盤巨大データの集計向け。課金が読んだ量で決まる
やりたいことSQLitePostgreSQLMySQLSQL Server
件数制限LIMIT 10LIMIT 10LIMIT 10TOP 10
文字列連結a || ba || bCONCAT(a,b)a + b
NULL置換IFNULLCOALESCEIFNULLISNULL
現在日時datetime('now')NOW()NOW()GETDATE()
大文字小文字の区別既定で区別する区別する既定で区別しない設定による

COALESCE はすべての製品で使えます。迷ったら標準SQLの書き方を選ぶと、移植のときに困りません。

本番データを扱うときのルール

この5つは、例外なく守ってください
本番環境では、まず読み取り専用の権限で作業する(そもそも壊せない状態にする)
② UPDATE・DELETEは必ず同じWHEREでSELECTしてから、件数を確認して実行する
③ 変更はトランザクションで囲む。確認してからCOMMITする
④ 大量更新の前にバックアップを取る。取ったことを確認してから始める
業務時間中に重いクエリを流さない。全件集計は夜間か、分析用の複製データベースで行う

個人情報の扱いも同じくらい重要です。氏名・住所・電話番号・メールアドレスを、必要もないのに抽出したりダウンロードしたりしない。集計に必要なのは「東京都に何人いるか」であって、その人たちの名前ではないことがほとんどです。抽出したファイルの置き場所と削除まで含めて、社内のルールを必ず確認してください。

人に見せられるSQLを書く

style.sql
-- 目的:月次の売上レポート(経理部・毎月5日提出)
-- 定義:status='完了' のみ対象。金額は明細の 数量×単価 の合計。
-- 作成:2025-06-15 うえき / 変更:2025-07-01 消費税率の記載を削除
WITH monthly AS (
  SELECT
      strftime('%Y-%m', o.ordered_at)  AS 年月
    , SUM(oi.quantity * oi.unit_price) AS 売上
  FROM      orders      AS o
  JOIN      order_items AS oi ON o.order_id = oi.order_id
  WHERE     o.status = '完了'
  GROUP BY  strftime('%Y-%m', o.ordered_at)
)
SELECT
    年月
  , 売上
  , SUM(売上) OVER (ORDER BY 年月) AS 累計
FROM  monthly
ORDER BY 年月;
  • 先頭に目的・定義・作成者を書く ── 半年後の自分と、引き継ぐ人のために
  • キーワードは大文字、列名は小文字 ── 構造が目で追える
  • 1行に1つの要素 ── 差分が読みやすく、レビューしやすい
  • テーブルには必ず別名を付け、列には表名を付ける ── どの表の列か迷わせない
  • 「なぜ」をコメントに残す ── 「何を」はSQLを読めば分かる

そして、業務で使うSQLはGitで管理してください。ファイルとして残し、変更履歴を残し、可能なら他の人にレビューしてもらう。「あのときの集計、どのSQLで出したっけ」が起きなくなるだけで、仕事の質が変わります。

よく使う道具立て

仕組み何をするもの使いどころ
ビュー(VIEW)SELECT文に名前を付けて表のように扱うよく使う集計を共通化する。個人情報を除いた表を配る
集計テーブル集計結果を実際の表に保存しておく毎回集計すると重い場合。夜間バッチで更新
BIツールSQLの結果をグラフ・ダッシュボードにするLooker Studio、Metabase、Tableauなど
ストアドプロシージャデータベース側に処理を保存する既存の業務システムで出会うことがある

この先に学ぶこと

  • ロックとデッドロック ── 複数の人が同時に更新したときに何が起きるか。トランザクション分離レベル
  • インデックス設計 ── 複合インデックスの列順、カバリングインデックス
  • バックアップと復旧 ── 「戻せること」を確認していないバックアップは無いのと同じ
  • データモデリング ── ER図、正規化と、あえて崩す判断
  • 分析基盤 ── BigQueryなどの列指向データベース、dbtによる変換の管理
  • 資格で体系化する ── 基本情報技術者、データベーススペシャリスト、OSS-DB Silver など
自己診断チェック

次の質問に自信を持って答えられれば、実務で通用する水準です。

  • WHEREとHAVINGの違いを、例を挙げて説明できる
  • LEFT JOINで COUNT(*) を使ってはいけない理由を説明できる
  • 結合によって行が増える場面を予測し、COUNT(DISTINCT …)で対処できる
  • NOT IN ではなく NOT EXISTS を勧める理由を説明できる
  • 「顧客ごとの最新の注文」をウィンドウ関数で書ける
  • 実行計画を見て、全表走査かどうかを判断できる
  • UPDATE文を実行する前に、自分が何をするか説明できる
  • 集計結果を、別の切り口で検算する習慣がある
答えられない項目があったら

該当するステップに戻ってください。1〜2はSTEP 4、3はSTEP 5、4はSTEP 6、5はSTEP 10、6はSTEP 12、7〜8はSTEP 8とSTEP 11です。読み返すのではなく、必ず手を動かして書き直してください。

SQL早見表

迷ったらここに戻ってください
役割書く順実行順登場
SELECT取り出す列を決める15STEP 1
FROM / JOIN使う表を決め、つなぐ21STEP 1・5
WHERE行を絞る(集計前)32STEP 2
GROUP BY行を束ねる43STEP 4
HAVING束を絞る(集計後)54STEP 4
ORDER BY並べ替える66STEP 3
LIMIT件数を絞る77STEP 3
WITH途中の結果に名前を付ける01より前STEP 11
やりたいこと書き方注意点
件数を数えるCOUNT(*)NULLも数える。結合後は重複に注意
種類数を数えるCOUNT(DISTINCT 列)結合で増えた行を正しく数えられる
合計・平均SUM(列) / AVG(列)AVGはNULLの行を分母に入れない
NULLを0にするCOALESCE(列, 0)標準SQL。どの製品でも使える
ゼロ除算を防ぐa / NULLIF(b, 0)結果はNULLになる
条件で値を変えるCASE WHEN … THEN … END上から順に判定。ELSE省略時はNULL
条件付きの件数SUM(CASE WHEN 条件 THEN 1 ELSE 0 END)クロス集計の基本形
0件も表示するLEFT JOINCOUNT(右表の列)条件はWHEREでなくONに書く
〜したことがないNOT EXISTS (SELECT 1 … )NOT INはNULLで0件になる
グループごとの最新1件ROW_NUMBER() OVER (PARTITION BY … ORDER BY … DESC) =1いったん番号を振り、外側で絞る
構成比SUM(x) * 100.0 / SUM(SUM(x)) OVER ()100.0と小数で書く
累計SUM(x) OVER (ORDER BY 並び順)OVERの中のORDER BYが範囲を決める
前月比x - LAG(x) OVER (ORDER BY 年月)先頭行はNULLになる
期間で絞るd >= '開始' AND d < '翌期間の先頭'BETWEENは時刻付きだと漏れる
安全に更新するBEGIN; → 確認 → COMMIT;おかしければROLLBACK;

※ この表の内容が「調べずに書ける」状態になれば、日常業務のSQLはほぼ困りません。覚えようとせず、何度も引いてください。引く回数が減っていくことが上達です。

2〜3か月の学習プラン例

1日1時間、週5日で進めた場合の目安
時期やることその週の到達目標
1週目STEP 0〜1環境を作り、サンプルデータを入れ、列を選んで表示できる
2週目STEP 2〜3条件で絞り、並べ替え、上位N件を出せる
3〜4週目STEP 4「〇〇別の合計」を自力で出せる(ここが最大の山場)
5〜6週目STEP 53つの表を結合し、集計と組み合わせられる
7週目STEP 6〜7サブクエリとCASE式でクロス集計が書ける
8週目STEP 8+復習更新系を安全に扱える。自分の職場のデータで1本書いてみる
9〜10週目STEP 9〜10設計の考え方が分かり、ウィンドウ関数が書ける
11〜12週目STEP 11〜13CTEで長いSQLを組み立て、実行計画を読める
続けるためのコツ自分の仕事のデータで試すのがいちばん続きます。売上でも、勤怠でも、部活の記録でもかまいません ② 分からない場面に出会ったら、まず10行くらいの小さな表を自分で作って試す。大きなデータで悩むより百倍速く分かります ③ エラーメッセージは英語でもそのまま検索する。だいたい同じところでつまずいた人がいます

用語集

調べものの途中で出てきたら、ここに戻ってください
テーブル table
行と列からなるデータの入れ物。表計算ソフトの1シートに近いもの。
レコード / 行 record / row
テーブルの1件分のデータ。「顧客ひとり」「注文1回」など。
カラム / 列 column
テーブルの項目。名前と型を持つ。
主キー primary key
行を1つに特定するための列。重複もNULLも許されない。
外部キー foreign key
他のテーブルの主キーを指す列。存在しない値が入るのを防ぐ。
NULL null
値が入っていない状態。0でも空文字でもなく、= NULL では比較できない。
結合 join
複数のテーブルを共通の列でつなぐこと。SQLの中心的な操作。
集約関数 aggregate function
複数行を1つの値にする関数。COUNT・SUM・AVGなど。
サブクエリ subquery
SQLの中に書いた別のSQL。副問い合わせともいう。
CTE common table expression
WITH で途中の結果に名前を付けたもの。長いSQLを読みやすくする。
ウィンドウ関数 window function
行を残したまま集計する関数。順位・累計・前月比に使う。
インデックス index
検索を速くするための索引。付けすぎると更新が遅くなる。
実行計画 execution plan
データベースが立てた「どう探すか」の作戦。EXPLAIN で見る。
全表走査 full scan
索引を使わず全行を読むこと。大きな表では致命的に遅い。
トランザクション transaction
複数の変更をひとかたまりとして扱う仕組み。COMMIT / ROLLBACK
正規化 normalization
同じ情報を1か所にだけ持つよう、テーブルを分ける設計手法。
DDL / DML
DDLは表を作る系(CREATE・ALTER・DROP)、DMLはデータを操作する系(SELECT・INSERT・UPDATE・DELETE)。
ビュー view
SELECT文に名前を付けて、表のように使えるようにしたもの。

よくある質問

学び始めるときに、多くの人が迷うこと
プログラミング未経験でもSQLから始めて大丈夫ですか

むしろSQLから始めるのは良い選択です。変数もループも関数も出てこず、「ほしい結果」を書くだけだからです。1時間でSELECTが書けるようになり、その日から役に立ちます。事務職や営業職の方が最初に学ぶ言語としても、費用対効果が非常に高いといえます。

ExcelができればSQLは要りませんか

数百行までならExcelで十分です。しかし数万行を超えると重くなり、100万行を超えると開くことすらできません。またExcelは、同じ集計を毎月やり直すたびに手作業が発生し、そのたびにミスの可能性が生まれます。SQLなら同じ1本を実行するだけです。「データが大きい」「同じ集計をくり返す」「複数の表を突き合わせる」のどれかに当てはまるなら、SQLの出番です。

どのデータベースで学べばよいですか

職場で使う製品が決まっているなら、それに合わせるのがいちばんです。決まっていないなら、学習はSQLite、その先はPostgreSQLをおすすめします。SQLiteは準備が数分で終わり、PostgreSQLは標準への準拠度が高く、そこで覚えた書き方が他の製品でもほぼ通用するからです。基本の文法は共通なので、最初にどれを選んでもやり直しにはなりません。

どこでつまずく人が多いですか

圧倒的に多いのはGROUP BY(STEP 4)と JOIN(STEP 5)です。ここは文法ではなく「データがどう束ねられ、どう増えるか」というイメージの問題なので、10行くらいの小さな表を紙に書いて、手で結果を作ってみるのがいちばん早い解決策です。SQLを打つ前に、答えが何行になるはずかを予想する癖をつけてください。予想と違ったときが、いちばん学べる瞬間です。

生成AIに書いてもらえば、学ばなくてよいのでは?

SQLの下書きをAIに書いてもらうのは、実務でも普通のことになりました。しかし返ってきたSQLが正しいかどうかを判断できるのは、SQLが読める人だけです。とくに集計は、間違っていても「それらしい数字」が返ってくるため、誤りに気づけません。誤った数字で判断された経営会議は、誰も幸せにしません。読んで検算できる力は、これから逆に価値が上がります。

本番のデータベースを触るのが怖いです

その感覚は正しく、大切にしてください。実務では①読み取り専用の権限をもらう ②本番の複製(分析用データベース)で作業する ③更新は必ずトランザクションで囲むの3つで守ります。とくに①は、権限そのものが無ければ事故が起こりえません。「怖いから触らない」ではなく、「壊せない状態を作ってから触る」が正解です。

資格は取るべきですか

実務では、資格より「実際に集計を出せること」が評価されます。ただし知識の抜けを埋める目的には有効です。日本なら基本情報技術者(データベース分野)、その先にデータベーススペシャリスト、OSS-DB Silverなどがあります。先に手を動かし、ひととおり書けるようになってから体系化するのが効率的な順序です。

SQLの次には何を学べばよいですか

目的によって変わります。データ分析に進むならPythonとpandas、そしてBIツール。Web開発に進むならPythonやJavaScriptと、データベース設計。データベースを深めるなら、ロック・トランザクション分離レベル・インデックス設計です。当サイトにはゼロから実務まで学ぶPythonもありますので、SQLと合わせるとデータを扱う仕事の幅が大きく広がります。

👪 保護者・先生のみなさまへ

このページは、データベースをこれから学ぶ中学生以上の方と、お子さまの学習に付き添う保護者の方に向けて構成しています。前半(STEP 0〜4)は、表計算ソフトを使ったことがあれば中学生でも十分に取り組める内容です。後半は職業として使う場面を想定しているため、必要になったときに戻ってくる資料として使ってください。

SQLは、プログラミング学習の中でも成果が目に見えるのが早い分野です。「文法を覚えたけれど何も作れない」という、プログラミング学習でよくある挫折が起きにくく、書いたその場で答えが表として返ってきます。お子さまが数字やデータに興味を持っているなら、最初の一歩として非常に相性がよい題材です。

学習で身につくのは、SQLの文法だけではありません。「何を知りたいのかを、あいまいさなく言葉にする力」です。「売れている商品を出して」という依頼は、SQLにするためには「どの期間の」「キャンセルを含むのか」「金額か個数か」まで決めなければ書けません。問いを具体化する訓練は、どんな仕事にもそのまま役立ちます。

お子さまがつまずいたときは、答えのSQLを教えるより「その結果は何行になるはずだと思う?」と聞いてみてください。予想と実際の差を確かめる作業そのものが、このページで最も伝えたい学びです。

なお、STEP 8以降にはデータを書き換える操作が含まれます。練習は必ずサンプルの shop.db で行い、学校や職場の実データでは試さないようお伝えください。技術的にできることと、してよいことは別だという感覚は、大人から折に触れて伝えていただければと思います。