本文へ移動
SQL道場 更新日: 2026年5月2日 約15分で読めます

最新レコードをSQLで取得する方法|ROW_NUMBERとDELETE INSERTの使い分け


「重複した履歴から最新を取る」は日常業務だ

通販のデータベースには、同じエンティティに対して複数の履歴レコードが積み上がるテーブルが必ず存在する。

  • 注文ステータスの変更履歴(pending → processing → shipped → delivered)
  • 会員ランクの変更履歴(一般 → シルバー → ゴールド)
  • 住所の変更履歴(引越しのたびに更新される)
  • 商品価格の改定履歴

こうした「最新の状態だけを使いたい」という場面で、2つのアプローチが対立する。

アプローチA:ROW_NUMBER で最新を選ぶ(読み取り時に解決)
テーブルには履歴を全件保持したまま、SELECT するときに ROW_NUMBER で最新1件だけを取り出す。

アプローチB:DELETE + INSERT で最新を維持する(書き込み時に解決)
新しいレコードを INSERT する前に古いレコードを DELETE し、常に「最新の1件だけ」がテーブルに存在する状態を維持する。

どちらが正解かは一概には言えない。設計の目的・データ量・更新頻度・履歴の保持要否によって最適解が変わる。この記事では両者を徹底比較し、通販現場での選択基準を明確にする。


サンプルシナリオ:注文ステータスの履歴テーブル

注文のステータスが変更されるたびに履歴レコードが追加されるテーブルを例にとる。

-- order_status_history(注文ステータス履歴テーブル)
-- history_id    : 履歴ID(主キー、AUTO INCREMENT)
-- order_id      : 注文ID
-- status        : ステータス('pending','processing','shipped','delivered','cancelled')
-- changed_at    : ステータス変更日時
-- changed_by    : 変更者('system' / 'operator')
-- note          : メモ
history_idorder_idstatuschanged_atchanged_by
1O001pending2024-12-01 09:00:00system
2O001processing2024-12-01 10:30:00system
3O001shipped2024-12-02 14:00:00operator
4O002pending2024-12-01 11:00:00system
5O002cancelled2024-12-01 11:45:00operator
6O001delivered2024-12-04 15:00:00system

O001 の現在の最新ステータスは delivered(history_id = 6)だ。


アプローチA:ROW_NUMBER で最新を取る

基本クエリ

-- ROW_NUMBERで各注文の最新ステータスを取得する

WITH ranked AS (
    SELECT
        history_id,
        order_id,
        status,
        changed_at,
        changed_by,
        note,
        -- 注文ごとに変更日時の降順で番号を振る(1 = 最新)
        ROW_NUMBER() OVER (
            PARTITION BY order_id
            ORDER BY changed_at DESC
        )  AS rn
    FROM order_status_history
)
SELECT
    history_id,
    order_id,
    status        AS current_status,
    changed_at    AS last_changed_at,
    changed_by
FROM ranked
WHERE rn = 1  -- 最新の1件だけを残す
ORDER BY order_id;

出力イメージ

order_idcurrent_statuslast_changed_atchanged_by
O001delivered2024-12-04 15:00:00system
O002cancelled2024-12-01 11:45:00operator

シンプルで分かりやすい。元テーブルに全履歴が残っているため、「O001 はいつ shipped になったか」を後から追うことも可能だ。

MIN / MAX で代替する方法

最新1件だけが欲しい場合、ROW_NUMBER より単純な MAX を使うアプローチもある。

-- MAX(changed_at)で最新日時を取り、再度JOINして全カラムを取得する

WITH latest_time AS (
    SELECT
        order_id,
        MAX(changed_at)  AS latest_changed_at
    FROM order_status_history
    GROUP BY order_id
)
SELECT
    h.order_id,
    h.status        AS current_status,
    h.changed_at    AS last_changed_at,
    h.changed_by
FROM order_status_history  h
JOIN latest_time           l
    ON  h.order_id   = l.order_id
    AND h.changed_at = l.latest_changed_at
ORDER BY h.order_id;

こちらはウィンドウ関数を使わないため、古いSQLエンジンでも動く。ただし「同じ changed_at のレコードが2件ある」場合に複数行が返るリスクがある。安全なのは ROW_NUMBER だ。

ROW_NUMBER vs MAX の使い分け

観点ROW_NUMBERMAX + JOIN
同時刻レコードへの対処ORDER BY のタイブレーカーで制御可能複数行が返る可能性あり
追加カラムの取得CTEの中で全カラム保持できるJOINが必要
可読性やや冗長だが意図が明確シンプル
エンジン互換性ウィンドウ関数対応が必要どこでも動く

アプローチB:DELETE + INSERT で最新を維持する

こちらはテーブル設計の話になる。新しいステータスを記録するとき、古いレコードを削除してから新しいレコードを挿入する。結果としてテーブルには常に「最新の1件」しか存在しない。

-- ステータスを更新するときの処理(例:O001 を delivered に更新)

-- Step 1: 既存レコードを削除
DELETE FROM order_current_status
WHERE order_id = 'O001';

-- Step 2: 新しいレコードを挿入
INSERT INTO order_current_status
    (order_id, status, changed_at, changed_by)
VALUES
    ('O001', 'delivered', '2024-12-04 15:00:00', 'system');

もしくはデータベースによっては UPSERT(INSERT ... ON CONFLICT DO UPDATE / REPLACE INTO)で1文で実現できる。

-- PostgreSQL / BigQuery の UPSERT(MERGE文)相当
MERGE INTO order_current_status AS target
USING (
    SELECT 'O001' AS order_id, 'delivered' AS status,
           TIMESTAMP '2024-12-04 15:00:00' AS changed_at, 'system' AS changed_by
) AS source
ON target.order_id = source.order_id
WHEN MATCHED THEN
    UPDATE SET status = source.status,
               changed_at = source.changed_at,
               changed_by = source.changed_by
