本文へ移動
今すぐ使えるSQLレシピ 更新日: 2026年4月30日 約22分で読めます

クーポン施策の効果をSQLで検証する方法|割引の売上貢献を正しく測る


クーポンは「売上を生んでいる」のか「売上を前借りしている」のか

クーポンを発行すると、たいていその週の売上は上がる。でもこの問いに答えられるだろうか。

「そのクーポンがなかったとしても、その顧客は買っていたのではないか」

クーポンの本当のコストは「割引額」だけではない。クーポンがなくても買う予定だった顧客への割引は、純粋なコストだ。逆に「クーポンがあったから初めて買った」顧客への割引は、新規獲得コストとして正当化できる。

この2つを混同したまま「クーポンの効果」を語っても意味がない。

この記事では、クーポンの使用率・回収率・実質的な売上貢献額をSQLで計算し、クーポン施策が本当に利益に貢献しているかを検証するクエリを段階的に作っていく。


クーポン分析で見るべき5つの指標

クエリを書く前に、何を測るかを整理しておく。

指標計算式意味
発行数発行したクーポンの総数施策のリーチ規模
使用率使用数 ÷ 発行数クーポンが使われた割合
使用時売上クーポン使用注文の合計金額(割引前)紐づいた総売上
割引総額使用されたクーポンの割引額合計実際のコスト
回収率使用時売上 ÷ 割引総額1円の割引が何円の売上を生んだか

回収率が最重要指標だ。回収率10倍なら「100円の割引が1,000円の売上を生んでいる」ことを意味する。回収率が1倍を下回れば割引コストが売上を上回っており、施策として成立していない。


使用するテーブル

今回は3つのテーブルを使う。

-- coupons(クーポンマスタ)
-- coupon_id        : クーポンID(主キー)
-- coupon_code      : クーポンコード
-- campaign_id      : 紐づくキャンペーンID
-- discount_type    : 'fixed'(固定額)/ 'rate'(割引率)
-- discount_value   : 割引額(fixed)または割引率(rate、0〜1の小数)
-- issued_at        : 発行日時
-- customer_id      : 発行対象の顧客ID(NULL なら汎用クーポン)
-- expires_at       : 有効期限
-- orders(注文テーブル)
-- order_id         : 注文ID
-- customer_id      : 顧客ID
-- order_date       : 注文日
-- total_amount     : 注文金額(割引後)
-- original_amount  : 注文金額(割引前)
-- coupon_id        : 使用したクーポンID(NULL なら未使用)
-- status           : 'completed' / 'cancelled'
-- campaigns(キャンペーンマスタ)
-- campaign_id      : キャンペーンID
-- campaign_name    : キャンペーン名
-- start_date       : 開始日
-- end_date         : 終了日

STEP 1 ― クーポン使用状況の基礎集計

まずクーポンごとに「発行されたか」「使われたか」を1行にまとめる。

-- STEP1: クーポンの発行・使用状況を把握する

WITH coupon_usage AS (
    SELECT
        c.coupon_id,
        c.coupon_code,
        c.campaign_id,
        c.discount_type,
        c.discount_value,
        c.customer_id          AS issued_to,
        CAST(c.issued_at AS DATE)   AS issued_date,
        CAST(c.expires_at AS DATE)  AS expires_date,

        -- 注文テーブルと紐づける(使用された注文)
        o.order_id,
        o.order_date,
        o.original_amount,
        o.total_amount,

        -- 割引額を計算(fixedはそのまま、rateは元値から計算)
        CASE
            WHEN c.discount_type = 'fixed'
                THEN c.discount_value
            WHEN c.discount_type = 'rate'
                THEN ROUND(o.original_amount * c.discount_value, 0)
            ELSE 0
        END  AS discount_amount,

        -- 使用フラグ
        CASE WHEN o.order_id IS NOT NULL THEN 1 ELSE 0 END  AS is_used
    FROM coupons  c
    LEFT JOIN orders  o
        ON  c.coupon_id  = o.coupon_id
        AND o.status     = 'completed'
)
SELECT *
FROM coupon_usage
ORDER BY issued_date, coupon_id;

