【2025年最新版】GA4×BigQueryセッションのズレ補正!検証結果と全SQLクエリ公開

本記事は、下記の実践ガイド記事の続編です:
👉 【後編】GA4×BigQuery セッションのズレを補正する実践ガイド

前回の記事では、GA4とBigQuery間で発生するセッション数のズレや、チャネル情報(default_channel_group)の不一致について、その背景や構造的な違いを解説し、代表的な補正SQLを紹介しました。

今回はその実装を踏まえ、実際にどこまでズレを補正できるか?を検証してみました。
使用したのは、ChatGPTに組んでもらった4種類のSQLクエリです。
(※使用したSQLクエリ全文は本記事の最後に記載しています)

⚠️ 2025/4/20追記
2024年10月9日から「セッションのデフォルトチャネルグループ」のフィールドがBigQueryに追加されていたことが分かったため、クエリ(4)を追加し、再度検証を行いました

🧪 今回の検証でわかったこと(結論)

観点結果補註
✅ セッション数補正成功GA4 UIに近い値が得られた
△ チャネル分類補正❌ 部分失敗【クエリ(1)〜(3)】
クエリ(4) は完全一致
クエリ(1)〜(3)は、Direct / Organic Social などでズレあり

🔍 どのクエリを使えばよい?

要件最適クエリ
GA4画面と同じ分類・数値が欲しい
※2024年10月9日〜のデータのみ使用可
✅ クエリ(4)
※新フィールド「session_traffic_source_last_click」と「default_channel_group」を使用
GA4画面と少しでも近い数値が欲しい
2024年7月17日〜10月9日の間のデータのみ使用可
✅ クエリ(3)
※新フィールド「session_traffic_source_last_click」を使用
GA4画面と少しでも近い数値が欲しい
2024年7月16日以前のデータにも使用可
✅ クエリ(2)
※Last Non-Direct Clickメソッドを使用
自前の分類ロジックを効かせたい✅ クエリ(1)
※数値補正なし

各クエリはそれぞれ、補正ロジックの違いによりセッション件数やチャネル分類結果に差が出ます。
特にチャネル分類(Organic Social・Direct など)については、Google公式の新フィールドを使用したクエリ(4)と、クエリによる再現を試みたクエリ(1)〜(3)で乖離が見られました。

1. 前回紹介した補正方法の復習

GA4の画面で表示されている「セッションの流入元」には、ダイレクト流入した再訪者が、前回訪問時に非ダイレクト流入していた場合は、前回の流入元からのアクセスとしてカウントするロジックが組み込まれています。
BigQueryにエクスポートされるデータにはその補正が反映されないため、SQLクエリによって再現する必要があります。

ただし、2024年7月17日以降のデータについては「session_traffic_source_last_click」という構造体(※)が実装されたため、再現が大幅に楽になりました。

※構造体:まとまった情報セット(フィールドのかたまり)

✅ 補正方法(1):2024年7月17日以降のデータ用(抜粋)

SELECT
  session_traffic_source_last_click.manual_campaign.source,
  session_traffic_source_last_click.manual_campaign.medium
