
本記事は、下記の実践ガイド記事の続編です:
👉 【後編】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」が存在しないため、自力で下記のロジックを再現する必要があります。
処理ステップ:
- セッションごとのトラフィックソースを収集
- ダイレクト流入を除いた前回の流入元をユーザー単位で保持
- セッション順に遡って非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. セッションのメディア別セッション数
| medium | GA4 | クエリ(1) | クエリ(2) | クエリ(3) | クエリ(4) |
|---|---|---|---|---|---|
| referral | 48 | 43 | 47 | 48 | 48 |
| organic | 44 | 31 | 43 | 42 | 44 |
| (none) | 30 | null | 30 | 30 | 30 |
| (not set) | 1 | – | – | 1 | 1 |
| null | – | 47 | 30 | 1 | 1 |
3-2. セッションのデフォルトチャネルグループ別セッション数
| default_channel_group | GA4 | クエリ(1) | クエリ(2) | クエリ(3) | クエリ(4) |
|---|---|---|---|---|---|
| Organic Search | 44 | 31 | 43 | 42 | 44 |
| Organic Social | 44 | 27 | 26 | 28 | 44 |
| Direct | 30 | 53 | 30 | 28 | 30 |
| Referral | 4 | 19 | 20 | 21 | 4 |
| Unassigned | 1 | 1 | 1 | 1 | 1 |
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 トラフィックソースのスコープと分類
GA4がどういう順序・ルールでチャネルを分類しているのかを説明。
default_channel_group の構成や attribution model との関係も明記あり。 - GA4 BigQuery Schema Change Log(更新履歴)
今回の使用したsession_traffic_source_last_click.cross_channel_campaign.default_channel_group
の追加については、このページの 2024年7月〜10月ごろの更新履歴 に記載あり。 - GA4 チャネルグループの定義と構成ルール
GA4が内部でどのようなsource/mediumをもとにOrganic SearchやPaid Searchと判定しているか、参考ルールの概要が掲載(ただし詳細ロジックは非公開)。
📌 この記事は、GA4 × BigQuery の実務分析を支援する「サイトウオンライン」の実験室よりお届けしました。