出力イメージ

coupon_codediscount_typediscount_valueorder_idoriginal_amountdiscount_amountis_used
SUMMER10rate0.1010898,0008001
SUMMER10rate0.10NULLNULLNULL0
WINTER500fixed50011025,5005001
WINTER500fixed500NULLNULLNULL0
VIP2000fixed2000114518,0002,0001

STEP 2 ― キャンペーン別の使用率・回収率を集計する

STEP1 のデータを使って、キャンペーン単位に集約する。

-- STEP2: キャンペーン別クーポン効果サマリ

WITH coupon_usage AS (
    SELECT
        c.coupon_id,
        c.campaign_id,
        CAST(c.issued_at AS DATE)  AS issued_date,
        o.order_id,
        o.order_date,
        o.original_amount,
        o.total_amount,
        CASE
            WHEN c.discount_type = 'fixed' THEN c.discount_value
            WHEN c.discount_type = 'rate'  THEN ROUND(o.original_amount * c.discount_value, 0)
            ELSE 0
        END  AS discount_amount,
        CASE WHEN o.order_id IS NOT NULL THEN 1 ELSE 0 END  AS is_used
    FROM coupons  c
    LEFT JOIN orders  o
        ON  c.coupon_id = o.coupon_id
        AND o.status    = 'completed'
)
SELECT
    cu.campaign_id,
    ca.campaign_name,

    -- 発行数・使用数・使用率
    COUNT(cu.coupon_id)                              AS issued_count,
    SUM(cu.is_used)                                  AS used_count,
    ROUND(SUM(cu.is_used) * 100.0
        / NULLIF(COUNT(cu.coupon_id), 0), 1)         AS usage_rate_pct,

    -- 売上・割引額
    COALESCE(SUM(cu.original_amount), 0)             AS gross_sales,
    COALESCE(SUM(cu.discount_amount), 0)             AS total_discount,
    COALESCE(SUM(cu.total_amount), 0)                AS net_sales,

    -- 回収率(売上 ÷ 割引額)
    ROUND(
        COALESCE(SUM(cu.original_amount), 0) * 1.0
        / NULLIF(COALESCE(SUM(cu.discount_amount), 0), 0)
    , 1)                                             AS recovery_rate,

    -- 使用1件あたりの平均注文金額(割引前)
    ROUND(
        COALESCE(SUM(cu.original_amount), 0)
        / NULLIF(SUM(cu.is_used), 0)
    , 0)                                             AS avg_order_value_per_use

FROM coupon_usage      cu
JOIN campaigns         ca  ON cu.campaign_id = ca.campaign_id
GROUP BY cu.campaign_id, ca.campaign_name
ORDER BY recovery_rate DESC;

完成した出力イメージ

campaign_nameissued_countused_countusage_rate_pctgross_salestotal_discountrecovery_rate
誕生日クーポン2000円OFF89253459.912,816,0001,068,00012.0
VIP限定10%OFF1,24086870.018,228,0001,822,80010.0
新規登録500円OFF3,8401,34435.010,752,000672,00016.0
全顧客一律15%OFF18,2005,46030.032,760,0004,914,0006.7

回収率の差が歴然だ。

「新規登録500円OFF」は回収率16倍で、1円の割引が16円の売上を生んでいる。「全顧客一律15%OFF」は回収率6.7倍と低く、しかも割引総額が約490万円と最大だ。「全顧客に一律割引」という設計の非効率さが数字に出ている。


STEP 3 ― 「クーポンがなくても買った顧客」を除いたネット効果を測る

ここからが本記事の核心だ。

クーポン使用顧客の中には「クーポンがなくても買う予定だった顧客」が必ず混在する。それを取り除いてクーポンによる純増売上を推計する。