FROM `your_project.your_dataset.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20240717' AND '20241231'
  AND event_name = 'session_start'

✅ 補正方法(2):2024年7月16日以前のデータ用

2024年7月16日以前のデータには「session_traffic_source_last_click」が存在しないため、自力で下記のロジックを再現する必要があります。

処理ステップ:

  1. セッションごとのトラフィックソースを収集
  2. ダイレクト流入を除いた前回の流入元をユーザー単位で保持
  3. セッション順に遡って非Directのソースを引き継ぐ

BigQuery上で正しく実装すれば、GA4画面にかなり近いセッション分類が可能になるはずです。

2. SQLクエリの使い方(Looker Studio で実行)

Looker Studio の「カスタムクエリ機能」を使えば、そのまま接続して利用できます(下図参照)

今回使用したSQLクエリは、本記事の最後に全文掲載しています。
データの取得期間は過去30日になっていますので、取得期間を変えたいときは下記の部分を修正してください

WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
                      AND FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))

3. 検証結果(クエリ別に比較)

下記の検証は、2025年4月6日〜4月19日(14日間)の弊社サイトのGA4エクスポートデータでおこないました。

  • クエリ(1):補正なし、チャネルグループのみ作成
  • クエリ(2):旧バージョン補正(Last Non-Direct Click方式)
  • クエリ(3):新バージョン補正(session_traffic_source_last_click使用)
  • クエリ(4):新バージョン補正+新フィールド「default_channel_group」使用

3-1. セッションのメディア別セッション数

mediumGA4クエリ(1)クエリ(2)クエリ(3)クエリ(4)
referral4843474848
organic4431434244
(none)30null303030
(not set)111
null473011

3-2. セッションのデフォルトチャネルグループ別セッション数

default_channel_groupGA4クエリ(1)クエリ(2)クエリ(3)クエリ(4)
Organic Search4431434244
Organic Social4427262844
Direct3053302830
Referral41920214
Unassigned11111

4. なぜ クエリ(1)〜(3) のチャネルグループ再現に失敗したのか?

  • Organic Social の正規表現が完全な形で再現できていない
  • Organic Social の正規表現で拾えなかったセッションが Referral にカウントされている?

上表以外のチャネルグループについては、アクセス自体がなかったため検証できませんでした。
Geminiの情報によると公開していない分類ロジックがあるようですが、今後も引き続き検証と解明を進めたいと思います。

5. 今回使用したSQLクエリ全文

以下が、今回使用したSQLクエリになります。
・「your_project」のところに、BigQueryのプロジェクトID
・「your_dataset」のところに、BigQueryのデータセット名
を入れてご利用ください。

クエリ(1):補正なし(チャネルグループ分類のみ)

WITH base AS (
  SELECT
    PARSE_DATE('%Y%m%d', event_date) AS parsed_event_date,
    user_pseudo_id,
    (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS session_id,
    LOWER(IFNULL((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'source'), '')) AS source,
    LOWER(IFNULL((SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'medium'), '')) AS medium
  FROM
    `your_project.your_dataset.events_*`
  WHERE
    _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
                     AND FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
    AND event_name = 'session_start'
),
classified AS (
  SELECT
    parsed_event_date,
    user_pseudo_id,
    session_id,
    source,
    medium,
    CASE
      WHEN medium = 'organic' AND REGEXP_CONTAINS(source, r'(google|bing|yahoo|baidu|duckduckgo|search\\.brave\\.com)') THEN 'Organic Search'
      WHEN REGEXP_CONTAINS(source, r'(.*facebook.*|.*instagram.*|.*twitter.*|.*t\\.co.*|.*linkedin.*|.*pinterest.*|.*reddit.*)')
           AND medium IN ('social', 'social-organic', 'referral') THEN 'Organic Social'
      WHEN REGEXP_CONTAINS(source, r'(.*facebook.*|.*instagram.*|.*twitter.*|.*t\\.co.*|.*linkedin.*|.*pinterest.*|.*reddit.*)')
           AND REGEXP_CONTAINS(medium, r'(paidsocial|.*cp.*|ppc|retargeting|paid.*)') THEN 'Paid Social'
      WHEN REGEXP_CONTAINS(medium, r'(cpc|ppc|paidsearch)') AND REGEXP_CONTAINS(source, r'(google|bing|yahoo|baidu|duckduckgo|search\\.brave\\.com)') THEN 'Paid Search'
      WHEN medium IN ('email', 'e-mail') THEN 'Email'
      WHEN medium = 'affiliate' THEN 'Affiliates'
      WHEN medium IN ('display', 'banner', 'cpm') THEN 'Display'
      WHEN medium IN ('video', 'paidvideo') THEN 'Paid Video'
      WHEN medium = 'organic-video' THEN 'Organic Video'
      WHEN medium = 'audio' THEN 'Audio'
      WHEN medium IN ('sms', 'text') THEN 'SMS'
      WHEN medium IN ('push', 'notification') THEN 'Mobile Push Notifications'
      WHEN medium = 'referral' THEN 'Referral'
      WHEN source IN ('', '(direct)', '(not set)') AND medium IN ('', '(none)', '(not set)') THEN 'Direct'
      ELSE 'Unassigned'
    END AS default_channel_group
  FROM base
)
SELECT
  parsed_event_date,
  default_channel_group,
  source,
  medium,
  COUNT(DISTINCT CONCAT(user_pseudo_id, CAST(session_id AS STRING))) AS sessions
FROM classified
GROUP BY parsed_event_date, default_channel_group, source, medium
ORDER BY parsed_event_date, default_channel_group, source, medium;

クエリ(2):旧バージョン補正(Last Non-Direct Click方式)

WITH SessionData AS (
  SELECT
    user_pseudo_id,
    PARSE_DATE('%Y%m%d', FORMAT_TIMESTAMP('%Y%m%d', TIMESTAMP_MICROS(event_timestamp))) AS parsed_event_date,
    event_timestamp,
    (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS session_id,
    LOWER(IFNULL(collected_traffic_source.manual_source, '')) AS source,
    LOWER(IFNULL(collected_traffic_source.manual_medium, '')) AS medium,
    CASE
      WHEN (collected_traffic_source.manual_source IS NULL OR collected_traffic_source.manual_source = 'direct')
           AND (collected_traffic_source.manual_medium IS NULL OR collected_traffic_source.manual_medium = '(none)')
      THEN TRUE ELSE FALSE
    END AS is_direct
  FROM `your_project.your_dataset.events_*`
  WHERE
    _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
                     AND FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
),
UserJourneys AS (
  SELECT
    user_pseudo_id,
    session_id,
    parsed_event_date,
    MIN(event_timestamp) AS session_start_time,
    MAX(is_direct) AS is_direct_session,
    MAX(IF(is_direct = FALSE, source, NULL)) AS non_direct_source,
    MAX(IF(is_direct = FALSE, medium, NULL)) AS non_direct_medium
  FROM SessionData
  GROUP BY user_pseudo_id, session_id, parsed_event_date
),
RankedSessions AS (
  SELECT
    *,
    LAST_VALUE(non_direct_source IGNORE NULLS) OVER (
      PARTITION BY user_pseudo_id
      ORDER BY session_start_time
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS final_source,
    LAST_VALUE(non_direct_medium IGNORE NULLS) OVER (
      PARTITION BY user_pseudo_id
      ORDER BY session_start_time
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS final_medium
  FROM UserJourneys
),
Finalized AS (
  SELECT
    parsed_event_date,
    user_pseudo_id,
    session_id,
    session_start_time,
    is_direct_session,
    CASE
      WHEN is_direct_session AND final_source IS NULL THEN ''
      WHEN is_direct_session THEN final_source
      ELSE non_direct_source
    END AS source,
    CASE
      WHEN is_direct_session AND final_medium IS NULL THEN ''
      WHEN is_direct_session THEN final_medium
      ELSE non_direct_medium
    END AS medium
  FROM RankedSessions
)
SELECT
  parsed_event_date,
  CASE
    WHEN medium = 'organic' AND REGEXP_CONTAINS(source, r'(google|bing|yahoo|baidu|duckduckgo|search\\.brave\\.com)') THEN 'Organic Search'
    WHEN REGEXP_CONTAINS(source, r'(.*facebook.*|.*instagram.*|.*twitter.*|.*t\\.co.*|.*linkedin.*|.*pinterest.*|.*reddit.*)')
         AND medium IN ('social', 'social-organic', 'referral') THEN 'Organic Social'
    WHEN REGEXP_CONTAINS(source, r'(.*facebook.*|.*instagram.*|.*twitter.*|.*t\\.co.*|.*linkedin.*|.*pinterest.*|.*reddit.*)')
         AND REGEXP_CONTAINS(medium, r'(paidsocial|.*cp.*|ppc|retargeting|paid.*)') THEN 'Paid Social'
    WHEN REGEXP_CONTAINS(medium, r'(cpc|ppc|paidsearch)') AND REGEXP_CONTAINS(source, r'(google|bing|yahoo|baidu|duckduckgo|search\\.brave\\.com)') THEN 'Paid Search'
    WHEN medium IN ('email', 'e-mail') THEN 'Email'
    WHEN medium = 'affiliate' THEN 'Affiliates'
    WHEN medium IN ('display', 'banner', 'cpm') THEN 'Display'
    WHEN medium IN ('video', 'paidvideo') THEN 'Paid Video'
    WHEN medium = 'organic-video' THEN 'Organic Video'
    WHEN medium = 'audio' THEN 'Audio'
    WHEN medium IN ('sms', 'text') THEN 'SMS'
    WHEN medium IN ('push', 'notification') THEN 'Mobile Push Notifications'
    WHEN medium = 'referral' THEN 'Referral'
    WHEN source IN ('', '(direct)', '(not set)') AND medium IN ('', '(none)', '(not set)') THEN 'Direct'
    ELSE 'Unassigned'
  END AS default_channel_group,
  source,
  medium,
  COUNT(DISTINCT CONCAT(user_pseudo_id, CAST(session_id AS STRING))) AS sessions
FROM Finalized
GROUP BY parsed_event_date, default_channel_group, source, medium
ORDER BY parsed_event_date, default_channel_group, source, medium;

クエリ(3):新バージョン補正(session_traffic_source_last_click使用)

WITH base AS (
  SELECT
    PARSE_DATE('%Y%m%d', event_date) AS parsed_event_date,
    user_pseudo_id,
    (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS session_id,
    LOWER(IFNULL(session_traffic_source_last_click.manual_campaign.source, '')) AS source,
    LOWER(IFNULL(session_traffic_source_last_click.manual_campaign.medium, '')) AS medium
  FROM
    `your_project.your_dataset.events_*`
  WHERE
    _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
                     AND FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
    AND event_name = 'session_start'
),
classified AS (
  SELECT
    parsed_event_date,
    user_pseudo_id,
    session_id,
    source,
    medium,
    CASE
      WHEN medium = 'organic' AND REGEXP_CONTAINS(source, r'(google|bing|yahoo|baidu|duckduckgo|search\\.brave\\.com)') THEN 'Organic Search'
      WHEN REGEXP_CONTAINS(source, r'(.*facebook.*|.*instagram.*|.*twitter.*|.*t\\.co.*|.*linkedin.*|.*pinterest.*|.*reddit.*)')
           AND medium IN ('social', 'social-organic', 'referral') THEN 'Organic Social'
      WHEN REGEXP_CONTAINS(source, r'(.*facebook.*|.*instagram.*|.*twitter.*|.*t\\.co.*|.*linkedin.*|.*pinterest.*|.*reddit.*)')
           AND REGEXP_CONTAINS(medium, r'(paidsocial|.*cp.*|ppc|retargeting|paid.*)') THEN 'Paid Social'
      WHEN REGEXP_CONTAINS(medium, r'(cpc|ppc|paidsearch)') AND REGEXP_CONTAINS(source, r'(google|bing|yahoo|baidu|duckduckgo|search\\.brave\\.com)') THEN 'Paid Search'
      WHEN medium IN ('email', 'e-mail') THEN 'Email'
      WHEN medium = 'affiliate' THEN 'Affiliates'
      WHEN medium IN ('display', 'banner', 'cpm') THEN 'Display'
      WHEN medium IN ('video', 'paidvideo') THEN 'Paid Video'
      WHEN medium = 'organic-video' THEN 'Organic Video'
      WHEN medium = 'audio' THEN 'Audio'
      WHEN medium IN ('sms', 'text') THEN 'SMS'
      WHEN medium IN ('push', 'notification') THEN 'Mobile Push Notifications'
      WHEN medium = 'referral' THEN 'Referral'
      WHEN source IN ('', '(direct)', '(not set)') AND medium IN ('', '(none)', '(not set)') THEN 'Direct'
      ELSE 'Unassigned'
    END AS default_channel_group
  FROM base
)
SELECT
  parsed_event_date,
  default_channel_group,
  source,
  medium,
  COUNT(DISTINCT CONCAT(user_pseudo_id, CAST(session_id AS STRING))) AS sessions
FROM classified
GROUP BY parsed_event_date, default_channel_group, source, medium
ORDER BY parsed_event_date, default_channel_group, source, medium;

クエリ(4):新バージョン補正+新フィールド「default_channel_group」使用

SELECT
  PARSE_DATE('%Y%m%d', event_date) AS parsed_event_date,
  session_traffic_source_last_click.cross_channel_campaign.default_channel_group AS default_channel_group,
  LOWER(IFNULL(session_traffic_source_last_click.manual_campaign.source, '')) AS source,
  LOWER(IFNULL(session_traffic_source_last_click.manual_campaign.medium, '')) AS medium,
  COUNT(DISTINCT CONCAT(user_pseudo_id, CAST((SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS STRING))) AS sessions
FROM
  `your_project.your_dataset.events_*`
WHERE
  _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
                  AND FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
  AND event_name = 'session_start'
GROUP BY
  parsed_event_date, default_channel_group, source, medium
ORDER BY
  parsed_event_date, default_channel_group, source, medium;

参考リンク


📌 この記事は、GA4 × BigQuery の実務分析を支援する「サイトウオンライン」の実験室よりお届けしました。