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

SCDをSQLで実装する方法|履歴を保持するディメンション設計


「当時の情報」を後から正確に再現できるか

通販の分析をしていると、こんな問いに直面することがある。

「昨年のゴールド会員の購買金額を集計してください」

一見シンプルだが、落とし穴がある。「昨年ゴールドだったが、今はプラチナに昇格した顧客」はどう扱うか。現在の会員ランクでフィルタすると、昨年時点ではゴールドだった顧客が抜けてしまう。

もっと深刻な例もある。

「あの注文が発生した当時の商品価格はいくらでしたか?」

価格が改定されたあとで「当時の価格」を参照しようとしても、最新価格しか残っていなければ正確な売上分析ができない。

こういった「ある時点での状態」を正確に記録・参照するためのデータ設計手法が SCD(Slowly Changing Dimension:緩やかに変化するディメンション) だ。

今回は通販現場で最もよく使われる Type 2 SCD の設計と、それをSQLで実装する方法を解説する。


SCDの3つのタイプ

SCDには複数のタイプがある。まず全体像を把握しよう。

タイプ概要履歴向いているケース
Type 1上書き更新。古い値は消える残らない誤入力の修正など、履歴不要な変更
Type 2新しいレコードを追加。有効期間で管理残る会員ランク・住所・価格など、時点参照が必要な変更
Type 3現在値と1つ前の値だけカラムで保持1世代のみ変更が1回しか起きない前提のケース

通販の分析基盤で最もよく使われるのは Type 2 だ。履歴を完全に保持しながら「特定時点での状態」を正確に再現できる。


Type 2 SCD のテーブル設計

Type 2 の肝は「有効期間(valid_from / valid_to)」カラムだ。

-- member_rank_history(会員ランク履歴テーブル:Type 2 SCD)
-- surrogate_key  : サロゲートキー(このテーブル固有のID)
-- customer_id    : 顧客ID(自然キー)
-- rank           : 会員ランク
-- valid_from     : このランクが有効になった日
-- valid_to       : このランクが無効になった日(現在有効なレコードはNULLまたは9999-12-31)
-- is_current     : 現在有効なレコードかどうかのフラグ(1=現在、0=過去)
surrogate_keycustomer_idrankvalid_fromvalid_tois_current
1C001一般2022-01-152022-09-300
2C001シルバー2022-10-012023-06-300
3C001ゴールド2023-07-01NULL1
4C002一般2023-03-20NULL1

C001 は「一般 → シルバー → ゴールド」と昇格してきた。valid_to = NULL のレコードが現在有効なランクだ。

この設計で「2023年1月1日時点のC001のランクは?」という問いに正確に答えられる。valid_from ≤ 2023-01-01 < valid_to または valid_to が NULL の行を探せばよく、シルバーだったことが分かる。


STEP 1 ― 現在有効なレコードを取得する

is_current = 1 を使う方法と、valid_to IS NULL を使う方法がある。

-- STEP1: 現在有効なレコードを取得する

-- 方法1: is_current フラグを使う(シンプルで高速)
SELECT
    customer_id,
    rank   AS current_rank,
    valid_from
FROM member_rank_history
WHERE is_current = 1
ORDER BY customer_id;

-- 方法2: valid_to IS NULL を使う(is_currentカラムがない場合)
SELECT
    customer_id,
    rank   AS current_rank,
    valid_from
FROM member_rank_history
WHERE valid_to IS NULL
ORDER BY customer_id;

どちらも同じ結果になる。is_current フラグは冗長だが、インデックスをかけてクエリを高速化しやすいメリットがある。大規模テーブルでは is_current = 1 のほうが WHERE 句の絞り込みが速い場合がある。


STEP 2 ― 特定時点での状態を取得する(AS-OFクエリ)

「ある時点でのランク」を取得するクエリだ。これが Type 2 SCD の最大の価値だ。

-- STEP2: 特定時点(2023-01-01)でのランクを取得する

SELECT
    customer_id,
    rank        AS rank_at_that_date,
    valid_from,
    valid_to
FROM member_rank_history
WHERE
    valid_from <= DATE '2023-01-01'  -- その日より前から有効だった
    AND (
        valid_to >  DATE '2023-01-01'  -- その日より後まで有効だった
        OR valid_to IS NULL             -- または現在も有効(有効期限なし)
    )
ORDER BY customer_id;

出力イメージ

customer_idrank_at_that_datevalid_fromvalid_to
C001シルバー2022-10-012023-06-30
C002一般2023-03-20NULL

※ C002 は 2023-03-20 に登録なので 2023-01-01 時点では存在しない。実際には行が返らない。