考え方はシンプルだ。クーポン使用者の中で「過去30日以内に購買歴がある顧客」は、クーポンがなくても近いうちに購買した可能性が高い。これをコントロールグループ的な視点で分離する。

-- STEP3: クーポン起因の「新規行動」と「元々買う予定だった行動」を分離

WITH coupon_usage AS (
    SELECT
        c.coupon_id,
        c.campaign_id,
        c.customer_id       AS issued_to,
        o.order_id,
        o.customer_id       AS purchaser_id,
        o.order_date,
        o.original_amount,
        CASE
            WHEN c.discount_type = 'fixed' THEN c.discount_value
            WHEN c.discount_type = 'rate'  THEN ROUND(o.original_amount * c.discount_value, 0)
            ELSE 0
        END  AS discount_amount,
        CASE WHEN o.order_id IS NOT NULL THEN 1 ELSE 0 END  AS is_used
    FROM coupons  c
    LEFT JOIN orders  o
        ON  c.coupon_id = o.coupon_id
        AND o.status    = 'completed'
),
-- クーポン使用日の30日前までに購買歴があるかを確認
prior_purchase_check AS (
    SELECT
        cu.coupon_id,
        cu.order_id,
        cu.purchaser_id,
        cu.order_date,
        cu.original_amount,
        cu.discount_amount,
        -- 30日以内に購買歴があれば「元々買う予定だった可能性が高い」
        CASE
            WHEN EXISTS (
                SELECT 1
                FROM orders  prev
                WHERE prev.customer_id = cu.purchaser_id
                AND prev.status        = 'completed'
                AND prev.order_date    >= DATE_ADD('day', -30, cu.order_date)
                AND prev.order_date    <  cu.order_date
            ) THEN '直近購買あり(自然購買の可能性)'
            ELSE   '直近購買なし(クーポン誘引の可能性)'
        END  AS purchase_likelihood
    FROM coupon_usage  cu
    WHERE cu.is_used = 1
)
SELECT
    purchase_likelihood,
    COUNT(order_id)              AS order_count,
    SUM(original_amount)         AS gross_sales,
    SUM(discount_amount)         AS total_discount,
    ROUND(AVG(original_amount), 0)  AS avg_order_value
FROM prior_purchase_check
GROUP BY purchase_likelihood
ORDER BY purchase_likelihood;

出力イメージ

purchase_likelihoodorder_countgross_salestotal_discountavg_order_value
直近購買あり(自然購買の可能性)8239,876,0001,481,40012,000
直近購買なし(クーポン誘引の可能性)5218,336,0001,250,40016,000

クーポン使用者1,344人のうち、823人(61%)はクーポンがなくても近いうちに購買していた可能性が高い。この823人への割引148万円は「もったいない割引」だったかもしれない。

一方、521人(39%)はクーポンが引き金になって買ったと推定でき、こちらへの割引125万円は新規購買を獲得するコストとして正当化できる。

注意:これはあくまで近似推計

「直近30日以内に購買あり」=「クーポンがなくても買った」とは断定できない。正確な検証にはランダムに配信しなかったコントロールグループとの比較(A/Bテスト)が必要だ。このクエリは「傾向を掴む」ための参考指標として使ってほしい。


STEP 4 ― クーポン種別・割引額帯別のROI分析

発行数が多いキャンペーンでは、割引額の大きさによって購買行動が変わることがある。割引額を帯(バケット)に分けて比較する。

-- STEP4: 割引額帯別のROI比較

