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

異常値・重複データをSQLで検知する方法|データ品質チェックの実務クエリ


「データが正しい」を前提にしていないか

ここまでのシリーズで、RFM分析・LTV計算・コホート分析・セグメント抽出と、さまざまな分析クエリを作ってきた。しかし実は、これらすべてに共通する大前提がある。

「入力データが正しい」という前提だ。

現実のデータは汚い。同じ顧客が複数IDで登録されている。注文金額にマイナス値が入っている。昨日まで毎日1,000件入ってきていた注文データが今日突然ゼロになっている。未来の日付で注文が登録されている。

こういったデータの異常に気づかないまま分析を続けると、間違った数字を正しいものとして経営会議に持ち込むことになる。それはSQLの問題ではなく、ビジネス上の意思決定の誤りに直結する。

データ品質の確認は「完璧なデータが来てから分析を始める」ものではない。日次・週次で自動チェックを走らせ、異常を早期に検知する習慣が必要だ。

この記事では、通販データベースで頻発する異常・重複のパターンごとに、すぐに使えるチェッククエリを紹介する。


検知すべき異常の6パターン

通販現場で実際によく遭遇する異常パターンを6つに整理した。

#パターン症状の例
1件数の急変昨日1,200件だった注文が今日10件しかない
2金額の異常値注文金額がマイナス、または1件で100万円超
3日付の異常未来日付の注文、登録日より前の購買日
4主キーの重複同じ order_id が複数行存在する
5参照整合性の破綻orders に存在する customer_id が customers に存在しない
6NULL混入必須カラムに NULL が入っている

チェック1 ― 日次件数の急変を検知する

前日比で件数が激減・激増していないかを確認する。データパイプラインの障害、ETLの設定ミス、上流システムの仕様変更などで件数が突然変わることがある。

-- チェック1: 注文件数の前日比チェック

WITH daily_count AS (
    SELECT
        order_date,
        COUNT(order_id)  AS order_count
    FROM orders
    WHERE order_date >= DATE_ADD('day', -7, CURRENT_DATE)  -- 直近7日
    GROUP BY order_date
),
with_prev AS (
    SELECT
        order_date,
        order_count,
        LAG(order_count, 1) OVER (ORDER BY order_date)  AS prev_day_count
    FROM daily_count
)
SELECT
    order_date,
    order_count,
    prev_day_count,
    -- 前日比(%)
    ROUND(
        (order_count - prev_day_count) * 100.0
        / NULLIF(prev_day_count, 0)
    , 1)                     AS day_over_day_pct,
    -- 異常フラグ:前日比 -50% 以下、または +200% 以上
    CASE
        WHEN (order_count - prev_day_count) * 100.0
             / NULLIF(prev_day_count, 0) <= -50  THEN '⚠ 急減'
        WHEN (order_count - prev_day_count) * 100.0
             / NULLIF(prev_day_count, 0) >= 200  THEN '⚠ 急増'
        ELSE '正常'
    END                      AS anomaly_flag
FROM with_prev
WHERE prev_day_count IS NOT NULL  -- 初日は比較対象なしで除外
ORDER BY order_date DESC;

出力イメージ

order_dateorder_countprev_day_countday_over_day_pctanomaly_flag
2024-12-071,2841,198+7.2正常
2024-12-061,1981,241-3.5正常
2024-12-051,24148+2485.4⚠ 急増
2024-12-04481,189-96.0⚠ 急減

12月4日に件数が激減し、翌5日に急増している。ETLバッチが4日分を5日にまとめて流し込んでしまうようなパイプライン障害が疑われる。

閾値(-50%・+200%)は自社データの特性に合わせて調整する。週末に件数が落ちる業種なら、曜日別の平均値と比較する方法もある。


チェック2 ― 金額の異常値を検知する

注文金額に関して、「ありえない値」が混入していないかを確認する。

-- チェック2: 金額異常チェック

SELECT
    '金額がゼロ以下'           AS check_type,
    COUNT(*)                   AS anomaly_count,
    MIN(total_amount)          AS min_value,
    MAX(total_amount)          AS max_value
FROM orders
WHERE total_amount <= 0
AND order_date >= DATE_ADD('day', -7, CURRENT_DATE)

