本文へ移動
顧客分析SQL 更新日: 2026年5月2日 約19分で読めます

連続購買をSQLで分析する方法|何ヶ月買い続けているかを判定するクエリ


「連続」を数えることの難しさ

「3ヶ月連続購買キャンペーン」「6ヶ月継続でゴールド会員昇格」
通販では顧客の継続購買を促すロイヤリティ施策がよく使われる。

このとき必ず必要になるのが「今この顧客は何ヶ月連続で買っているか」を計算するクエリだ。

一見シンプルに見えるが、これは SQL の中でも難問の部類に入る。なぜか。

「連続」とは「途切れていないこと」だ。しかしデータには「買った月」しか記録されていない。「買わなかった月」はデータに存在しない。存在しないものを使って「途切れた」かどうかを判定するのが難しさの正体だ。

この問題は GAP and ISLAND 問題と呼ばれる。データが連続している区間(Island)と、途切れている区間(Gap)を特定するテクニックだ。SQL の応用問題としてデータエンジニアの採用面接でも頻出する。


GAP and ISLAND 問題とは

まず概念をイメージしよう。

ある顧客の購買月が次のようだったとする。

2024-01, 2024-02, 2024-03, (空白), 2024-05, 2024-06, (空白), 2024-08

これを「連続している区間(Island)」で区切ると

Island 1: 2024-01 〜 2024-03(3ヶ月連続)
Island 2: 2024-05 〜 2024-06(2ヶ月連続)
Island 3: 2024-08 〜 2024-08(1ヶ月のみ)

「Gap(空白)」は 2024-04 と 2024-07 だ。

この Island を SQL で検出することが今回のゴールだ。


使用するテーブル

-- orders テーブル
-- order_id     : 注文ID
-- customer_id  : 顧客ID
-- order_date   : 注文日(DATE型)
-- total_amount : 注文金額
-- status       : 'completed' / 'cancelled'

STEP 1 ― 顧客×月の購買有無を整理する

まず「この顧客はこの月に買ったか」を月単位で1行にまとめる。
同じ月に複数回買っていても「その月は購買した」という1行にする。

-- STEP1: 顧客×購買月の一覧(月次ユニーク化)

WITH monthly_purchases AS (
    SELECT DISTINCT
        customer_id,
        DATE_TRUNC('month', order_date)  AS purchase_month
    FROM orders
    WHERE status = 'completed'
)
SELECT *
FROM monthly_purchases
ORDER BY customer_id, purchase_month;

出力イメージ(顧客 C001 の例)

customer_idpurchase_month
C0012024-01-01
C0012024-02-01
C0012024-03-01
C0012024-05-01
C0012024-06-01
C0012024-08-01

2024-04 と 2024-07 が存在しないことが Gap だ。


STEP 2 ― Island を特定する核心ロジック

ここが GAP and ISLAND 問題の核心だ。

考え方:「連続する月」は ROW_NUMBER との差が一定になる

購買月を昇順に並べて ROW_NUMBER を振ると、連続している月では「購買月 – ROW_NUMBER(月単位)」が常に同じ値になる。途切れた瞬間にこの差がずれる。

具体的に見てみよう。

purchase_monthROW_NUMBER月番号月番号 – ROW_NUMBER
2024-01110
2024-02220
2024-03330
2024-05451 ← ここで変わる
2024-06561
2024-08682 ← またずれる

「月番号 – ROW_NUMBER」が同じ値のグループが、そのまま Island(連続購買区間)になる。

この値をグループキーとして使うことで、Island を特定できる。

-- STEP2: Island グループキーを計算する

WITH monthly_purchases AS (
    SELECT DISTINCT
        customer_id,
        DATE_TRUNC('month', order_date)  AS purchase_month
    FROM orders
    WHERE status = 'completed'
),
with_group_key AS (
    SELECT
        customer_id,
        purchase_month,

        -- 購買月を「月番号」に変換(基準日からの月数)
        DATE_DIFF('month', DATE '2020-01-01', purchase_month)  AS month_num,

        -- 顧客ごとに購買月の連番
        ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY purchase_month
        )  AS rn,

        -- グループキー = 月番号 - 連番(連続している間は一定)
        DATE_DIFF('month', DATE '2020-01-01', purchase_month)
        - ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY purchase_month
        )  AS group_key
    FROM monthly_purchases
)
SELECT *
FROM with_group_key
ORDER BY customer_id, purchase_month;