WITH coupon_usage AS (
    SELECT
        c.coupon_id,
        c.discount_type,
        o.order_id,
        o.original_amount,
        CASE
            WHEN c.discount_type = 'fixed' THEN c.discount_value
            WHEN c.discount_type = 'rate'  THEN ROUND(o.original_amount * c.discount_value, 0)
            ELSE 0
        END  AS discount_amount,
        CASE WHEN o.order_id IS NOT NULL THEN 1 ELSE 0 END  AS is_used
    FROM coupons  c
    LEFT JOIN orders  o
        ON  c.coupon_id = o.coupon_id
        AND o.status    = 'completed'
),
with_bucket AS (
    SELECT
        *,
        CASE
            WHEN discount_amount < 500          THEN '〜499円'
            WHEN discount_amount < 1000         THEN '500〜999円'
            WHEN discount_amount < 2000         THEN '1000〜1999円'
            WHEN discount_amount < 5000         THEN '2000〜4999円'
            ELSE                                     '5000円以上'
        END  AS discount_bucket
    FROM coupon_usage
    WHERE is_used = 1
)
SELECT
    discount_bucket,
    COUNT(order_id)                            AS order_count,
    ROUND(AVG(original_amount), 0)             AS avg_order_value,
    ROUND(AVG(discount_amount), 0)             AS avg_discount,
    ROUND(AVG(original_amount) * 1.0
        / NULLIF(AVG(discount_amount), 0), 1)  AS avg_recovery_rate,
    SUM(original_amount)                       AS total_sales,
    SUM(discount_amount)                       AS total_discount
FROM with_bucket
GROUP BY discount_bucket
ORDER BY
    CASE discount_bucket
        WHEN '〜499円'      THEN 1
        WHEN '500〜999円'   THEN 2
        WHEN '1000〜1999円' THEN 3
        WHEN '2000〜4999円' THEN 4
        ELSE                    5
    END;

出力イメージ

discount_bucketorder_countavg_order_valueavg_discountavg_recovery_rate
〜499円4124,80032015.0
500〜999円6878,20068012.1
1000〜1999円53414,4001,28011.3
2000〜4999円28922,0002,8007.9
5000円以上7838,0006,2006.1

割引額が大きいほど回収率が下がる傾向が見える。大型割引は高単価顧客を動かしているが、割引コストが売上に対して重くなってきている。ただし絶対額の総売上は2000〜4999円帯が高く、施策の「量」と「効率」のトレードオフをここで検討する。


実務での運用ヒント

① 有効期限切れクーポンの発行数除外

発行したが有効期限内に使われなかったクーポンは、使用率の分母に含めるかどうかで数字が変わる。「1年有効のクーポンを先月発行した」ならまだ使われる可能性があるので分母から除いたほうが正確だ。expires_at > CURRENT_DATE でフィルタする方法がある。

② 同一注文への複数クーポン適用

ECシステムによっては1注文に複数クーポンを適用できる場合がある。その場合 orders.coupon_id が1つしか持てない設計だと片方しか記録されない。クーポン使用ログを別テーブル(order_coupons)で持つ設計が理想的だ。

③ クーポン未使用者への購買フォロー

発行したが使用していないクーポンの所有者は「興味はあるが行動していない顧客」の可能性がある。有効期限3日前に「もうすぐ期限です」のリマインドメールを送るセグメントリストは、このクエリの is_used = 0 AND expires_at > CURRENT_DATE で簡単に抽出できる。


まとめ

今回のクエリの流れを振り返る。

  1. couponsorderscoupon_id でLEFT JOINし、発行・使用の両方を1行に持つデータを作る
  2. discount_type に応じて割引額を計算(固定額 vs 割引率)する
  3. キャンペーン別に使用率・回収率を集計して施策の費用対効果を比較する
  4. クーポン使用者の中で「直近購買あり」と「なし」を分けて純増効果を近似推計する
  5. 割引額帯別に回収率を比較して最適な割引設計を検討する

クーポンは「発行すれば売上が上がる」という幻想から脱して、「どの顧客に」「いくらの割引を」「どのタイミングで」配布すれば利益が最大化するかを設計する時代に入っている。そのための判断材料をSQLで作ることが、このクエリの本質的な価値だ。


次のアクション

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

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

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

この記事を書いた人:martechfarm

Treasure Data Top Lapidarist Award受賞。

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

実績・支援内容を見る →

MarTech Farmをもっと見る

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

続きを読む