SQL バッチ処理 — SELECT・JOIN・集計の基礎

基礎SQL基礎文法SELECT / WHERE / JOIN集計・更新CTE・サブクエリAPI・バッチPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

SELECT + WHERE + ORDER BY — APIレスポンスの基本データ取得パターン

SELECTWHEREORDER BYAPI取得
前提知識

SELECT はテーブルから行・列を取り出す最基本の命令です。WHERE で条件絞り込み、ORDER BY で並び順を制御します。

SELECT   col1, col2                 -- 取得したい列を列挙(* で全列)
FROM     table_name               -- 対象テーブル
WHERE    col1 = 'value'           -- 絞り込み条件(省略可)
ORDER BY col2 DESC;               -- 並び順(ASC=昇順/DESC=降順、省略時はASC)
WHERE の比較演算子:=(等しい)、!= または <>(等しくない)、> < >= <=(大小比較)、LIKE '%word%'(部分一致)、IN (a, b, c)(複数値いずれか)、IS NULL(NULL判定) — = NULL は NG

APIでユーザー一覧を返すエンドポイントでは、WHERE で active なユーザーだけ絞り、ORDER BY で最新順にするのが定石です。

問題

users テーブルから、status が 'active' かつ plan が 'premium' のユーザーを、created_at の新しい順で取得してください。取得する列は user_id, name, email, created_at のみとします。

使用テーブル
▸ users
user_idnameemailstatusplancreated_at
1田中 太郎tanaka@ex.comactivepremium2024-03-10
2佐藤 花子sato@ex.cominactivepremium2024-02-01
3鈴木 一郎suzuki@ex.comactivefree2024-04-05
4山田 次郎yamada@ex.comactivepremium2024-01-20
5伊藤 三郎ito@ex.comactivepremium2024-05-15
期待出力
user_idnameemailcreated_at
5伊藤 三郎ito@ex.com2024-05-15
1田中 太郎tanaka@ex.com2024-03-10
4山田 次郎yamada@ex.com2024-01-20
模範解答コード
SELECT
  user_id,
  name,
  email,
  created_at
FROM   users
WHERE  status = 'active'   -- active かつ premium に絞り込み
  AND  plan   = 'premium'
ORDER BY created_at DESC;  -- 新しい順