出力イメージ(顧客 C001)

customer_idpurchase_monthmonth_numrngroup_key
C0012024-01-0148147
C0012024-02-0149247
C0012024-03-0150347
C0012024-05-0152448
C0012024-06-0153548
C0012024-08-0155649

group_key が同じ行が Island(連続購買区間)だ。


STEP 3 ― Island ごとに開始・終了・連続月数を集計する

group_key でグループ化して、各 Island の開始月・終了月・連続月数を出す。

-- STEP3: 連続購買区間(Island)ごとのサマリ

WITH monthly_purchases AS (
    SELECT DISTINCT
        customer_id,
        DATE_TRUNC('month', order_date)  AS purchase_month
    FROM orders
    WHERE status = 'completed'
),
with_group_key AS (
    SELECT
        customer_id,
        purchase_month,
        DATE_DIFF('month', DATE '2020-01-01', purchase_month)
        - ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY purchase_month
        )  AS group_key
    FROM monthly_purchases
)
SELECT
    customer_id,
    MIN(purchase_month)  AS streak_start,   -- 連続購買の開始月
    MAX(purchase_month)  AS streak_end,     -- 連続購買の終了月
    COUNT(*)             AS streak_months   -- 連続購買の月数
FROM with_group_key
GROUP BY customer_id, group_key
ORDER BY customer_id, streak_start;

出力イメージ

customer_idstreak_startstreak_endstreak_months
C0012024-01-012024-03-013
C0012024-05-012024-06-012
C0012024-08-012024-08-011
C0022024-02-012024-07-016
C0032024-01-012024-01-011

C001 の最長ストリークは3ヶ月、C002 は6ヶ月連続購買していることが分かる。


STEP 4 ― 現在進行中のストリークと最長ストリークを出す(完成版)

施策で使いたいのは「今この瞬間、何ヶ月連続購買中か」という現在のストリークと、「過去の最長記録は何ヶ月か」の2つだ。

-- STEP4: 現在ストリーク・最長ストリーク(完成版)

WITH monthly_purchases AS (
    SELECT DISTINCT
        customer_id,
        DATE_TRUNC('month', order_date)  AS purchase_month
    FROM orders
    WHERE status = 'completed'
),
with_group_key AS (
    SELECT
        customer_id,
        purchase_month,
        DATE_DIFF('month', DATE '2020-01-01', purchase_month)
        - ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY purchase_month
        )  AS group_key
    FROM monthly_purchases
),
islands AS (
    SELECT
        customer_id,
        MIN(purchase_month)  AS streak_start,
        MAX(purchase_month)  AS streak_end,
        COUNT(*)             AS streak_months
    FROM with_group_key
    GROUP BY customer_id, group_key
),
streak_summary AS (
    SELECT
        customer_id,
        -- 最長ストリーク
        MAX(streak_months)  AS longest_streak,

        -- 現在のストリーク:直前の購買月が「先月」であるIslandのみ
        -- 基準日の前月と streak_end が一致しているものが「現在進行中」
        MAX(
            CASE
                WHEN streak_end = DATE_TRUNC('month', DATE_ADD('month', -1, CURRENT_DATE))
                THEN streak_months
                ELSE 0
            END
        )  AS current_streak,

        -- 現在ストリークの開始月
        MIN(
            CASE
                WHEN streak_end = DATE_TRUNC('month', DATE_ADD('month', -1, CURRENT_DATE))
                THEN streak_start
                ELSE NULL
            END
        )  AS current_streak_start
    FROM islands
    GROUP BY customer_id
)
SELECT
    customer_id,
    current_streak,
    current_streak_start,
    longest_streak,
    -- キャンペーン対象フラグ(例:3ヶ月連続達成)
    CASE WHEN current_streak >= 3 THEN 1 ELSE 0 END  AS campaign_eligible
FROM streak_summary
ORDER BY current_streak DESC, longest_streak DESC;

完成した出力イメージ

customer_idcurrent_streakcurrent_streak_startlongest_streakcampaign_eligible
C00262024-02-0161
C00442024-04-0141
C0010NULL30
C00312024-08-0110

C002 は現在6ヶ月連続継続中でキャンペーン対象。C001 は過去最長3ヶ月の記録があるが今月は購買していないため現在ストリークは0だ。