UNION ALL

SELECT
    '金額が異常に高額(50万円超)'  AS check_type,
    COUNT(*)                        AS anomaly_count,
    MIN(total_amount)               AS min_value,
    MAX(total_amount)               AS max_value
FROM orders
WHERE total_amount > 500000
AND order_date >= DATE_ADD('day', -7, CURRENT_DATE)

UNION ALL

SELECT
    'total_amountがNULL'       AS check_type,
    COUNT(*)                   AS anomaly_count,
    NULL                       AS min_value,
    NULL                       AS max_value
FROM orders
WHERE total_amount IS NULL
AND order_date >= DATE_ADD('day', -7, CURRENT_DATE);

出力イメージ

check_typeanomaly_countmin_valuemax_value
金額がゼロ以下3-1,8000
金額が異常に高額(50万円超)1980,000980,000
total_amountがNULL0NULLNULL

マイナス金額は返品・キャンセルの処理ミスが疑われる。50万円超は実際にあり得る注文かもしれないが、担当者に確認する価値がある。


チェック3 ― 日付の異常を検知する

未来日付・ありえない過去日付・論理的に矛盾する日付を検知する。

-- チェック3: 日付異常チェック

SELECT
    '未来日付の注文'                AS check_type,
    COUNT(*)                        AS anomaly_count,
    MIN(order_date)                 AS min_date,
    MAX(order_date)                 AS max_date
FROM orders
WHERE order_date > CURRENT_DATE

UNION ALL

SELECT
    '登録日より前の注文日'          AS check_type,
    COUNT(*)                        AS anomaly_count,
    MIN(o.order_date)               AS min_date,
    MAX(o.order_date)               AS max_date
FROM orders     o
JOIN customers  c  ON o.customer_id = c.customer_id
WHERE o.order_date < c.registered_at  -- 登録前に注文している(論理矛盾)

UNION ALL

SELECT
    '5年以上前の注文(データ移行ミスの疑い)'  AS check_type,
    COUNT(*)                                    AS anomaly_count,
    MIN(order_date)                             AS min_date,
    MAX(order_date)                             AS max_date
FROM orders
WHERE order_date < DATE_ADD('year', -5, CURRENT_DATE);

「登録日より前の注文日」が存在する場合、システム移行時のデータ投入ミスや、顧客マスタの登録日が後から修正されたケースが多い。放置すると在籍期間の計算(LTVのSTEP2で使った tenure_days)が負の値になる。


チェック4 ― 主キーの重複を検知する

order_id は注文テーブルの主キーのはずだが、ETLやデータ統合の過程で重複が生まれることがある。重複した主キーは集計時に件数を二重カウントする原因になる。

-- チェック4: 主キー重複チェック

WITH duplicate_orders AS (
    SELECT
        order_id,
        COUNT(*)  AS row_count
    FROM orders
    GROUP BY order_id
    HAVING COUNT(*) > 1  -- 2件以上存在するorder_idだけ残す
)
SELECT
    COUNT(*)                    AS duplicate_key_count,  -- 重複しているorder_idの種類数
    SUM(row_count)              AS total_duplicate_rows, -- 重複行の総数
    MIN(row_count)              AS min_duplication,
    MAX(row_count)              AS max_duplication
FROM duplicate_orders;

さらに「どの order_id が重複しているか」の詳細も出しておく。

-- 重複しているorder_idの具体的な内容を確認する

WITH duplicate_ids AS (
    SELECT order_id
    FROM orders
    GROUP BY order_id
    HAVING COUNT(*) > 1
)
SELECT
    o.*
FROM orders         o
JOIN duplicate_ids  d  ON o.order_id = d.order_id
ORDER BY o.order_id, o.customer_id;

重複行を見比べることで「完全に同一の行か」「一部カラムだけ異なるか」を確認できる。完全同一ならETLの二重投入、一部異なるならデータ更新が正しく適用されていない可能性がある。


チェック5 ― 参照整合性の破綻を検知する

orders.customer_idcustomers テーブルに存在しない「孤立した注文」がないかを確認する。

-- チェック5: 参照整合性チェック(孤立レコード)

