upsert:行を挿入する、すでにあれば更新する

既存の行と衝突するかもしれない INSERT には、エンジンごとに異なる UPSERT 構文があります。同じウィッシュリストの行を使って動作の違いを比べます。

このページはデータを変更します。変わるのはブラウザにあるあなた自身のサンプルデータベースです。やり直したいときはウェルカム画面から読み込み直してください。

SQLite と PostgreSQL

どちらも ON CONFLICT を使い、構文も同じです。片方向けに書いたクエリがもう片方でも動きます。

1

-- Insert, or update the row that is already there.
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '2026-01-01 00:00:00')
ON CONFLICT (customer_id, product_id)
DO UPDATE SET added_at = excluded.added_at;
Planche で実行

1 行を変更

ブラウザの別タブで Planche が開き、サンプルデータベースを読み込んだ状態でこのクエリが入ります。このページの例はどれも同じタブを使い回します。F9 を押すまで実行はされません。

衝突する列を自分で指定し、excluded が「挿入しようとした行」を指します。2回実行してみてください。1回目は挿入、2回目は更新になります。

2

SELECT customer_id, product_id, added_at
FROM wishlists
WHERE customer_id = 1 AND product_id = 288;
Planche で実行

結果 — 1 行

customer_idproduct_idadded_at
12882026-01-01 00:00:00

もうひとつの分岐は「既存の行はそのままにする」です。

3

-- "Insert if it is not there, otherwise leave it alone." No error.
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '1999-12-31 00:00:00')
ON CONFLICT (customer_id, product_id) DO NOTHING;
Planche で実行

0 行を変更

何も変わらず、エラーも出ません。素の INSERT なら主キーで失敗するところで、そこが違いです。

PostgreSQL

INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '2026-01-01 00:00:00')
ON CONFLICT (customer_id, product_id)
DO UPDATE SET added_at = EXCLUDED.added_at;

-- DO NOTHING is the same idea as SQLite's.
INSERT INTO wishlists (customer_id, product_id)
VALUES (1, 288)
ON CONFLICT DO NOTHING;

PostgreSQL — 比較のために載せています。ブラウザ内のサンプルは SQLite なので、この文はここでは実行できません。

PostgreSQL は慣習として excluded を大文字で書きますが、どちらでも受け付けます。更新側の分岐に WHERE を付けられるので、条件を満たすときだけ更新することもできます。

MySQL と MariaDB

MySQL / MariaDB

-- MySQL 8.0.20 and later: alias the new row instead of VALUES().
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '2026-01-01 00:00:00') AS new
ON DUPLICATE KEY UPDATE added_at = new.added_at;

-- Older MySQL and MariaDB:
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '2026-01-01 00:00:00')
ON DUPLICATE KEY UPDATE added_at = VALUES(added_at);

MySQL / MariaDB — 比較のために載せています。ブラウザ内のサンプルは SQLite なので、この文はここでは実行できません。

違うのは名前だけではありません。この句は自分で指定した制約ではなく、いずれかの一意制約に違反したことに反応します。一意キーが複数あるテーブルでは、衝突したほうの制約で更新が走るということで、それが必ずしも想定していたものとは限りません。MySQL 8.0.20 で VALUES(col) は非推奨になり、新しい行に別名を付ける形が推奨になりました。新しいコードは AS new の形を使ってください。MariaDB はまだ VALUES() を要求します。

SQL Server と Oracle

SQL Server / Oracle

MERGE INTO wishlists AS target
USING (SELECT 1 AS customer_id, 288 AS product_id, '2026-01-01' AS added_at) AS source
    ON target.customer_id = source.customer_id
   AND target.product_id  = source.product_id
WHEN MATCHED THEN
  UPDATE SET added_at = source.added_at
WHEN NOT MATCHED THEN
  INSERT (customer_id, product_id, added_at)
  VALUES (source.customer_id, source.product_id, source.added_at);

SQL Server / Oracle — 比較のために載せています。ブラウザ内のサンプルは SQLite なので、この文はここでは実行できません。

MERGE は標準SQLの答えで、群を抜いて冗長です。入ってくる行をクエリとして書き、対象と結合し、分岐をそれぞれ書き下します。そのぶん汎用的でもあり、WHEN NOT MATCHED BY SOURCE THEN DELETE を付ければ完全な同期になります。Oracle は PL/SQL の中で末尾のセミコロンを必要とせず、SQL Server は必要とします。コピーしてきた MERGE が最初に失敗する理由はたいていこれです。

SQL Server で1行だけを扱うなら、トランザクションの中で UPDATE を実行し、@@ROWCOUNT で守った INSERT を続ける書き方を選ぶチームも多くあります。MERGE には同時実行やトリガーがらみの不具合報告が長く積み上がっているからです。使うなら適切な分離レベルのトランザクションの下に置き、それ自体が原子的だとは考えないでください。

REPLACE は upsert ではない

SQLite と MySQL には REPLACE があり、これの短い書き方に見えます。同じ操作ではありません。

4

-- REPLACE deletes the conflicting row and inserts a new one, so
-- added_at is not kept — it falls back to its default. Run the check
-- above again afterwards and compare.
INSERT OR REPLACE INTO wishlists (customer_id, product_id)
VALUES (1, 288);
Planche で実行

1 行を変更

確認クエリをもう一度実行すると、added_at はもう2026年の日付ではなく、現在時刻になっています。REPLACE は衝突した行を削除して新しい行を挿入するので、書かなかった列はすべて既定値に戻ります。列の多いテーブルではこれが実データを黙って捨てますし、削除は削除トリガーを発火させ、外部キー経由で子の行にも波及します。

本当に「古い行は捨てていい」ときだけ使ってください。「挿入または更新」なら ON CONFLICT です。