「先月」を基準にする理由
「今月購買したか」は月が終わるまで確定しない。そのため基準を「先月末時点でのストリーク」とするのが現実的だ。月次バッチで毎月初旬に実行する設計に合わせてある。当月を含めたい場合は DATE_ADD('month', -1, CURRENT_DATE)CURRENT_DATE に変えればよい。


応用:ストリーク別の顧客分布を出す

どの長さのストリークの顧客が何人いるかを把握すると、キャンペーン閾値の設計に使える。

-- ストリーク長の分布

WITH monthly_purchases AS (
    SELECT DISTINCT
        customer_id,
        DATE_TRUNC('month', order_date)  AS purchase_month
    FROM orders
    WHERE status = 'completed'
),
with_group_key AS (
    SELECT
        customer_id,
        purchase_month,
        DATE_DIFF('month', DATE '2020-01-01', purchase_month)
        - ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY purchase_month
        )  AS group_key
    FROM monthly_purchases
),
islands AS (
    SELECT
        customer_id,
        MAX(purchase_month)  AS streak_end,
        COUNT(*)             AS streak_months
    FROM with_group_key
    GROUP BY customer_id, group_key
),
current_streaks AS (
    SELECT
        customer_id,
        MAX(CASE
            WHEN streak_end = DATE_TRUNC('month', DATE_ADD('month', -1, CURRENT_DATE))
            THEN streak_months ELSE 0
        END)  AS current_streak
    FROM islands
    GROUP BY customer_id
)
SELECT
    current_streak         AS streak_length,
    COUNT(customer_id)     AS customer_count,
    ROUND(
        COUNT(customer_id) * 100.0
        / SUM(COUNT(customer_id)) OVER ()
    , 1)                   AS pct
FROM current_streaks
GROUP BY current_streak
ORDER BY current_streak DESC;

出力イメージ

streak_lengthcustomer_countpct
0(今月未購買)8,41262.3
12,84121.0
21,0247.6
35844.3
43122.3
51481.1
6以上1891.4

「3ヶ月連続達成」のキャンペーン対象は全体の約9%(4+5+6以上)だと分かる。「2ヶ月連続の顧客(7.6%)」はあと1ヶ月でキャンペーン達成できる層なので、背中を押す施策の対象として最適だ。


実務での運用ヒント

① ストリーク計算の基準を「月」から「週」や「日」に変える

今回は月次購買のストリークを扱ったが、週次や日次に変えたい場合は DATE_TRUNC('month', ...)DATE_TRUNC('week', ...)order_date(日付そのまま)に変え、DATE_DIFF の単位も 'month' から 'week' / 'day' に変えるだけで同じロジックが動く。

② 「○ヶ月連続でキャンペーン達成後にまた連続中」の顧客を区別する

同じ顧客が複数の Island を持つ場合、過去に一度キャンペーン達成した人が再び連続購買しているケースも拾える。islands テーブルを使えばすべての連続区間が揃っているので、「最長が○ヶ月以上かつ現在も継続中」のような複合条件も CASE WHEN で表現できる。

③ Treasure Data での注意点

DATE_DIFF('month', DATE '2020-01-01', purchase_month) の基準日(DATE '2020-01-01')は、データの中で最も古い購買月より前であれば何でもよい。Presto では月番号の計算が整数として返るため、ROW_NUMBER との差引きがそのまま使える。


まとめ

GAP and ISLAND 問題を解くカギは、「月番号 − ROW_NUMBER = グループキー」 という1つの式だ。

  1. DATE_TRUNC('month', ...)DISTINCT で月次購買フラグを作る
  2. DATE_DIFF('month', ...) で月番号に変換し、ROW_NUMBER との差を group_key にする
  3. group_key でグループ化して MIN / MAX / COUNT で Island の開始・終了・長さを出す
  4. 現在進行中のストリークは「streak_end が先月と一致するか」で判定する

この発想は購買だけでなく、「ログイン連続日数」「メルマガ開封連続回数」「レビュー投稿連続週数」など、あらゆる連続行動の分析に応用できる。

一度パターンを掴んでしまえば、幅広い場面で使い回せる強力な技法だ。


次のアクション

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

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

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

この記事を書いた人:martechfarm

Treasure Data Top Lapidarist Award受賞。

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

実績・支援内容を見る →

MarTech Farmをもっと見る

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

続きを読む