STEP 3 ― 注文テーブルとJOINして「注文時点のランク」を取得する(完成版)

注文日と会員ランク履歴をJOINして「この注文が発生したとき、その顧客は何ランクだったか」を求める。

-- STEP3: 注文時点の会員ランクを取得する

SELECT
    o.order_id,
    o.customer_id,
    o.order_date,
    o.total_amount,
    r.rank          AS rank_at_order_time,  -- 注文時点のランク
    r.valid_from,
    r.valid_to
FROM orders              o
JOIN member_rank_history r
    ON  o.customer_id = r.customer_id
    -- 注文日がランクの有効期間内に収まっていること
    AND r.valid_from <= o.order_date
    AND (
        r.valid_to   >  o.order_date
        OR r.valid_to IS NULL
    )
WHERE o.status = 'completed'
ORDER BY o.customer_id, o.order_date;

出力イメージ

order_idcustomer_idorder_datetotal_amountrank_at_order_time
O101C0012022-05-108,000一般
O203C0012023-01-1512,000シルバー
O341C0012023-09-2018,000ゴールド

C001 の購買履歴に、注文時点でのランクが正確に付与された。2022年5月はまだ一般会員、2023年1月はシルバー、9月はゴールドに昇格していた後の購買だ。


STEP 4 ― 新しいランクを追加するときの更新処理

Type 2 SCD では「新しいレコードを追加する前に、既存の現在レコードを終了させる」という2ステップの更新が必要だ。

-- STEP4: ランク変更時の更新処理(C001 が 2024-04-01 にプラチナに昇格)

-- Step1: 現在有効なレコードの valid_to を更新する(終了させる)
UPDATE member_rank_history
SET
    valid_to   = DATE '2024-03-31',  -- 昇格日の前日
    is_current = 0
WHERE
    customer_id = 'C001'
    AND is_current = 1;

-- Step2: 新しいランクのレコードを追加する
INSERT INTO member_rank_history
    (customer_id, rank, valid_from, valid_to, is_current)
VALUES
    ('C001', 'プラチナ', DATE '2024-04-01', NULL, 1);

UPDATE 非対応の DWH では、このステップをアプリケーション側やETLで対応し、全件を再作成するアプローチになる。

-- 代替:全件を再構築するアプローチ

CREATE TABLE member_rank_history_new AS

-- 既存レコードで、C001の現在レコードを終了させて新しい履歴を追加
SELECT
    customer_id,
    rank,
    valid_from,
    -- C001の現在レコードのvalid_toを昇格日前日に更新
    CASE
        WHEN customer_id = 'C001' AND is_current = 1
        THEN DATE '2024-03-31'
        ELSE valid_to
    END  AS valid_to,
    CASE
        WHEN customer_id = 'C001' AND is_current = 1
        THEN 0
        ELSE is_current
    END  AS is_current
FROM member_rank_history

UNION ALL

-- 新しいプラチナレコードを追加
SELECT
    'C001'          AS customer_id,
    'プラチナ'      AS rank,
    DATE '2024-04-01' AS valid_from,
    NULL            AS valid_to,
    1               AS is_current;

STEP 5 ― LEAD で valid_to を自動計算する

履歴テーブルを設計するとき、valid_to を毎回手動で設定するのは手間がかかる。valid_from だけを記録しておき、LEAD で「次のレコードの valid_from の前日」を valid_to として自動計算するアプローチも実務でよく使われる。

-- STEP5: valid_fromだけの履歴からLEADでvalid_toを計算する

-- 元テーブル(valid_from のみ)
-- member_rank_changes
-- customer_id, rank, changed_at(ランクが変わった日)

WITH rank_with_valid_to AS (
    SELECT
        customer_id,
        rank,
        changed_at                          AS valid_from,

        -- 次のレコードのchanged_atの前日がvalid_to
        DATE_ADD('day', -1,
            LEAD(changed_at) OVER (
                PARTITION BY customer_id
                ORDER BY changed_at ASC
            )
        )                                   AS valid_to,

        -- 次のレコードがない(最新)かどうか
        CASE
            WHEN LEAD(changed_at) OVER (
                PARTITION BY customer_id
                ORDER BY changed_at ASC
            ) IS NULL
            THEN 1 ELSE 0
        END                                 AS is_current
    FROM member_rank_changes
)
SELECT *
FROM rank_with_valid_to
ORDER BY customer_id, valid_from;

出力イメージ

customer_idrankvalid_fromvalid_tois_current
C001一般2022-01-152022-09-300
C001シルバー2022-10-012023-06-300
C001ゴールド2023-07-01NULL1