-- 孤立した注文(顧客マスタに存在しないcustomer_id)
SELECT
    '顧客マスタに存在しないcustomer_id'  AS check_type,
    COUNT(DISTINCT o.customer_id)         AS anomaly_count
FROM orders     o
LEFT JOIN customers  c  ON o.customer_id = c.customer_id
WHERE c.customer_id IS NULL

UNION ALL

-- 孤立した注文明細(注文テーブルに存在しないorder_id)
SELECT
    '注文テーブルに存在しないorder_id'  AS check_type,
    COUNT(DISTINCT oi.order_id)          AS anomaly_count
FROM order_items  oi
LEFT JOIN orders  o  ON oi.order_id = o.order_id
WHERE o.order_id IS NULL

UNION ALL

-- 孤立したクーポン使用(クーポンマスタに存在しないcoupon_id)
SELECT
    'クーポンマスタに存在しないcoupon_id'  AS check_type,
    COUNT(*)                                AS anomaly_count
FROM orders
WHERE coupon_id IS NOT NULL
AND coupon_id NOT IN (SELECT coupon_id FROM coupons);

出力イメージ

check_typeanomaly_count
顧客マスタに存在しないcustomer_id12
注文テーブルに存在しないorder_id0
クーポンマスタに存在しないcoupon_id3

顧客マスタに存在しないcustomer_idが12件ある。退会処理でマスタを物理削除したが注文履歴が残っている、あるいはデータ統合時にIDが変換されなかったケースが考えられる。これらの12件はRFM分析やLTV計算から除外されるか、誤った値を返す。


チェック6 ― 必須カラムのNULL混入を検知する

ビジネス上「NULL はありえない」カラムを一括でチェックする。

-- チェック6: 必須カラムのNULLチェック(直近7日分)

SELECT
    'orders.customer_id'          AS column_name,
    COUNT(*) FILTER (WHERE customer_id IS NULL)   AS null_count,
    COUNT(*)                                       AS total_count
FROM orders
WHERE order_date >= DATE_ADD('day', -7, CURRENT_DATE)

UNION ALL

SELECT
    'orders.order_date',
    COUNT(*) FILTER (WHERE order_date IS NULL),
    COUNT(*)
FROM orders
WHERE created_at >= DATE_ADD('day', -7, CURRENT_DATE)

UNION ALL

SELECT
    'orders.total_amount',
    COUNT(*) FILTER (WHERE total_amount IS NULL),
    COUNT(*)
FROM orders
WHERE order_date >= DATE_ADD('day', -7, CURRENT_DATE)

UNION ALL

SELECT
    'orders.status',
    COUNT(*) FILTER (WHERE status IS NULL),
    COUNT(*)
FROM orders
WHERE order_date >= DATE_ADD('day', -7, CURRENT_DATE);

FILTER (WHERE ...) 句について
COUNT(*) FILTER (WHERE 条件) は「条件を満たす行だけをカウントする」構文だ。Treasure DataのPrestoでサポートされている。MySQLなど一部の環境では SUM(CASE WHEN column IS NULL THEN 1 ELSE 0 END) と書き換えればよい。


すべてを1本の日次ヘルスチェッククエリにまとめる

上記の6チェックをまとめて実行し、異常がある項目だけを返すサマリクエリを作る。これを毎朝自動実行してSlackに通知する仕組みにすると、データ品質の監視が自動化できる。

-- 日次データ品質ヘルスチェック(サマリ版)