/*
  実行順序(SQLの論理的な評価順):
  1. FROM users               → 行を読み込む
  2. WHERE status, plan       → 行を絞り込む
  3. SELECT                   → 列を評価
  4. ORDER BY created_at DESC → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT user_id, name, email, created_at FROM users WHERE status = 'active' AND plan = 'premium' ORDER BY created_at DESC;
LEGEND
データ取得・読込対象
① FROM
FROM usersusers テーブル全5行を読み込みます。この段階ではすべての行が対象です。
1 / 3
user_idnamestatusplan
1田中 太郎activepremium
2佐藤 花子inactivepremium
3鈴木 一郎activefree
4山田 次郎activepremium
5伊藤 三郎activepremium
全 5行 読込
学習ポイント
超基礎:SELECT句はデータの出力列を決め、FROM句は対象のテーブルを指定し、WHERE句で行の絞り込みを行います。書く順番と異なり、実行順序は必ず FROM → WHERE → SELECT になることを意識しましょう。
APIエンドポイントの定番パターン:GET /users?status=active&plan=premium のようなクエリパラメータを WHERE 条件に変換するのが最頻出パターン。アプリ層でパラメータを受け取りバインド変数(プリペアドステートメント)で安全にSQL化する。
AND / OR の優先順位:AND は OR より優先度が高い。WHERE a=1 OR b=2 AND c=3WHERE a=1 OR (b=2 AND c=3) と解釈される。意図を明確にするため括弧を必ず書く習慣が重要。
SELECT * は本番NGの理由:列追加・削除でAPIレスポンス構造が変わりクライアントが壊れる可能性がある。必要な列を明示的に列挙すること。
アンチパターン
NULL の比較に = を使う:WHERE deleted_at = NULL は常にFALSEになり1行も取れない。NULL との比較は必ず IS NULL / IS NOT NULL を使うこと。NULLは「値が不明」なため = では判定できない。
ORDER BY なしで「順序保証」を期待する:ORDER BY を省略するとRDBMSは任意の順序で返す。APIが常に同じ順序を返すことを保証するには必ず ORDER BY を明示すること。
実務コラム:論理削除とWHERE条件の抜け漏れ
実際のWebサービスでは、データが物理的に削除されることは少なく、deleted_at などの論理削除フラグが利用されます。そのため、WHERE status = 'active' だけでなく AND deleted_at IS NULL を付け忘れると、退会済みユーザーをAPIで誤って返してしまう事故に直結します。ORMのデフォルトスコープに頼らず生SQLを書く場合は、常に「論理削除の除外」を意識する癖をつけましょう。
QUESTION 2

INNER JOIN — 関連テーブルを結合してAPIレスポンスを1クエリで組み立てる

INNER JOINONテーブル結合API組み立て
前提知識

JOIN は複数テーブルを共通の列(外部キー)でつなぎ合わせる操作です。INNER JOINは両方のテーブルに一致するデータが存在する行だけを返します(一致しない行は除外)。

SELECT   a.col1, b.col2
FROM     table_a a                  -- a はテーブルの別名(エイリアス)
INNER JOIN table_b b               -- b も別名。INNER は省略可(JOIN のみでもINNER JOIN)
  ON   a.id = b.a_id;             -- 結合条件(外部キー = 主キー)

APIで「注文一覧をユーザー名付きで返す」ような処理は JOIN が必須です。アプリ側でN+1クエリを避けるために1クエリで結合するのが基本です。

LEFT JOIN との違い:INNER JOIN は両テーブルに存在する行だけ。LEFT JOIN は左テーブルの全行を残し、右テーブルに対応行がなければ NULL を入れる。「注文がないユーザーも含めたい」→ LEFT JOIN、「注文のあるユーザーだけ」→ INNER JOIN。
問題

orders テーブルと users テーブルを結合し、status が 'completed' の注文について order_id, user_name, amount, ordered_at を取得してください。ordered_at の新しい順に並べてください。

使用テーブル
▸ orders
order_iduser_idamountstatusordered_at
10115000completed2024-05-10
10223200pending2024-05-12
10318800completed2024-05-15
10431500completed2024-05-08
10546200cancelled2024-05-11
▸ users
user_idname
1田中 太郎
2佐藤 花子
3鈴木 一郎
4山田 次郎
期待出力
order_iduser_nameamountordered_at
103田中 太郎88002024-05-15
101田中 太郎50002024-05-10
104鈴木 一郎15002024-05-08
模範解答コード
SELECT
  o.order_id,
  u.name   AS user_name,
  o.amount,
  o.ordered_at
FROM       orders o
INNER JOIN users  u            -- 両方に存在する行だけ結合
  ON  o.user_id = u.user_id
WHERE  o.status = 'completed'  -- 完了した注文のみ
ORDER BY o.ordered_at DESC;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM orders o              → 行を読み込む
  2. INNER JOIN users u         → 結合(一致行のみ)
  3. WHERE o.status             → 行を絞り込む
  4. SELECT                     → 列を評価
  5. ORDER BY o.ordered_at DESC → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT o.order_id, u.name AS user_name, o.amount, o.ordered_at FROM orders o INNER JOIN users u ON o.user_id = u.user_id WHERE o.status = 'completed' ORDER BY o.ordered_at DESC;
LEGEND
データ取得・読込対象
① FROM / INNER JOIN
FROM orders o INNER JOIN users uorders テーブル(5行)と users テーブル(4行)を準備します。INNER JOIN では両テーブルに一致する行だけが結合されます。
1 / 4
▸ orders (o) — 主テーブル
order_iduser_idamountstatusordered_at
10115000completed2024-05-10
10223200pending2024-05-12
10318800completed2024-05-15
10431500completed2024-05-08
10546200cancelled2024-05-11
▸ users (u) — 結合テーブル
user_idname
1田中 太郎
2佐藤 花子
3鈴木 一郎
4山田 次郎
左: 5行 / 右: 4行
学習ポイント
超基礎:JOIN(結合)は、別々のテーブルを特定の共通キー(例: user_id)を使って横に繋ぎ合わせる操作です。テーブル同士の関連性を結びつける、リレーショナルデータベース(RDB)の根幹となる機能です。
N+1クエリ問題の回避:アプリ側で「注文ループの中でユーザー名を1件ずつ取得」するN+1はJOINで解決できる。1クエリで全データを取得することでDBへのラウンドトリップを最小化する。
エイリアスの必要性:複数テーブルを結合すると同名列(例: user_id)が衝突する。テーブル名.列名 または エイリアス.列名 で必ず明示すること。エイリアスはクエリを短く読みやすくする。
INNER / LEFT の選択基準:「全ユーザーとその注文数」→ LEFT JOIN(注文0件のユーザーも含める)。「注文があるユーザーだけ」→ INNER JOIN。データ要件から適切に選ぶことがAPIレスポンス設計の基本。
アンチパターン
ON 条件の書き忘れ(クロス結合):FROM orders, usersJOIN users(ON なし)は全行×全行のデカルト積(クロス結合)になる。ordersが100行・usersが1000行なら100,000行が生成されDBが応答不能になりうる。ON は必ず書くこと。
WHERE で結合条件を書く古い記法:FROM orders o, users u WHERE o.user_id = u.user_id という旧式の結合記法は、JOIN条件と絞り込み条件が混在して読みにくい。明示的な INNER JOIN ... ON を使うこと。
実務コラム:N+1クエリ問題とAPIのレスポンスタイム
「注文一覧を取ってから、ループの中で1件ずつユーザー名を取得する」処理を書いてしまうと、注文が100件ある場合にDBへ101回のクエリ(N+1クエリ)が飛び、APIのレスポンスが極端に遅延します。実務ではJOINを使って1回のクエリで必要な関連データを全て引き上げるのが絶対の基本です。ただし、結合するテーブルが多すぎると今度はメモリを圧迫するため、必要最小限の結合にとどめる設計バランスが求められます。
QUESTION 3

GROUP BY + HAVING — 集計関数でKPIサマリAPIを1クエリで作る

GROUP BYHAVINGCOUNT/SUMKPI集計
前提知識

GROUP BY は指定した列の値ごとに行をグループ化し、集計関数(COUNT/SUM/AVG/MAX/MIN)でグループ内を1行に集約します。HAVING は集計後の結果をさらに絞り込みます(WHERE の集計後バージョン)。

SELECT   col1,
         COUNT(*)    AS cnt,    -- COUNT(*): グループ内の全行数
         SUM(col2)   AS total,  -- SUM: 合計
         AVG(col2)   AS avg     -- AVG: 平均
FROM     table_name
GROUP BY col1                    -- col1 の値ごとにグループ化
HAVING   COUNT(*) > 1;          -- HAVING: 集計後の絞り込み(WHERE は集計前)
WHERE と HAVING の違い:WHERE は GROUP BY の前(個々の行を絞り込む)、HAVING は GROUP BY 後(集計結果を絞り込む)。HAVING COUNT(*) > 5 のように集計関数を条件にする場合は必ず HAVING を使う。
問題

orders テーブルから、status が 'completed' の注文について、ユーザーIDごと注文件数(order_count)合計金額(total_amount) を集計してください。ただし 注文件数が2件以上 のユーザーだけを、合計金額の大きい順で返してください。

使用テーブル
▸ orders
order_iduser_idamountstatus
1U013000completed
2U015000completed
3U028000completed
4U032000cancelled
5U011500completed
6U024500pending
7U036000completed
8U032500completed
期待出力
user_idorder_counttotal_amount
U0139500
U0328500
模範解答コード
SELECT
  user_id,
  COUNT(*)    AS order_count,
  SUM(amount) AS total_amount
FROM   orders
WHERE  status = 'completed'    -- 集計「前」に行を絞り込み
GROUP BY user_id
HAVING   COUNT(*) >= 2         -- 集計「後」にグループを絞り込み
ORDER BY total_amount DESC;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM orders                → 行を読み込む
  2. WHERE status               → 行を絞り込む
  3. GROUP BY user_id           → グループ化
  4. COUNT(*) / SUM(amount)     → 集計関数を評価
  5. HAVING COUNT(*) >= 2       → グループを絞り込む
  6. SELECT                     → 列を評価
  7. ORDER BY total_amount DESC → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders WHERE status = 'completed' GROUP BY user_id HAVING COUNT(*) >= 2 ORDER BY total_amount DESC;
LEGEND
データ取得・読込対象
① FROM
FROM ordersorders テーブル全8行を読み込みます。
1 / 6
order_iduser_idamountstatus
1U013000completed
2U015000completed
3U028000completed
4U032000cancelled
5U011500completed
6U024500pending
7U036000completed
8U032500completed
全 8行 読込
学習ポイント
超基礎:GROUP BY は特定の列の値が同じ行を「1つのグループ」にまとめます。まとめた後は、COUNT(件数)やSUM(合計)などの集計関数を使って、グループ全体の情報を1行で表現します。
KPI集計APIの基本形:GET /reports/users/summary のような集計系エンドポイントはこのパターンが土台。WHERE で期間・ステータスを絞り、GROUP BY で集計軸を決め、HAVING で足切り条件を付ける。
SELECT できる列の制約:GROUP BY を使うと SELECT に書ける列は「GROUP BY に含まれる列」か「集計関数の結果」だけ。SELECT order_id, user_id, COUNT(*) と書いてもorder_idはグループ内で複数値があるためエラーになる。
HAVING vs WHERE の使い分けまとめ:「active なユーザーだけ集計したい」→ WHERE status='active' で事前に絞る(パフォーマンスが良い)。「集計後に件数が5以上だけ欲しい」→ HAVING COUNT(*) >= 5。集計関数を条件にするなら HAVING 一択。
アンチパターン
HAVING に集計関数なし条件を書く:HAVING status = 'completed' と書くのは動くが非推奨。集計前の行レベル条件は必ず WHERE に書く。WHERE に書くと集計対象行が減りパフォーマンスが向上する。
GROUP BY なしで集計関数と非集計列を混在:SELECT user_id, COUNT(*) FROM orders のようにGROUP BY なしで集計関数と普通の列を混在させるとエラー。PostgreSQLは 「user_idをどう集約すればいいか不明」 と判断するため必ずGROUP BYに含めること。
実務コラム:巨大テーブルの集計とパフォーマンス
数千万行規模のアクセスログなどを集計する際、いきなり GROUP BY をかけるとDBのメモリを食いつぶし、スロークエリやタイムアウトを引き起こします。実務では「まずは WHERE 句で対象期間(直近1週間など)を絞り込んでから GROUP BY する」のが鉄則です。絞り込みにより集約対象のデータ量が激減するため、安全かつ高速にKPIサマリAPIを返すことができます。
QUESTION 4

LIMIT + OFFSET — ページネーションAPIの基本と番号ベース実装

LIMITOFFSETページネーションAPI設計
前提知識

LIMIT は返す行数の上限を指定し、OFFSET は先頭から何行スキップするかを指定します。APIのページネーション(/items?page=2&per_page=10)の基本実装です。

SELECT   col1, col2
FROM     table_name
ORDER BY id                         -- ORDER BY は必須(順序がないとページの中身が不定)
LIMIT  10                            -- 最大10行返す(1ページあたりの件数)
OFFSET 20;                           -- 先頭20行をスキップ(3ページ目 = (page-1)*per_page)

OFFSET が大きくなるとDBは先頭からOFFSET行分スキャンするため大量データでは低速化します。本番の大規模APIでは「最後に取得したIDより大きいIDを取る」カーソルベースが推奨されます。

OFFSET の計算式:OFFSET = (page - 1) * per_page。1ページ目はOFFSET 0(スキップなし)、2ページ目はOFFSET 10、3ページ目はOFFSET 20(per_page=10の場合)。
問題

products テーブルから、1ページあたり3件、2ページ目のデータを取得してください。product_id, name, price を price の安い順で返してください。

使用テーブル
▸ products(全7件)
product_idnameprice
1ペン100
2ノート200
3消しゴム80
4定規150
5ハサミ300
6のり120
7クリップ50
期待出力
product_idnameprice
6のり120
4定規150
2ノート200
模範解答コード
SELECT
  product_id,
  name,
  price
FROM   products
ORDER BY price ASC  -- 安い順(ASC は省略可)
LIMIT  3            -- 取得する最大行数
OFFSET 3;           -- スキップする行数(4件目から)

/*
  実行順序(SQLの論理的な評価順):
  1. FROM products      → 行を読み込む
  2. ORDER BY price ASC → 並び替えて出力
  3. OFFSET 3           → 行をスキップ
  4. LIMIT 3            → 件数を制限
*/
解説(テーブル変化・ポイント)
SELECT product_id, name, price FROM products ORDER BY price ASC LIMIT 3 OFFSET 3;
LEGEND
データ取得・読込対象
① FROM
FROM productsproducts テーブル全7行を読み込みます。この段階では挿入順のままです。
1 / 3
product_idnameprice
1ペン100
2ノート200
3消しゴム80
4定規150
5ハサミ300
6のり120
7クリップ50
全 7行 読込
学習ポイント
超基礎:LIMITは「最大何件取得するか」を指定し、OFFSETは「取得を始める前に先頭から何件読み飛ばすか」を指定します。この2つを組み合わせることで、大量のデータを少しずつ切り出すことができます。
APIでの実装パターン:GET /products?page=2&per_page=3 を受け取ったら、サーバー側で OFFSET = (page-1) * per_page = 3 を計算してSQLに渡す。総件数も SELECT COUNT(*) FROM products で別取得しレスポンスに含めるのが一般的。
LIMIT のみでの先頭N件取得:LIMIT 1 で最新1件、LIMIT 5 で上位5件を取得できる。ランキングAPIや「最新投稿を3件表示」といった用途に頻出。
カーソルベースページネーション(大規模向け):OFFSET が大きいと全件スキャンが発生して低速になる。代替として WHERE id > :last_id ORDER BY id LIMIT 10 のように最後のIDを条件にすることで、常に高速なインデックスアクセスが可能になる。
アンチパターン
ORDER BY なしの LIMIT:LIMIT 10 だけでは毎回異なる行が返る可能性がある。RDBMSは内部的に任意の順序で行を管理するため、ORDER BY なしの順序は保証されない。
データ挿入中のOFFSET競合:ページ1を取得後に新規データがINSERTされると、ページ2でOFFSETがズレて同じ行が重複したり、スキップされる「ページスキップ問題」が発生する。リアルタイムデータにはカーソルベースが安全。
実務コラム:OFFSETの罠とカーソルベースの台頭
OFFSET 100000 のようにスキップ数が増えると、DBは先頭から10万行を読み込んで捨てるという無駄な処理を行うため、ページが深くなるほどAPIが遅くなります。これを防ぐため、モダンなAPI設計(TwitterやSlackなど)では、最後のIDを基準にするカーソルベースのページネーション(WHERE id > :last_id LIMIT 10)が主流になっています。データ量が増える見込みのあるシステムでは初期段階からカーソルベースを検討しましょう。
QUESTION 5

