はじめに
この記事では、UPSERTをどのような場面で使うべきか、乱用するとどのような問題が起きるかを整理します。
UPSERTは「INSERT または UPDATE」を1文で行う書き方です。
便利に見えますが、何でも UPSERT で済ませようとすると、意図しない上書きや監査ログの欠落など、後から気づきにくいバグを生みます。
UPSERTとは何か
MySQLでのUPSERTは INSERT ... ON DUPLICATE KEY UPDATE で記述します。
INSERT INTO user_settings (user_id, theme, language)
VALUES (1, 'dark', 'ja') AS new
ON DUPLICATE KEY UPDATE
theme = new.theme,
language = new.language;
user_id の一意制約(または主キー)が重複した場合にUPDATE、存在しない場合にINSERTが走ります。
MySQL 8.0.20以降では VALUES(col) による参照が非推奨になり、挿入行に別名を付けて参照する書き方が推奨されています。この記事のサンプルはその書き方に統一しています。
UPSERTが適切なケース
設定・状態を最新値で管理するテーブル
1ユーザーに対して常に1レコードが対応する設定テーブルのような場合、UPSERTは自然な選択です。
-- ユーザーの設定は常に最新値を保持する
INSERT INTO user_settings (user_id, theme, language, updated_at)
VALUES (?, ?, ?, NOW()) AS new
ON DUPLICATE KEY UPDATE
theme = new.theme,
language = new.language,
updated_at = NOW();
「存在しなければ作る、存在すれば上書く」という操作が業務的に正しい場合は適切です。
集計テーブルへのバッチ更新
集計結果を定期バッチで更新する場合、存在しなければINSERT、存在すれば集計値をUPDATEする操作はUPSERTと相性がよいです。
前提として、monthly_order_summaries には user_id + target_month の一意制約を設定します。
INSERT INTO monthly_order_summaries
(user_id, target_month, order_count, total_amount, aggregated_at)
SELECT
src.user_id,
src.target_month,
src.order_count,
src.total_amount,
src.aggregated_at
FROM (
SELECT
user_id,
DATE(ordered_at - INTERVAL (DAY(ordered_at) - 1) DAY) AS target_month,
COUNT(*) AS order_count,
SUM(amount) AS total_amount,
NOW() AS aggregated_at
FROM orders
WHERE ordered_at >= ? AND ordered_at < ?
GROUP BY
user_id,
DATE(ordered_at - INTERVAL (DAY(ordered_at) - 1) DAY)
) AS src
ON DUPLICATE KEY UPDATE
order_count = src.order_count,
total_amount = src.total_amount,
aggregated_at = src.aggregated_at;
UPSERTがアプリケーションコードを単純にする
UPSERTの実用的な価値の一つは、アプリケーション側のコードを減らせることです。
INSERT と UPDATE を分けて書く場合、アプリ側では「存在するかどうか確認してから分岐する」コードが必要になります。
// INSERT/UPDATE を分けると存在確認のロジックが必要になる
existing, err := repo.FindByID(ctx, id)
if errors.Is(err, ErrNotFound) {
return repo.Insert(ctx, record)
}
return repo.Update(ctx, record)
UPSERTにすると、この分岐がなくなります。
// UPSERTなら1呼び出しで済む
return repo.Upsert(ctx, record)
「存在しなければ作る、存在すれば上書く」が業務的に正しい場合、この単純さは設計上の利点です。
POSTにHTTPメソッドを寄せる選択肢
UPSERTと相性のよい操作は、API設計も単純化しやすいことがあります。
通常、RESTfulなAPIは新規作成に POST、更新に PUT や PATCH を使います。
しかし、設定画面や状態の保存のように「送られてきた値で最新の状態を作る」という操作では、POST だけに寄せることも選択肢になります。
これはDBでUPSERTを使うから自動的にそうなる、という話ではありません。
新規作成と更新の業務的な意味が近い場合に、API側でもその形を取りやすい、という関係です。
// POST /api/user-settings
// 存在しなければ作り、存在すれば上書く
func (h *UserSettingsHandler) Save(w http.ResponseWriter, r *http.Request) {
var req UserSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "リクエストの解析に失敗しました", http.StatusBadRequest)
return
}
// アプリ側に INSERT/UPDATE の分岐がない
if err := h.service.Save(r.Context(), req); err != nil {
http.Error(w, "保存に失敗しました", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// サービス層もシンプルになる
func (s *UserSettingsService) Save(ctx context.Context, req UserSettingsRequest) error {
return s.repo.Upsert(ctx, &UserSettings{
UserID: req.UserID,
Theme: req.Theme,
Language: req.Language,
})
}
これが成立するのは、次の条件が揃っているときです。
- 新規登録と更新でビジネス的な意味に差がない
- 操作の履歴を残す必要がない
- クライアントが「今の状態を送りつける」という設計になっている
ユーザー設定・プロフィール情報・フォームの下書き保存などはこのパターンと相性がよいです。
一方、注文の作成や契約の登録など、「新規に作る行為」としての意味が重要な操作では、POST と PUT/PATCH を分けた方が意図が明確になります。
方針としてUPSERTを禁止・制限する現場もある
実務では、UPSERTを技術的に使える環境でも、設計方針として禁止または制限する現場があります。
代表的な理由は次のとおりです。
- INSERTとUPDATEを監査ログで厳密に分離したい
- DB方言への依存を最小化したい
- 呼び出し側の操作意図(新規か更新か)を明示したい
Goのレイヤー設計では、DBアクセス層のインタフェースに Upsert を置かない運用も一般的です。
その場合、インタフェースは Create と Update を分けて定義し、MySQL実装の内部だけで ON DUPLICATE KEY UPDATE を使うかどうかを判断します。
この運用にしておくと、アプリケーション層の語彙を業務操作に寄せたまま、DB実装側だけで最適化を選択できます。
UPSERTが問題になるケース
操作の区別が必要な場合
「新規登録」と「更新」でビジネス的に意味が違う場合、UPSERTで一括処理すると区別ができなくなります。
例えば注文の状態遷移を管理するテーブルで、「新規注文の登録」と「既存注文の更新」が混在するケースです。
-- 意図せず既存注文を上書きしてしまう危険がある
INSERT INTO orders (id, user_id, amount, status)
VALUES (?, ?, ?, 'pending') AS new
ON DUPLICATE KEY UPDATE
amount = new.amount,
status = new.status;
「ID が重複していた = 同じ注文が2回来た」のか、「既存注文を更新したかった」のか、呼び出し側の意図をUPSERTが吸収してしまいます。
こういう場合は INSERT と UPDATE を明示的に分けます。
// 存在確認してから分岐する
existing, err := orderRepo.FindByID(ctx, id)
if errors.Is(err, ErrNotFound) {
return orderRepo.Insert(ctx, order)
}
return orderRepo.Update(ctx, order)
監査ログが取れなくなる
INSERT と UPDATE を区別したい監査ログの仕組みがある場合、UPSERTはどちらが走ったかをアプリ側で把握できません。
ON DUPLICATE KEY UPDATE では、実際に INSERT されたか UPDATE されたかを ROW_COUNT() の戻り値で判別できます(一般的には INSERTなら1、UPDATEなら2、変更なしなら0)。
ただし、クライアント接続設定(CLIENT_FOUND_ROWS)の影響で戻り値の扱いが変わることがあるため、監査用途で厳密に区別する場合は接続設定まで含めて確認が必要です。
-- ROW_COUNT() の戻り値(一般的な扱い)
-- 1 = INSERT が実行された
-- 2 = UPDATE が実行された
-- 0 = 変更なし(更新対象のカラムがすべて同じ値だった場合)
-- 接続設定によって解釈が変わる場合があるため、厳密運用時は要確認
なお、updated_at = NOW() のように実行のたびに値が変わるカラムを更新対象に含めている場合、業務的な値が変わっていなくてもUPDATEは常に発動し、ROW_COUNTは2になります。ROW_COUNT=0になるのは、更新対象のカラムがすべて同じ値である場合に限られます。
監査ログを確実に取るためには、INSERT と UPDATE を分けて明示的に記録する方が安全です。
意図しない上書きが起きる
UPSERTの条件は「一意キーの重複」です。
業務的には「上書きしてはいけない」データを、一意キーが重複しただけで上書いてしまう危険があります。
CREATE TABLE contracts (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL,
plan VARCHAR(50) NOT NULL,
signed_at DATE NOT NULL,
UNIQUE KEY uq_company (company_id)
);
-- company_id が重複すると契約内容が上書きされる
INSERT INTO contracts (company_id, plan, signed_at)
VALUES (?, ?, ?) AS new
ON DUPLICATE KEY UPDATE
plan = new.plan,
signed_at = new.signed_at;
「同じ会社との契約が2件目になった」ときに、UPSERT が走ると1件目の契約内容が消えます。
こういう場合はUPSERTを使わず、重複時はエラーとしてアプリ側で制御します。
INSERT IGNORE との使い分け
INSERT IGNORE は一意キーが重複した場合にエラーを無視してINSERTをスキップします。
-- 既に存在する場合はスキップする
INSERT IGNORE INTO user_tags (user_id, tag_id) VALUES (?, ?);
「存在しなければ追加、存在すれば何もしない」という操作に使います。
UPSERTと違い、既存レコードを上書きしません。
| 操作 | INSERT IGNORE | UPSERT (ON DUPLICATE KEY UPDATE) |
|---|---|---|
| 新規の場合 | INSERT | INSERT |
| 重複の場合 | スキップ | UPDATE |
| 用途 | 「なければ追加」 | 「なければ追加、あれば更新」 |
まとめ
UPSERTを使ってよいケースは次のとおりです。
- 常に最新値を1レコードで保持する設定テーブル
- 集計テーブルへのバッチ更新
- 新規と更新に業務的な差がなく、アプリ側のコードを単純にしたい場合
UPSERTが使える状況では、アプリ側の INSERT/UPDATE 分岐がなくなり、APIの HTTPメソッドも POST だけに寄せられることがあります。
ただし、API設計まで含めて単純化してよいのは、新規と更新の意味が十分近い場合に限られます。
UPSERTを避けるべきケースは次のとおりです。
- 新規登録と更新でビジネス的な意味が違う場合
- 監査ログで INSERT と UPDATE を区別する必要がある場合
- 意図しない上書きが起きると困るデータ
UPSERTは「操作の意図を消す」という副作用を持ちます。
「存在しなければ作る、存在すれば上書く」が業務的に正しい操作かどうかを確認してから使うことが重要です。
この記事のサンプルSQL・Goコード