SQL バッチ処理 — ウィンドウ関数・UNION・UPSERTの基礎

基礎SQL基礎文法DISTINCT / NULL / LIKEIN・EXISTS / 日付関数WINDOW / UNIONトランザクション・UPSERTPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

WINDOW関数 — ROW_NUMBER / RANK / SUM OVER でランキングと累計を1クエリで返す

ROW_NUMBERRANKSUM OVERWINDOW関数
前提知識

WINDOW関数(ウィンドウ関数)は、行を集約せずに「グループ内での順位」や「累積合計」を各行に付与できる強力な機能です。GROUP BY と異なり、元の行数を保ったまま集計値を追加できます。

ROW_NUMBER() OVER (               -- グループ内で連番(重複なし)を振る
  PARTITION BY grp_col           -- PARTITION BY: グループの区切り(GROUP BY相当)
  ORDER BY sort_col DESC         -- ORDER BY: グループ内での順序を決める
) AS rn

RANK()       OVER (...)           -- 同値に同じ順位(次の順位はスキップ: 1,1,3)
DENSE_RANK() OVER (...)           -- 同値に同じ順位(スキップなし: 1,1,2)
SUM(col)     OVER (               -- 累積合計(パーティション内で行ごとに積み上げ)
  PARTITION BY grp_col
  ORDER BY sort_col
)
GROUP BY との大きな違い:GROUP BY は行数を減らして集約する。WINDOW関数は行数を保ったまま各行に集計値を付ける。「全行を返しつつ、各行にグループ内の順位を付けたい」場合にWINDOW関数を使う。
問題

sales テーブルから、各商品カテゴリ内での売上ランキング(同売上は同順位)と、カテゴリ内での累積売上を求めてください。カテゴリ・ランキング・累積の順で並べてください。

使用テーブル
▸ sales
sale_idcategoryproductamount
1飲料コーヒー5000
2飲料お茶3000
3飲料ジュース3000
4食品パン8000
5食品ケーキ6000
6食品クッキー4000
期待出力
categoryproductamountrank_in_catrunning_total
飲料コーヒー500015000
飲料お茶300028000
飲料ジュース3000211000
食品パン800018000
食品ケーキ6000214000
食品クッキー4000318000
模範解答コード
SELECT
  category,
  product,
  amount,
  RANK() OVER (            -- カテゴリ内の順位を付ける
    PARTITION BY category  -- カテゴリごとに区切る
    ORDER BY amount DESC   -- 金額の大きい順
  ) AS rank_in_cat,        -- 同値は同順位・次をスキップ(1,1,3)
  SUM(amount) OVER (               -- カテゴリ内の累積合計
    PARTITION BY category
    ORDER BY amount DESC, sale_id
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW  -- 先頭〜現在行の範囲
  ) AS running_total                 -- 累積合計

FROM     sales
ORDER BY
  CASE category WHEN '飲料' THEN 1 WHEN '食品' THEN 2 END,
  rank_in_cat,
  sale_id;  -- 教材で指定したカテゴリ順。同順位はsale_id順