INSERT + RETURNING — 新規作成APIでDB採番IDをそのまま返す

INSERTRETURNINGPOST APIID採番
前提知識

INSERT はテーブルに新しい行を追加します。RETURNING(PostgreSQL拡張)を付けると INSERT した行の列値を SELECT のように返せます。新規作成API(POST /items)でDBが自動採番したIDをアプリに返す際の必須パターンです。

INSERT INTO table_name (col1, col2)  -- 挿入先テーブルと列名
VALUES ('val1', 'val2')             -- 挿入する値(列名と同じ順番・同じ数)
RETURNING id, col1;                 -- 挿入した行の列値を返す(省略するとINSERTのみ)

シリアル列(SERIAL / BIGSERIAL)や GENERATED ALWAYS AS IDENTITY はINSERT時に自動で採番されます。アプリ側でIDを事前に決める必要はありません。

RETURNING の利点:RETURNING なしでは「INSERT後にSELECTで採番IDを取得」という2クエリが必要。RETURNING を使えば1クエリで完結し、競合状態(別のINSERTとIDが混線する)も防げる。
問題

tasks テーブルに新しいタスクを作成してください。title='バッチ処理の実装'、status='pending'、created_at=現在時刻 を挿入し、DBが自動採番した id と created_at を返してください。