WHEN NOT MATCHED THEN
    INSERT (order_id, status, changed_at, changed_by)
    VALUES (source.order_id, source.status, source.changed_at, source.changed_by);

SELECT するときは何も考えずに全件取るだけでよい。

-- SELECT は単純に全件取るだけ
SELECT *
FROM order_current_status
ORDER BY order_id;

2つのアプローチを徹底比較する

観点ROW_NUMBER(読み取り時解決)DELETE + INSERT(書き込み時解決)
履歴の保持✅ 全履歴が残る❌ 最新しか残らない
SELECT の複雑さ❌ 毎回 ROW_NUMBER が必要✅ 単純な SELECT でよい
テーブルのサイズ❌ 履歴が増え続ける✅ 常に最小限
書き込みコスト✅ INSERT のみ(低コスト)❌ DELETE + INSERT(2操作必要)
障害時の対応✅ 履歴があるので原因追跡できる❌ 削除前の状態に戻れない
分析クエリの利便性✅ 時系列分析が可能❌ 現在状態しか分からない
整合性リスク✅ INSERT のみなので整合性が壊れにくい❌ DELETE と INSERT の間に障害が起きると不整合になる
データウェアハウス適性✅ Treasure Data のような DWH に向く⚠ トランザクション対応の RDBMS 向き

通販現場での選択基準

履歴テーブル(アプローチA推奨)

「いつ、誰が、何を変えたか」を追跡する必要があるテーブルには全件保持 + ROW_NUMBER が向く。

  • 注文ステータス変更履歴
  • 価格改定履歴
  • 会員ランク変更履歴
  • 住所変更履歴(返品対応・配送トラブル調査に使う)
-- 実務でよく使う「現在と1つ前」を同時に取得するパターン

WITH ranked AS (
    SELECT
        order_id,
        status,
        changed_at,
        ROW_NUMBER() OVER (
            PARTITION BY order_id ORDER BY changed_at DESC
        )  AS rn,
        LAG(status) OVER (
            PARTITION BY order_id ORDER BY changed_at DESC
        )  AS prev_status  -- 1つ前のステータス
    FROM order_status_history
)
SELECT
    order_id,
    status       AS current_status,
    prev_status,  -- 直前のステータス(例:shipped → delivered の遷移確認)
    changed_at
FROM ranked
WHERE rn = 1;

現在状態テーブル(アプローチB推奨)

リアルタイムの最新状態だけが必要で、履歴不要のテーブルにはシンプルな DELETE + INSERT が向く。

  • カート内商品テーブル(追加・削除を繰り返す一時的な状態)
  • セッション情報テーブル(ログイン中かどうか)
  • 在庫数テーブル(現在の在庫数のみ管理)

両方持つ設計(最も堅牢)

本番の OLTP(オンライントランザクション処理)では「現在状態テーブル(アプローチB)」を持ち、分析基盤(Treasure Data 等)では「履歴テーブル(アプローチA)」に同期するという二段構えが理想的だ。

[OLTP DB] order_current_status(最新のみ・高速参照)
    ↓ ETL/CDC(変更データキャプチャ)
[DWH]     order_status_history(全履歴・分析用)

Treasure Data での実務パターン

Treasure Data はデータを追記(Append)するアーキテクチャが基本だ。DELETE がサポートされていないため、アプローチA(ROW_NUMBER)一択になる。

ただし毎回全件 ROW_NUMBER を計算するとコストが高い。そこで実務でよく使うのが「最新スナップショットテーブル」を定期的に作り直すパターンだ。

-- Treasure Dataでの実務パターン:
-- 毎朝バッチで「最新ステータスのみ」のスナップショットを作り直す

CREATE TABLE order_latest_status AS
WITH ranked AS (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY order_id
            ORDER BY changed_at DESC
        )  AS rn
    FROM order_status_history
    WHERE changed_at >= DATE_ADD('day', -90, CURRENT_DATE)  -- 直近90日分で十分な場合
)
SELECT
    order_id,
    status        AS current_status,
    changed_at    AS last_changed_at,
    changed_by
FROM ranked
WHERE rn = 1;

このスナップショットテーブルを後続のクエリから参照することで、毎回 ROW_NUMBER を計算するコストを節約できる。


よくある間違い:GROUP BY + MAX だけで済ませてしまう

MAX(changed_at) だけでなく他のカラム(status など)も一緒に取りたいとき、よくある誤りがある。

-- ❌ 間違い:MAX(changed_at) と status が対応していない

SELECT
    order_id,
    MAX(changed_at)  AS last_changed_at,
    status           -- これは「最新日時のstatusとは限らない」
FROM order_status_history
GROUP BY order_id, status;  -- status を GROUP BY に入れると全ステータスが出てしまう

GROUP BY order_id だけにすると status が SELECT できず、GROUP BY order_id, status にすると全ステータスが出る。この罠から逃れるには ROW_NUMBER か MAX + JOIN の二択だ。SQL を書き始めたばかりの担当者が必ずハマる箇所なので、チームでのコードレビュー時に特に注意したい。


まとめ

ROW_NUMBERDELETE + INSERT
いつ解決するか読み取り時(SELECT)書き込み時(INSERT/DELETE)
向いているケース履歴追跡が必要・DWH環境リアルタイム最新状態・OLTP環境
Treasure Data✅ こちらのみ使える❌ DELETE 非対応

「最新レコードを取る」という一見シンプルな要件の裏に、データ設計の哲学が詰まっている。履歴を捨てるか保持するかの判断は、後から変えることが難しいため、テーブル設計の段階で意識的に選ぶ必要がある。


MarTech Farmをもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む