valid_from だけを記録する設計にしておき、LEADvalid_to を導出することで、更新のたびに手動で valid_to を埋める手間がなくなる。Treasure Data のような追記型 DWH との相性も良い。


応用:ランク別の購買金額を「注文時点のランク」で集計する

Type 2 SCD の真価が発揮されるクエリだ。「昨年ゴールド会員だったときの購買金額」を現在のランクではなく注文時点のランクで集計する。

-- 注文時点のランク別の年次購買金額集計

WITH rank_history AS (
    -- LEAD で valid_to を自動計算
    SELECT
        customer_id,
        rank,
        changed_at  AS valid_from,
        DATE_ADD('day', -1,
            LEAD(changed_at) OVER (
                PARTITION BY customer_id ORDER BY changed_at ASC
            )
        )            AS valid_to
    FROM member_rank_changes
)
SELECT
    EXTRACT(YEAR FROM o.order_date)  AS order_year,
    r.rank                           AS rank_at_order_time,
    COUNT(DISTINCT o.customer_id)    AS customer_count,
    COUNT(o.order_id)                AS order_count,
    SUM(o.total_amount)              AS total_sales,
    ROUND(AVG(o.total_amount), 0)    AS avg_order_value
FROM orders        o
JOIN rank_history  r
    ON  o.customer_id = r.customer_id
    AND r.valid_from  <= o.order_date
    AND (r.valid_to   >  o.order_date OR r.valid_to IS NULL)
WHERE o.status = 'completed'
GROUP BY EXTRACT(YEAR FROM o.order_date), r.rank
ORDER BY order_year, r.rank;

出力イメージ

order_yearrank_at_order_timecustomer_countorder_counttotal_salesavg_order_value
2023一般8,41212,841128,410,00010,000
2023シルバー2,1844,91273,680,00015,000
2023ゴールド8913,12462,480,00020,000
2023プラチナ12462118,630,00030,000

ゴールド・プラチナ会員の平均注文金額が一般会員の2〜3倍であることが「注文時点のランク」で正確に集計できた。現在のランクで集計すると、昇格・降格した顧客の影響で数字が歪む。


実務での運用ヒント

valid_to の末端値は NULL か遠い未来か統一する

valid_to = NULL(現在有効)と valid_to = DATE '9999-12-31'(遠い未来)の2つの流派がある。NULL は IS NULL チェックが必要で、9999-12-31 は比較演算子だけで処理できる。どちらかに統一してチームで合意しておかないと、クエリのたびにどちらか確認が必要になる。

valid_from = valid_to になるレコードに注意

同日に2回ランク変更が起きると valid_from = valid_to のゼロ期間レコードが生まれることがある。AS-OF クエリで valid_from <= target_date AND valid_to > target_date の条件を使っていれば問題ないが、valid_from <= target_date AND valid_to >= target_date と書くと重複して2件ヒットするケースがある。>>= かを意識して統一する。

③ surrogate_key(サロゲートキー)を必ず設ける

Type 2 SCD では同一 customer_id に複数行が存在する。customer_id だけでは一意に行を特定できないため、surrogate_key(このテーブル固有の連番ID)が必要だ。後続テーブルが surrogate_key で参照できるようにしておくと、データモデルが整合的になる。


まとめ

Type 2 SCD のポイントを振り返る。

  1. valid_from / valid_to / is_current の3カラムで有効期間を管理する
  2. 現在状態は is_current = 1 または valid_to IS NULL で取得する
  3. 特定時点の状態は valid_from <= 対象日 AND (valid_to > 対象日 OR valid_to IS NULL) で取得する(AS-OF クエリ
  4. 注文テーブルとのJOINで「注文時点の会員ランク・価格」を正確に再現できる
  5. valid_from だけ記録し LEADvalid_to を導出する設計が Treasure Data との相性が良い

「当時の情報を正確に再現できるか」はデータ基盤の品質を根本的に左右する。Type 2 SCD は実装コストが少し高いが、一度正しく設計すれば「あのときの数字をもう一度出してほしい」という要求にいつでも答えられる、信頼性の高い分析基盤の礎になる。


次のアクション

SQLやデータ活用を、手元で試しながら理解する

記事で読んだ考え方を、SQL練習場や関連カテゴリの記事でさらに深掘りできます。相談やご依頼もお問い合わせページから受け付けています。

SQL練習場で試す お問い合わせ

この記事を書いた人:martechfarm

Treasure Data Top Lapidarist Award受賞。

SQL / Digdag / Python / CDP設計 / CRM設計を横断し、企業のデータ活用を支援。

実績・支援内容を見る →

MarTech Farmをもっと見る

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

続きを読む