使用テーブル
▸ tasks(テーブル定義)
列名備考
idBIGSERIAL自動採番・主キー(指定不要)
titleTEXTタスク名
statusTEXT'pending'/'running'/'done'
created_atTIMESTAMPTZ作成日時
▸ tasks(挿入前のデータ)
idtitlestatuscreated_at
1ユーザー同期done2024-05-01 09:00
2メール送信running2024-05-10 11:00
期待出力
idcreated_at
32024-06-01 14:30:00+09
模範解答コード
INSERT INTO tasks (
  title,
  status,
  created_at
)
VALUES (
  'バッチ処理の実装',
  'pending',
  NOW()                    -- 現在日時
)
RETURNING id, created_at;  -- 挿入された行の値を返す(PostgreSQL)

/*
  実行順序(SQLの論理的な評価順):
  1. INSERT INTO tasks        → 行の領域を準備
  2. VALUES                   → 値を列に埋め込む
  3. BIGSERIAL を自動採番      → id を採番
  4. RETURNING id, created_at → 挿入行を返す
*/
解説(テーブル変化・ポイント)
INSERT INTO tasks ( title, status, created_at ) VALUES ( 'バッチ処理の実装', 'pending', NOW() ) RETURNING id, created_at;
LEGEND
データ取得・読込対象
① INSERT INTO
INSERT INTO tasks — 挿入前のテーブルtasks テーブルには既存の2行があります。INSERT 実行前の状態です。BIGSERIAL 列 id は次の挿入で id=3 を自動採番します。
1 / 3
idtitlestatuscreated_at
1ユーザー同期done2024-05-01 09:00
2メール送信running2024-05-10 11:00
現在 2行
学習ポイント
超基礎:INSERT INTO はテーブルに新しいデータを「行単位」で追加します。列の順番とVALUESの中のデータの順番は必ず一致させる必要があります。
POST APIの典型フロー:クライアントがPOSTリクエスト → サーバーがINSERT + RETURNING → 採番されたIDをレスポンスに含める → クライアントはそのIDでGETして詳細を取得。RETURNING がなければINSERT後に別のSELECTが必要になる。
BIGSERIAL とは:内部的には BIGINT + シーケンス(SEQUENCE) の糖衣構文。INSERTのたびにシーケンスが自動インクリメントされる。SERIAL は INT(約21億上限)、BIGSERIALはBIGINT(約922京上限)なので大規模サービスではBIGSERIALを推奨。
RETURNING * で全列取得:RETURNING * で挿入行の全列を返せる。DBのデフォルト値(DEFAULT)が入った列も返ってくるため、INSERT後のレスポンスとして使いやすい。
アンチパターン
INSERT後にSELECTで採番IDを取得する2クエリパターン:マルチスレッド環境では「INSERTしてからSELECT MAX(id)」で別スレッドのIDを取得してしまう競合が起きる。RETURNING を使えば1クエリで安全・確実にIDを取得できる。
VALUES の列数と値の数を間違える:INSERT INTO tasks (title, status) VALUES ('test') は列2つに対して値1つなのでエラー。列名リストと VALUES の要素数は必ず一致させること。
実務コラム:バルクインサートとRETURNINGの組み合わせ
バッチ処理で数千件のデータを登録する際、1件ずつINSERTすると通信オーバーヘッドで数分かかってしまいます。実務では INSERT INTO tasks (...) VALUES (...), (...), (...) のように複数行を1クエリで挿入するバルクインサート(一括挿入)を多用します。この際 RETURNING id を付ければ、挿入された数千件分のIDを一瞬でリストとして取得でき、後続の処理(別テーブルへの紐付けなど)が非常にスムーズになります。