/*
  実行順序(WINDOW関数を使ったクエリ):
  1. FROM sales                     → 行を読み込む
  2. PARTITION BY category          → ウィンドウを分割
  3. RANK() OVER (...)              → ウィンドウ関数を評価(行数は保持)
  4. SUM(amount) OVER (...)         → ウィンドウ関数を評価(行数は保持)
  5. SELECT                         → 列を評価
  6. ORDER BY CASE category WHEN '飲料' THEN 1 WHEN '食品' THEN 2 END, rank_in_cat, sale_id → 指定カテゴリ順、ランク順、同順位はsale_id順で出力
*/
解説(テーブル変化・ポイント)
SELECT category, product, amount, RANK() OVER ( PARTITION BY category ORDER BY amount DESC ) AS rank_in_cat, SUM(amount) OVER ( PARTITION BY category ORDER BY amount DESC, sale_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total FROM sales ORDER BY CASE category WHEN '飲料' THEN 1 WHEN '食品' THEN 2 END, rank_in_cat, sale_id;
LEGEND
データ取得・読込対象
① FROM
FROM salessales テーブル全体(6行)を読み込みます。次のステップで PARTITION BY category によりカテゴリごとにウィンドウを分割します。
1 / 4
sale_idcategoryproductamount
1飲料コーヒー5000
2飲料お茶3000
3飲料ジュース3000
4食品パン8000
5食品ケーキ6000
6食品クッキー4000
全 6行 読込
学習ポイント
超基礎:WINDOW関数とGROUP BYの根本的な違い:GROUP BY はカテゴリを1行に集約(行数が減る)。WINDOW関数は元の各行に順位や累積値を「追加列として付ける」(行数が変わらない)。「元データを残しつつ順位や累積も知りたい」ときにWINDOW関数を使います。
ランキングAPIの典型用途:「カテゴリ内売上TOP3の商品だけ返す」には WHERE rank_in_cat <= 3 を CTEや サブクエリでフィルタすれば実現できる。WINDOW関数を使わずにGROUP BYとJOINで同じ結果を得ようとすると、クエリが非常に複雑になる。
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW の意味:「先頭行(UNBOUNDED PRECEDING)から現在行(CURRENT ROW)まで」の範囲をウィンドウフレームとして指定する。累積合計では省略してもデフォルトでこの範囲になる場合が多いが、明示することで意図が伝わりやすくなる。
ROW_NUMBER でユニーク連番:同値でも必ず異なる連番を振りたい場合は ROW_NUMBER。重複排除(「カテゴリごとに1件だけ取る」)や「各ユーザーの最新注文1件だけ取得する」パターンで WHERE rn = 1 のフィルタと組み合わせてよく使われる。
アンチパターン
WINDOW関数の結果をWHEREで直接フィルタ:WHERE RANK() OVER (...) <= 3 はエラーになる。WINDOW関数はSELECT/ORDER BYでしか使えない。フィルタしたい場合は CTE または サブクエリで一度ウィンドウ計算をしてから外側でWHEREを書く。
RANK と DENSE_RANK の混同:RANK は同値の次をスキップ(1,1,3)、DENSE_RANK はスキップなし(1,1,2)。「TOP3を取りたい」ときにRANKを使うと、同値が多いと3位が存在しなくなる。用途に合った関数を選ぶこと。
実務コラム:WINDOW関数が使えるとSQLの表現力が格段に広がる
WINDOW関数はSQL初心者には難しく見えますが、習得すると「前月比の計算」「直近N件の移動平均」「ユーザーごとの初回注文を識別する」などが1クエリで書けるようになります。実務では ROW_NUMBER + CTE の組み合わせで「各グループの最新1件を取得する」パターンが特に頻出です。GROUP BY だけで頑張ろうとしているSQL中級者がWINDOW関数を覚えると、クエリの行数が半分以下になることもあります。
QUESTION 7

3テーブルJOIN — 注文・商品・カテゴリを1クエリで結合して詳細APIを作る

INNER JOINLEFT JOIN3テーブルAPI設計
前提知識

実務のAPIではほとんどの場合、複数のテーブルを結合する必要があります。JOINは連鎖して使えます。結合の種類と結合順序を意識することが重要です。

SELECT  a.col, b.col, c.col
FROM       table_a a             -- 起点テーブル
INNER JOIN table_b b             -- 両方に一致する行のみ結合
  ON a.b_id = b.id
LEFT JOIN  table_c c             -- cに一致がなくてもaの行を保持(NULLで補完)
  ON b.c_id = c.id;
INNER JOIN vs LEFT JOIN の選択基準:「結合先に必ずデータがある」→ INNER JOIN。「結合先にデータがない行も残したい」→ LEFT JOIN。LEFT JOINで結合先がNULLの行を WHERE c.id IS NULL で絞ると「存在しない行だけ抽出」になる。
問題

order_items(注文明細)・products(商品)・categories(カテゴリ)の3テーブルを結合して、注文明細に商品名・カテゴリ名を付けて取得してください。カテゴリが設定されていない商品も含めて取得し、その場合は category_name を '未分類' として表示してください。

使用テーブル
▸ order_items
item_idorder_idproduct_idqtyprice
1101P012500
2101P0211200
3102P033300
4102P011500
▸ products
product_idnamecategory_id
P01コーヒーC01
P02サンドイッチC02
P03新商品ANULL
▸ categories
category_idname
C01飲料
C02フード
期待出力
item_idorder_idproduct_namecategory_nameqtysubtotal
1101コーヒー飲料21000
2101サンドイッチフード11200
3102新商品A未分類3900
4102コーヒー飲料1500
模範解答コード
SELECT
  oi.item_id,
  oi.order_id,
  p.name         AS product_name,
  COALESCE(c.name, '未分類') AS category_name, -- 未分類=LEFT JOIN で外れた商品
  oi.qty,
  oi.price * oi.qty AS subtotal        -- 小計 = 単価 × 数量

FROM       order_items oi
INNER JOIN products   p             -- 商品は必須なので INNER JOIN
  ON oi.product_id = p.product_id
LEFT JOIN  categories c             -- カテゴリ無しの商品も残す(LEFT JOIN)
  ON p.category_id = c.category_id

ORDER BY oi.item_id;

/*
  実行順序(3テーブルJOINクエリ):
  1. FROM order_items oi          → 行を読み込む
  2. INNER JOIN products p ON ... → 結合(一致行のみ)
  3. LEFT JOIN categories c ON ...→ 結合(左表を全行保持)
  4. SELECT                       → 列を評価(subtotal)
  5. ORDER BY oi.item_id          → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT oi.item_id, oi.order_id, p.name AS product_name, COALESCE(c.name, '未分類') AS category_name, oi.qty, oi.price * oi.qty AS subtotal FROM order_items oi INNER JOIN products p ON oi.product_id = p.product_id LEFT JOIN categories c ON p.category_id = c.category_id ORDER BY oi.item_id;
LEGEND
データ取得・読込対象
① FROM order_items
FROM order_items oi起点テーブル order_items 全体(4行)を読み込みます。product_id をキーに products を INNER JOIN し、さらに category_id をキーに categories を LEFT JOIN します。
1 / 4
item_idorder_idproduct_idqtyprice
1101P012500
2101P0211200
3102P033300
4102P011500
order_items: 4行
学習ポイント
超基礎:JOINは連鎖できる:テーブルAにJOINしたテーブルBに、さらにCをJOINできます。FROM句の後に JOIN を続けて書くだけです。FROM→JOIN1→JOIN2...の順に左から順番に結合されていきます。
注文詳細APIの典型設計:ECサイトの注文詳細API(GET /orders/:id/items)では、order_items + products + categories + usersなど4〜5テーブルを結合するのが一般的。最初にINNER JOINで必須テーブルを繋ぎ、任意項目にLEFT JOINを使うパターンが鉄板。
結合テーブルのエイリアス命名:3テーブル以上になると oi(order_items)、p(products)、c(categories) のように略称を付けることが多い。列名が衝突する場合は oi.name のようにエイリアスで明示する。
WHERE と LEFT JOINの干渉:LEFT JOINで結合した右テーブルの列に WHERE c.name = '飲料' と条件を付けると、NULLの行(新商品A)が除外されてINNER JOINと同じ動作になる。LEFT JOINの意味が消えるので要注意。
アンチパターン
ON 条件の書き間違い(クロス積):3テーブル結合でON条件を1つ書き忘れると、そのテーブルとのクロス積が発生する。結合するJOINの数だけ ON 条件があることを確認すること(テーブルN個のJOIN → ON条件はN-1個)。
不必要なテーブルを結合する:SELECT に使わない列のためだけにJOINを追加すると、結合コストが増大する。「このJOINがなくても同じ結果が得られるか」を常に確認し、不要なJOINは削ること。
実務コラム:JOINの順序とクエリプランナー
SQLは「どの順でテーブルを結合するか」を直接指定しますが、データベースのクエリプランナー(オプティマイザー)が自動的に最適な実行順序を選択します。インデックスが適切に設定されていれば、JOINの順番を意識しなくても高速に実行されます。ただし、JOINが5テーブルを超えるような複雑なクエリは、プランナーが最適解を選べないことがあるため、CTEや一時テーブルに分割する設計を検討しましょう。
QUESTION 8

UNION / UNION ALL — 複数クエリを縦に結合してマルチソースのレポートを作る

UNION ALLUNION縦結合バッチレポート
前提知識

UNION は複数のSELECT文の結果を縦に結合します。UNION ALL は重複行を含めてすべて結合し、UNION(ALL なし)は重複行を除去します。

SELECT col1, col2 FROM table_a     -- 上のSELECT
UNION ALL                           -- 重複を含めてすべて縦に結合(UNION より高速)
SELECT col1, col2 FROM table_b;    -- 下のSELECT(列数・型を上のSELECTと合わせること)

SELECT col1, col2 FROM table_a
UNION                               -- 重複行を除去して縦に結合(内部でDISTINCT処理が走る)
SELECT col1, col2 FROM table_b;

異なるテーブルを横に並べたい(列を増やす)ならJOIN、縦に積み上げたい(行を増やす)ならUNIONを使います。

UNION の制約:各SELECT文の列数・列の型が一致していなければエラーになる。列名は最初のSELECTのものが採用される。型が異なる場合はキャストが必要。
問題

システムには2つの通知テーブルがあります:email_notificationspush_notifications両テーブルの通知を1つにまとめ、送信日時(sent_at)の新しい順で一覧を返すクエリを作成してください。各行に通知種別('email'/'push')も付与してください。

使用テーブル
▸ email_notifications
iduser_idsubjectsent_at
1U01ご注文確認2024-05-10 09:00
2U02お知らせ2024-05-12 14:00
3U01発送通知2024-05-15 11:00
▸ push_notifications
iduser_idmessagesent_at
1U01クーポン配布中!2024-05-11 10:00
2U03新着商品入荷2024-05-14 16:00
期待出力
notification_typeuser_idcontentsent_at
emailU01発送通知2024-05-15 11:00
pushU03新着商品入荷2024-05-14 16:00
emailU02お知らせ2024-05-12 14:00
pushU01クーポン配布中!2024-05-11 10:00
emailU01ご注文確認2024-05-10 09:00
模範解答コード
SELECT
  'email'  AS notification_type,  -- 固定値で種別を付与
  user_id,
  subject  AS content,            -- 列名を content に統一
  sent_at
FROM  email_notifications

UNION ALL                         -- 縦に結合(重複除去なし=高速)
SELECT
  'push'   AS notification_type,  -- 固定値で種別を付与
  user_id,
  message  AS content,            -- email と列名を揃える
  sent_at
FROM  push_notifications

ORDER BY sent_at DESC;  -- UNION 後の全体を新しい順に

/*
  実行順序(UNION ALLクエリ):
  1. 上の SELECT email_notifications → 行を読み込む
  2. 下の SELECT push_notifications  → 行を読み込む
  3. UNION ALL                      → 和集合をとる
  4. ORDER BY sent_at DESC          → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT 'email' AS notification_type, user_id, subject AS content, sent_at FROM email_notifications UNION ALL SELECT 'push' AS notification_type, user_id, message AS content, sent_at FROM push_notifications ORDER BY sent_at DESC;
LEGEND
データ取得・読込対象
① 上の SELECT
SELECT 'email' AS notification_type, ... FROM email_notificationsemail_notifications テーブル(3行)を取得し、固定文字列 'email' を notification_type として付与します。subject を content として統一します。
1 / 4
notification_typeuser_idcontentsent_at
emailU01ご注文確認2024-05-10 09:00
emailU02お知らせ2024-05-12 14:00
emailU01発送通知2024-05-15 11:00
email: 3行
学習ポイント
超基礎:JOINとUNIONの違い:JOIN は「横に広げる(列を増やす)」操作で、テーブルを関係キーで紐付けます。UNION は「縦に積む(行を増やす)」操作で、同じ形の結果セットを繋ぎます。使い分けの判断基準は「増やしたいのは列か行か」です。
UNION ALL を推奨する理由:UNION(ALLなし)は内部で重複除去のためにソートが走り、UNION ALLより遅い。重複がないことが確実な場合(emailとpushは別テーブルなので重複しない)や重複を残していい場合は常に UNION ALL を使うこと。
固定文字列でソースを識別:'email' AS notification_type のように固定文字列の列を追加することで、結合後にどのテーブルのデータかを識別できる。バッチレポートや監査ログの生成で頻繁に使われるパターン。
ORDER BY は最後に1つだけ:UNION ALLで複数のSELECTを結合する場合、ORDER BY は最後のSELECTの後に1つだけ書く。各SELECT内にORDER BYを書いてもUNION時に無視される(DBによっては構文エラーになる)。
アンチパターン
列数や型の不一致:UNION の両方のSELECTで列数や型が異なるとエラーになる。型が異なる場合は CAST(amount AS TEXT)::TEXT で明示的にキャストして揃えること。
各SELECT内のORDER BY:SELECT ... FROM a ORDER BY sent_at UNION ALL SELECT ... FROM b は構文エラーまたは意図しない動作になる。ORDER BY は最後のSELECトの後にのみ書くこと。
実務コラム:マルチテナント・マルチソースのデータ統合
「複数のデータソースを1つのAPIで返す」場面でUNION ALLは力を発揮します。例えば「通知履歴API」として複数チャンネルの送信履歴を統合したり、「アクティビティフィード」として注文・レビュー・コメントなど異なるアクションを時系列順に並べたりするのに使われます。ただし、テーブルが増えるほどUNIONのSELECTも増えるため、テーブル数が多い場合はUNIONでなく統一的なイベントログテーブルに設計を見直すことも検討してください。
QUESTION 9

トランザクション — BEGIN / COMMIT / ROLLBACK で複数更新を原子的に処理する

BEGINCOMMITROLLBACKACID
前提知識

トランザクションは「複数のSQL文をひとまとまりの処理として扱う」仕組みです。BEGIN で開始し、すべて成功したら COMMIT で確定、途中でエラーが起きたら ROLLBACK で全変更を巻き戻します(原子性: Atomicity)。

BEGIN;                              -- トランザクション開始(START TRANSACTION でも可)

  UPDATE table_a SET col = val;    -- 操作1
  INSERT INTO table_b VALUES (...); -- 操作2
  -- ここでエラーが起きた場合 ↓

COMMIT;                             -- 全操作を確定(DBに永続保存)
-- または --
ROLLBACK;                           -- 全操作を取り消す(BEGIN前の状態に戻す)
ACID特性:A=原子性(全成功/全失敗)、C=一貫性(制約を常に満たす)、I=分離性(他トランザクションの影響を受けない)、D=永続性(COMMITしたら電源断でも保持)。トランザクションはACIDを保証する仕組み。
問題

Eコマースの注文処理(①orders に注文行を INSERT ②order_items に明細を INSERT ③products の在庫数を UPDATE)をトランザクションで1つの原子処理として実装してください。3つの操作のうちどれか1つでも失敗したら全てROLLBACKされるようにしてください。

操作するテーブル
▸ orders(注文ヘッダ)
order_iduser_idtotal_amountstatus
▸ order_items(注文明細・処理前は0行)
order_idproduct_idqtyprice
▸ products(処理前在庫)
product_idnamestock
P01コーヒー100
期待出力

期待する出力①:

order_iduser_idtotal_amountstatus
1001U011500pending

期待する出力②:

order_idproduct_idqtyprice
1001P013500

期待する出力③:

product_idnamestock
P01コーヒー97
模範解答コード
BEGIN;  -- トランザクション開始(COMMIT まで未確定)

-- ① orders テーブルに注文ヘッダを追加
INSERT INTO orders (order_id, user_id, total_amount, status)
VALUES (1001, 'U01', 1500, 'pending');

-- ② order_items テーブルに注文明細を追加
INSERT INTO order_items (order_id, product_id, qty, price)
VALUES (1001, 'P01', 3, 500);

-- ③ products の在庫を購入数量分だけ減らす
UPDATE products
SET   stock = stock - 3    -- 在庫を3減らす(相対更新)
WHERE product_id = 'P01';  -- WHERE 必須(無いと全行更新)

COMMIT;  -- 全操作成功 → 変更を永続確定(他セッションに反映)

-- COMMIT 後の状態を検証
SELECT order_id, user_id, total_amount, status FROM orders ORDER BY order_id;
SELECT order_id, product_id, qty, price FROM order_items ORDER BY order_id, product_id;
SELECT product_id, name, stock FROM products ORDER BY product_id;

/*
  実行順序(正常系):
  1. BEGIN               → トランザクション開始
  2. INSERT orders       → 行を挿入(未確定)
  3. INSERT order_items  → 行を挿入(未確定)
  4. UPDATE products     → 行を更新(未確定)
  5. COMMIT              → 変更を永続確定
  6. SELECT × 3          → 確定後の3テーブルを検証
  */
解説(テーブル変化・ポイント)
BEGIN; INSERT INTO orders ... INSERT INTO order_items ... UPDATE products SET ... COMMIT; -- 正常系 ROLLBACK; -- 異常系(COMMIT の代わり) SELECT order_id, user_id, total_amount, status FROM orders ORDER BY order_id; SELECT order_id, product_id, qty, price FROM order_items ORDER BY order_id, product_id; SELECT product_id, name, stock FROM products ORDER BY product_id;
LEGEND
データ取得・読込対象
① BEGIN
BEGIN; — トランザクション開始BEGIN でトランザクションを開始します。この後の INSERT/UPDATE はすべて「未確定(コミット待ち)」状態になります。他のセッションからはこの変更が見えません。
1 / 4
ステップ操作ordersorder_itemsproducts.stock
STARTBEGIN変化なし変化なし100
トランザクション開始
学習ポイント
超基礎:トランザクションの「全か無か」原則:BEGIN〜COMMIT間の操作は「全て成功」か「全て失敗(ROLLBACK)」のどちらかになります。「注文を作ったが在庫を減らし忘れた」「在庫は減ったが注文行が作られなかった」という半端な状態がデータに残らないことを保証します。
APIにおけるトランザクション管理:Node.js等のフレームワークでは try-catch の中に BEGIN〜COMMIT を書き、catchブロックでROLLBACKを呼ぶパターンが基本。ORMを使っている場合も同様に明示的なトランザクション制御が必要な場面がある。
暗黙のトランザクション:PostgreSQLはBEGINなしで実行した単一のSQL文(INSERT/UPDATE/DELETE)も内部的に1トランザクションとして自動COMMITされます(autocommit)。複数SQL文を原子的に扱う必要があるときだけ明示的にBEGIN〜COMMITを使います。
SAVEPOINTで部分的な巻き戻し:SAVEPOINT sp1; を途中で設定すると ROLLBACK TO SAVEPOINT sp1; でそこまでの変更だけ取り消せる。長いトランザクション内で一部だけ再試行したい場合に使われる(中級テクニック)。
アンチパターン
トランザクションを長時間開きっぱなしにする:BEGIN後にCOMMIT/ROLLBACKをせず長時間放置すると、そのトランザクション内で更新したテーブルに「行ロック」がかかり続け、他のクエリがブロックされてAPIが詰まる。トランザクションは必要最小限の操作だけを含め、即座にCOMMIT/ROLLBACKすること。
アプリのロジックをトランザクション内に混ぜる:BEGIN後にHTTPリクエストや外部API呼び出しをはさむと、その間ロックが保持され続ける。DB操作はまとめてトランザクション外で準備し、DB操作だけをトランザクション内に閉じ込めること。
実務コラム:決済処理とトランザクションの設計
ECサイトの決済処理は「①外部決済APIで課金 → ②DBに注文を記録」という流れになることが多いですが、このケースでは外部API呼び出しをトランザクション内に入れることができません(HTTP通信はROLLBACKできないため)。実務では「まずDBに "pending(処理中)" で注文を記録 → 決済API成功後に "completed" に更新 → 失敗時は "failed" に更新」というステータス管理パターンで冪等性(べきとうせい)を確保します。トランザクションはDB操作の原子性を保証しますが、外部サービスとの整合性は別途設計が必要です。
QUESTION 10

INSERT ON CONFLICT (UPSERT) — 冪等なデータ登録でバッチ処理を安全にする

INSERTON CONFLICTUPSERT冪等性
前提知識

UPSERT(INSERT + UPDATE)は「存在しなければINSERT、すでに存在すればUPDATE」を1文で行う操作です。PostgreSQLでは INSERT ... ON CONFLICT 句で実現します。

INSERT INTO table_name (col1, col2)
VALUES ('val1', 'val2')
ON CONFLICT (unique_col)            -- 一意制約違反が起きた列を指定
DO UPDATE SET                       -- 競合時に実行するUPDATE処理
  col2 = EXCLUDED.col2;            -- EXCLUDED = 挿入しようとした新しい値

-- 競合時に何もしない場合:
ON CONFLICT (unique_col) DO NOTHING; -- エラーを無視して既存行はそのまま
UPSERT が重要な理由:バッチ処理は再実行(リトライ)されることがある。UPSERTを使うとデータの重複や「行が既に存在する」エラーを防ぎ、何度実行しても同じ結果になる冪等な処理を実現できる。
問題

user_profiles テーブルに外部APIから取得したプロフィールデータを同期する処理を実装してください。user_id が存在しない場合は新規INSERT、すでに存在する場合は name と updated_at だけを更新してください(email は変更しない)。

テーブル定義と現在のデータ
▸ user_profiles(UPSERTを行うテーブル)
user_id(UNIQUE)nameemailupdated_at
U01田中 太郎tanaka@example.com2024-04-01
U02佐藤 花子sato@example.com2024-04-15
▸ 外部APIからの同期データ
user_idnameemail
U01田中 太郎(改名)tanaka@example.com
U03鈴木 一郎suzuki@example.com
期待出力
user_idnameemailupdated_at
U01田中 太郎(改名)← 更新tanaka@example.com(変化なし)2024-06-01(更新)
U02佐藤 花子(変化なし)sato@example.com2024-04-15(変化なし)
U03鈴木 一郎 ← 新規suzuki@example.com2024-06-01(新規)
模範解答コード
-- U01 の同期: 既存行 → name と updated_at を更新(emailは変更しない)
INSERT INTO user_profiles (user_id, name, email, updated_at)
VALUES (
  'U01',
  '田中 太郎(改名)',
  'tanaka@example.com',
  NOW()                               -- 同期時刻を設定
)
ON CONFLICT (user_id)               -- user_id が重複した(既存行がある)場合の処理
DO UPDATE SET                       -- 競合したときにUPDATEを実行する
  name       = EXCLUDED.name,      -- EXCLUDED: 挿入しようとした新しい値を参照するキーワード
  updated_at = EXCLUDED.updated_at; -- email は SET に書かないことで元の値を保持する

-- U03 の同期: 新規行 → INSERT される(ON CONFLICT は発生しない)
INSERT INTO user_profiles (user_id, name, email, updated_at)
VALUES (
  'U03',
  '鈴木 一郎',
  'suzuki@example.com',
  NOW()
)
ON CONFLICT (user_id)               -- 同じUPSERT構文を書いても、競合なし → 通常のINSERT
DO UPDATE SET
  name       = EXCLUDED.name,
  updated_at = EXCLUDED.updated_at;

/*
  実行順序(UPSERT / U01 のケース):
  1. INSERT INTO user_profiles → 一意制約をチェック
  2. 一意制約違反を検出        → 既存行と競合
  3. ON CONFLICT DO UPDATE     → 既存行を上書き

  実行順序(UPSERT / U03 のケース):
  1. INSERT INTO user_profiles → 一意制約をチェック
  2. 競合なし                  → 制約に抵触しない
  3. 通常のINSERT             → 行を挿入
*/
解説(テーブル変化・ポイント)
INSERT INTO user_profiles (user_id, name, email, updated_at) VALUES ('U01', '田中 太郎(改名)', 'tanaka@example.com', NOW()) ON CONFLICT (user_id) DO UPDATE SET name = EXCLUDED.name, updated_at = EXCLUDED.updated_at;
LEGEND
データ取得・読込対象
① INSERT試行 (U01)
INSERT INTO user_profiles VALUES ('U01', ...)U01(田中 太郎改名)を INSERT しようとします。DB が user_id='U01' の UNIQUE制約をチェックします。U01 は既存行に存在するため ON CONFLICT 句が発動します。
1 / 3
user_idnameemailupdated_at
U01(既存)田中 太郎tanaka@example.com2024-04-01
U02(既存)佐藤 花子sato@example.com2024-04-15
UPSERT前の user_profiles(2行)
INSERT INTO user_profiles (user_id, name, email, updated_at) VALUES ('U03', '鈴木 一郎', 'suzuki@example.com', NOW()) ON CONFLICT (user_id) DO UPDATE SET name = EXCLUDED.name, updated_at = EXCLUDED.updated_at;
LEGEND
データ取得・読込対象
① INSERT試行 (U03)
INSERT INTO user_profiles VALUES ('U03', ...)U03(鈴木 一郎)を INSERT しようとします。DB が user_id='U03' の UNIQUE制約をチェックします。U03 は存在しないためコンフリクトが発生せず、通常の INSERT として処理されます。
1 / 2
user_idnameemailupdated_at
U01田中 太郎(改名)tanaka@example.com2024-06-01
U02佐藤 花子sato@example.com2024-04-15
U01 UPSERT後の状態(2行)
学習ポイント
超基礎:UPSERT(UPDATE + INSERT)とは:「あれば更新、なければ挿入」を1つのSQL文で行う操作です。アプリ側で「まずSELECTして存在チェック → INSERTかUPDATEを選ぶ」という2クエリ処理をなくせます。SQLによって書き方は異なります(MySQLは INSERT ... ON DUPLICATE KEY UPDATE、PostgreSQL は ON CONFLICT ... DO UPDATE)。
冪等性(べきとうせい)とバッチ処理:「何度実行しても同じ結果になる性質」が冪等性。バッチ処理はネットワーク障害やサーバー再起動で再実行されることがあり、UPSERTを使うことで「2回目の実行でも重複INSERTエラーが出ない」冪等なバッチを実装できる。
EXCLUDED キーワード:DO UPDATE SET 内で EXCLUDED.列名 と書くと「今回挿入しようとした新しい値」を参照できる。user_profiles.列名 と書けば「既存行の値」を参照できる。例えば SET click_count = user_profiles.click_count + EXCLUDED.click_count で既存値に加算もできる。
DO NOTHING との使い分け:ON CONFLICT DO NOTHING はエラーを無視して既存行をそのまま保持する(何も変更しない)。「初回だけINSERT、2回目以降は無視したい」マスタデータ初期化や、重複挿入が起こりうるがエラーにしたくない場合に使う。
アンチパターン
SELECTで確認してからINSERT/UPDATE(Check-Then-Act):IF EXISTS(SELECT ...) THEN UPDATE ELSE INSERT のロジックは、マルチスレッド環境でSELECTとINSERTの間に別のスレッドが先にINSERTすると「重複キーエラー」が発生する(TOCTOU競合)。UPSERTなら1文で原子的に処理できるため安全。
ON CONFLICT の対象列に一意制約がない:ON CONFLICT (user_id) は user_id 列にUNIQUE制約または主キー制約がないとエラーになる。ON CONFLICTを使う列には必ずDB側に一意制約を付けること。
実務コラム:外部サービスとのデータ同期バッチ設計
外部APIや他システムからデータを定期的に取り込む「データ同期バッチ」では、UPSERTが最も安定した選択肢です。全件DELETEしてから全件INSERTする方式(TRUNCATE + INSERT)は処理中に他のAPIが空テーブルを参照してしまうリスクがあります。一方UPSERTは「差分更新」なので既存データは保ちつつ変更分だけ反映できます。さらに updated_at = NOW() を必ず更新することで「いつ同期されたか」をトレースでき、障害調査にも役立ちます。