WITH checks AS (

    -- チェック1: 昨日の注文件数が一昨日から50%以上減少
    SELECT
        'order_count_drop'       AS check_name,
        '注文件数の急減(前日比-50%以下)'  AS description,
        CASE
            WHEN (
                SELECT COUNT(*) FROM orders
                WHERE order_date = DATE_ADD('day', -1, CURRENT_DATE)
            ) * 1.0
            / NULLIF(
                SELECT COUNT(*) FROM orders
                WHERE order_date = DATE_ADD('day', -2, CURRENT_DATE)
            , 0) < 0.5
            THEN 1 ELSE 0
        END  AS is_anomaly

    UNION ALL

    -- チェック2: 金額がゼロ以下の注文
    SELECT
        'negative_amount',
        '金額ゼロ以下の注文あり',
        CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
    FROM orders
    WHERE total_amount <= 0
    AND order_date >= DATE_ADD('day', -1, CURRENT_DATE)

    UNION ALL

    -- チェック3: 未来日付の注文
    SELECT
        'future_date',
        '未来日付の注文あり',
        CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
    FROM orders
    WHERE order_date > CURRENT_DATE

    UNION ALL

    -- チェック4: 主キー重複
    SELECT
        'duplicate_order_id',
        '注文IDの重複あり',
        CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
    FROM (
        SELECT order_id FROM orders
        GROUP BY order_id HAVING COUNT(*) > 1
    )

    UNION ALL

    -- チェック5: 孤立した注文
    SELECT
        'orphan_orders',
        '顧客マスタに存在しない注文あり',
        CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
    FROM orders     o
    LEFT JOIN customers  c  ON o.customer_id = c.customer_id
    WHERE c.customer_id IS NULL
    AND o.order_date >= DATE_ADD('day', -1, CURRENT_DATE)

    UNION ALL

    -- チェック6: 必須カラムのNULL
    SELECT
        'null_in_required_columns',
        'customer_idまたはtotal_amountにNULLあり',
        CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
    FROM orders
    WHERE (customer_id IS NULL OR total_amount IS NULL)
    AND order_date >= DATE_ADD('day', -1, CURRENT_DATE)
)
SELECT
    check_name,
    description,
    CASE WHEN is_anomaly = 1 THEN '🚨 異常検知' ELSE '✅ 正常' END  AS status,
    CURRENT_DATE  AS checked_at
FROM checks
ORDER BY is_anomaly DESC, check_name;

出力イメージ

check_namedescriptionstatuschecked_at
negative_amount金額ゼロ以下の注文あり🚨 異常検知2024-12-07
order_count_drop注文件数の急減(前日比-50%以下)✅ 正常2024-12-07
orphan_orders顧客マスタに存在しない注文あり✅ 正常2024-12-07
duplicate_order_id注文IDの重複あり✅ 正常2024-12-07
future_date未来日付の注文あり✅ 正常2024-12-07
null_in_required_columnscustomer_idまたはtotal_amountにNULLあり✅ 正常2024-12-07

「🚨 異常検知」が1行でも出たらSlackに通知する設定にしておけば、朝イチで問題に気づける。


実務での運用ヒント

① Treasure DataのdigdagでSlack通知を自動化する

このヘルスチェッククエリをdigdagで毎朝6時に実行し、is_anomaly = 1 の行が1件でもあればSlackのWebhookに通知するワークフローが組める。分析担当者が毎朝手動でチェックする手間が完全になくなる。

② 閾値はデータを見ながら育てる

最初から「前日比-50%で異常」という閾値が正しいとは限らない。自社のデータで過去の正常・異常を振り返り、誤検知(正常なのに異常と判定)と見逃し(異常なのに正常と判定)のバランスを調整していく。

③ テーブルごとにチェック範囲を分ける

注文テーブルは「直近1日」、マスタテーブルは「全件」、ログテーブルは「直近7日」など、テーブルの更新頻度と規模に合わせてチェック範囲を変えると、クエリの実行コストを抑えられる。


まとめ

今回紹介した6つのチェックパターンを振り返る。

  1. 件数の急変 — LAGで前日比を計算し、閾値を超えたら異常フラグを立てる
  2. 金額の異常値 — ゼロ以下・上限超え・NULLをUNION ALLでまとめてチェックする
  3. 日付の異常 — 未来日付・論理矛盾・極端な過去日付を検知する
  4. 主キーの重複 — GROUP BY + HAVING COUNT(*) > 1 で特定する
  5. 参照整合性の破綻 — LEFT JOIN + IS NULL で孤立レコードを抽出する
  6. NULL混入 — COUNT(*) FILTER (WHERE IS NULL) で必須カラムを一括確認する

どれだけ精緻な分析クエリを作っても、入力データが壊れていれば出てくる数字は信用できない。データ品質のチェックは「ついでにやること」ではなく、分析の精度を守るための基盤作業だ。


次のアクション

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

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

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

この記事を書いた人:martechfarm

Treasure Data Top Lapidarist Award受賞。

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

実績・支援内容を見る →

MarTech Farmをもっと見る

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

続きを読む