Content

― 生データ活用の自由と引き換えに背負うもの、そしてその畳み方 ―

0. まえがき:記事が少ない領域で、同じところに何度もつまずいた

現場でやっているのは「GA4をそのまま使う」ことではない

まず、私の担当領域から簡単に。お客様のデータ分析・データ活用をサポートすることが、主な業務のひとつです。そしてその現場で、ほぼ必ず登場するのが GA4(Google アナリティクス 4) です。

ただし、我々が案件でやることは「GA4の画面を一緒に眺める」ことではありません。多くの場合、

GA4の生データを BigQuery にエクスポートし、SQLで加工・カスタマイズして、そのお客様のニーズに合った集計・ダッシュボードを作る

という進め方になります。なぜなら、GA4の標準レポートでは答えられない問いに答えるには、結局これが一番早いからです。

参考になる記事が、当時ほとんど無かった

最近はさすがに慣れてきました。しかし、活用し始めた当初は、本当に何度も壁にぶつかりました。 しかも厄介だったのが、GA4単体の記事、BigQuery単体の記事はいくらでもあるのに、「GA4 × BigQuery」の組み合わせで実務的にハマるポイントを書いた記事が、当時ほとんど見つからなかった ことです。

もちろん公式のスキーマ定義はあります。ところが、そこには「この項目は何のためにあるのか」までは書かれていません。結果として、手戻りと請求書で学ぶことになりました。

そこで本記事では、私が実際につまずいた2つの壁 を、自分の整理も兼ねて書き残しておきます。

壁 内容 本記事の章
壁① 項目数が膨大で、どれが何のデータなのか分からない。なかでも参照元(source / medium)は複数箇所に展開されており、そのため使い分けが分からず手戻りが発生した 3章
壁② クエリ容量が膨大になり、課金額が問題になる。そして、BIツールと繋いだ瞬間に想定外の請求が発生し、改善を求められた 4章

そして読者の皆様には、ぜひ 私の苦労を屍として踏み越えていってほしい と思っています。

想定読者

具体的には、次の3タイプを想定しています。

読者 この記事の読み方
これからGA4のBigQueryエクスポートを触る人 全章。特に3章を先に読んでおくと手戻りが減ります
すでに触っていて、数字がGA4の画面と合わない人 3章。原因の大半は「項目の選び間違い」です
BigQueryの請求額に頭を抱えている人 / これから抱える人 4章。マートテーブルの粒度設計が本題です

注意(執筆時点の情報について)

なお本記事は 2026年9月時点 の情報をもとにしています。GA4のエクスポートスキーマは項目が追加・変更されることがあり、BigQueryの料金もリージョンや時期によって異なります。本記事のSQLをそのまま流す前に、必ず自分のテーブルのスキーマと、公式の料金表をご確認ください。


1. GA4とは ― 「イベント単位で記録するアクセス解析」

まず前提の確認から。ご存知の方は2章まで飛ばしてください。

GA4(Google アナリティクス 4) は、Googleが提供する無料のアクセス解析ツールです。2023年7月1日には前世代の UA(ユニバーサルアナリティクス) が計測を停止しました。そのため、現在Googleアナリティクスと言えば実質GA4を指します。

UAとの最大の違いは「計測モデル」

では、UAと何が違うのか。いちばん大きいのは、計測モデルの考え方 です。

【UA】ヒット/セッション中心のモデル
  「セッション」という箱があり、その中に
  ページビュー・イベント・トランザクションという
  種類の違う“ヒット”が入る

【GA4】イベント中心のモデル
  すべてが「イベント」。ページ閲覧も page_view というイベント。
  スクロールも scroll、購入も purchase。
  セッションやユーザーは、イベントに付随する“属性”として扱う

つまりGA4では、サイト上で起きたことすべてが「イベント」という1種類のレコードとして、時系列に積まれていく という構造になっています。

このモデルの整理は地味ですが重要です。というのも、BigQueryにエクスポートされるデータは、この「イベントの行列」がそのまま出てくるもの だからです。

一方、GA4の画面で見ている「セッション数」や「エンゲージメント率」はどうでしょうか。これらは生イベントを集計・加工した 結果 であって、生データにそのまま入っているわけではありません。したがって、画面の指標と生データの項目は1対1で対応しません。 ここが3章の壁に直結します。

その他の特徴 ― BigQueryエクスポートが無料版でも使える

次に、GA4のその他の特徴を簡単に挙げておきます。

特徴 内容
Web / アプリを横断して計測できる 同一プロパティ内に複数のデータストリーム(Web・iOS・Android)を持てる
自動収集イベントがある page_view、scroll、click、session_start、first_visit などはタグを貼るだけで勝手に取れる
探索(Exploration)レポート 自由度の高いアドホック分析ができる。ただし後述の制約あり
BigQueryエクスポートが無料版でも使える これが本記事の主題。 なお、UA時代は有償版(360)限定の機能でした

そして最後の項目が、実は非常に大きな変化でした。無料版のGA4でも、生データをBigQueryに吐き出せる。 これによって「GA4の画面でできることの外側」に、誰でも手が届くようになったわけです。


2. GA4のデータをBigQueryで活用する優位性

では、なぜわざわざBigQueryに出すのか。理由はひとつです。

GA4の画面は「集計結果を見る場所」であって、「データを自由に扱える場所」ではないから。

BigQueryにエクスポートすると、GA4のイベントが 1行1イベントの生データ(テーブル) として手に入ります。つまり、SQLが書ける状態になる、というのが本質です。そしてここから先は、集計もJOINも機械学習も自由になります。

では、具体的にどう効くのか。ここからは代表的なものを挙げていきます。

2-1. データ保持期間の制約から解放される

まずこれが、意外と一番効きます。

GA4の無料版では、ユーザー単位・イベント単位の詳細データの保持期間が最長14か月(デフォルトは2か月)です。つまり 設定を最長にしても、2年前との比較ができません。

一方、BigQueryにエクスポートしてしまえば、そのデータは 自分のプロジェクトの資産 です。保持期間はこちらで決められます。

GA4(無料版) BigQueryエクスポート
詳細データの保持 最長14か月 自分が消すまで残る
前年同月比 条件付きで可能 可能
3年前との比較 不可 可能

したがって「昨年の同じキャンペーン期間と比べたい」という、実務でごく普通に出てくる要望に答えるだけでも、エクスポートしておく価値があります。

⚠️ エクスポートは「設定した日から」しか溜まりません

なお、BigQueryエクスポートは 過去データを遡って出力してくれません。 設定した翌日以降のデータから溜まり始めます。

つまり 「必要になってから設定する」では手遅れ です。今のところ使う予定がなくても、とりあえず繋いでおく のが正解です。私はこれを言い続けています。

2-2. 「(other)」とサンプリングがなくなる ― 全量の正確な集計

GA4の標準レポートは、あらかじめ集計されたテーブルを見ています。そのため ディメンションの値の種類(カーディナリティ)が上限を超えると、あふれた分が (other) という行にまとめられてしまいます。

実際、ページ数の多いメディアサイトや、商品点数の多いECサイトでは、これが日常的に起きます。「上位20ページは分かるが、ロングテールがすべて (other) に吸い込まれて分析できない」という状態です。

また、自由度の高い探索レポートのほうにも、サンプリング という制約があります(無料版では1クエリあたりのイベント数に上限があり、それを超えると抽出計算になります)。

一方、BigQueryの生データは そのどちらとも無縁 です。そのため、全イベントに対して正確に集計できます。

課題 GA4画面 BigQuery
ロングテールのページ分析 (other) に丸められる 全URL取得できる
大規模データの探索 サンプリングされ得る 全量
独自の指標定義 用意された指標のみ SQLで自由に定義

2-3. 社内の他データとJOINできる ― 「売上に繋がったのか」が言える

そして、これがおそらくお客様に一番刺さる価値です。

というのも、GA4が持っているのは サイト内の行動 だけだからです。「この流入経路から来た人が、最終的にいくら売り上げたのか」「解約したのか」「広告費に対して 利益が出たのか」には、GA4単体では答えられません。

ところが、BigQueryに置けばそれができます。

  GA4イベント(BigQuery)
        │  user_id / トランザクションID をキーに結合
        ├──── 基幹システムの受注・売上データ
        ├──── CRM の顧客属性・LTV・解約フラグ
        └──── 広告プラットフォームの費用データ(Google Ads / Yahoo! / Meta …)
                      ↓
        「どのチャネルの、どの属性の顧客が、
          いくらのコストで、いくら利益を生んだか」

具体例を挙げます。

  • チャネル別ROAS / CPAの正確な算出:GA4のコンバージョン数ではなく、基幹の確定売上 で割って出す。そのため、キャンセル・返品を差し引いた「本当の数字」になります
  • 会員属性 × 行動のクロス分析:「優良顧客はサイト内でどこを見ているのか」。ただし会員ランクはCRM側にしかないので、JOINしないと出せません
  • オフラインコンバージョンの接続:資料請求 → インサイドセールスの架電 → 受注、という流れを1本に繋ぐ。なお、BtoBではほぼ必須です

2-4. GA4の画面ではできない集計・分析ができる

生データがあるということは、集計ロジックを自分で書ける ということです。したがって、GA4の定義に縛られません。

  • 独自定義のファネル・遷移分析:たとえば「Aを見てから3日以内にBに来て購入した人」。GA4の探索では組みづらい条件も、SQLなら直接書けます
  • リードタイム分析:初回訪問から購入までの日数分布。つまり user_first_touch_timestamp と購入イベントの差分を取るだけです
  • コホート分析の自由な設計:GA4のコホート探索は粒度が固定です。しかしSQLなら「初回流入チャネル × 初回月」など好きな軸で切れます
  • セッション定義の作り替え:GA4のセッションは30分無操作で切れます。ところがSQLなら「1日を1セッションとみなしたい」のような業務要件にも合わせられます
  • BigQuery ML による予測:たとえば離脱予測、購買確率、LTV予測などを データを動かさずに そのまま学習・推論できます

2-5. BIツールに直結できる

さらに、BigQueryは Looker Studio・Tableau・Power BI など主要BIツールの標準的な接続先でもあります。GA4のコネクタで直接繋ぐより、加工済みのテーブルを経由させたほうが、表示速度も指標定義の統制も安定します。

……ただし、この「BIツール連携」が、そのまま4章の壁②の引き金になります。 ここは後で詳しく書きます。


3. 苦労したポイント① ― 項目数が膨大で、どれが何のデータか分からない

ここからが本題です。

まず、BigQueryにGA4のテーブルが吐き出されて最初にやることは、当然「スキーマを見る」です。ところが、そこで固まります。

項目が、とにかく多い。 しかもネストしている。そして極めつけは、似た名前の項目が複数ある ことです。

3-1. まず、テーブルの構造を掴む

まず、GA4のエクスポートはこういう形で出てきます。

プロジェクト
└─ データセット(analytics_XXXXXXXXX)
   ├─ events_20260921       ← 日付ごとに1テーブル(日付シャーディング)
   ├─ events_20260920
   ├─ events_20260919
   ├─ …
   └─ events_intraday_20260922  ← 当日分(ストリーミング)

ポイントは 「1つの大きなテーブル」ではなく、日付ごとに別テーブルとして作られる ことです。そのため期間指定は、ワイルドカードと _TABLE_SUFFIX で書きます。

SQL

SELECT COUNT(*)
FROM `my-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260901' AND '20260907'

⚠️ _TABLE_SUFFIX >= ‘20260901’ と書くと、当日分が二重に混ざります

events_* というワイルドカードは、events_intraday_20260922 にもマッチします。 そして _TABLE_SUFFIX は文字列比較なので、こうなります。

“`

‘intraday_20260922’ BETWEEN ‘20260101’ AND ‘20260922’ → false(除外される)

‘intraday_20260922’ >= ‘20260101’ → true (混ざる)

“`

BETWEEN で上限を切っていれば、intraday_ で始まる文字列は上限を超えるので自然に除外されます。ところが >= だけで書くと、確定版(events_YYYYMMDD)と当日版(events_intraday_YYYYMMDD)の両方を読んでしまい、当日のイベントが二重計上されます。

そのため「今日の数字だけ妙に多い」ときは、まずここを疑ってください。_TABLE_SUFFIX は必ず BETWEEN で上下を閉じる のが安全です。

1行は「1イベント」 ― 主な列を押さえる

次に、テーブルの中身です。1行が「1イベント」に対応しており、主な列を整理するとこうなります。

列 型 中身
event_date STRING ‘20260921’ 形式。プロパティのタイムゾーン基準
event_timestamp INTEGER UTCのマイクロ秒(秒でもミリ秒でもない)
event_name STRING page_view / session_start / purchase など
user_pseudo_id STRING Cookie(クライアントID)ベースの擬似ID
user_id STRING 自社で設定したログインID。設定していなければ常にNULL
event_params REPEATED RECORD イベント固有のパラメータ。つまり key と value のペアの配列
user_properties REPEATED RECORD ユーザー属性の配列
device / geo / app_info RECORD 端末・地域などの入れ物
traffic_source RECORD 参照元(※ここが罠。後述)
collected_traffic_source RECORD 参照元(※これも)
session_traffic_source_last_click RECORD 参照元(※これも)
items REPEATED RECORD EC商品の配列
ecommerce RECORD EC集計値(購入金額など)
event_params は「key と value の配列」

このうち event_params が独特です。というのも 列が固定されていないので、欲しい値はサブクエリで取り出す 必要があります。

SQL

-- ページURLを取り出す
SELECT
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page_location
FROM `my-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX = '20260921' AND event_name = 'page_view'

この書き方自体は、慣れれば何でもありません。ただし、「どのキーに何が入っているのか」は、テーブルを見るだけでは分かりません。 そこで、主要なものを載せておきます。

event_params のキー 値の型 中身
ga_session_id int_value セッションID。セッション数を数えるにはこれが必要
ga_session_number int_value そのユーザーにとって何回目のセッションか
page_location string_value 閲覧URL(クエリパラメータ込みのフルURL)
page_title string_value ページタイトル
page_referrer string_value 直前のURL
engagement_time_msec int_value エンゲージメント時間(ミリ秒)
session_engaged string_value エンゲージしたセッションなら ‘1’(文字列)
entrances int_value ランディングなら 1

3-2. 最大の罠 ― source / medium が3か所以上にある

さて、いよいよ本題の参照元です。

案件で source / medium(参照元/メディア)を扱う機会は、何度もありました。というのも「どのチャネルから来た人が成果に繋がっているか」は、最も需要が高い分析のひとつだからです。

ところが、スキーマを検索すると 同じような項目がいくつも出てきます。

traffic_source.source
traffic_source.medium
traffic_source.name

collected_traffic_source.manual_source
collected_traffic_source.manual_medium
collected_traffic_source.manual_campaign_name
collected_traffic_source.gclid

session_traffic_source_last_click.manual_campaign.source
session_traffic_source_last_click.manual_campaign.medium
session_traffic_source_last_click.cross_channel_campaign.source
session_traffic_source_last_click.cross_channel_campaign.medium
session_traffic_source_last_click.cross_channel_campaign.default_channel_group
session_traffic_source_last_click.google_ads_campaign. ...

event_params の 'source' / 'medium' / 'campaign'

どれも「参照元」です。 そして当時の私は、一番名前が短くて分かりやすい traffic_source.source を選びました。これが手戻りの始まりでした。

その結果、出した数字がGA4の画面と合わない。合わないどころか、「セッションの参照元別」で出したはずの集計が、直感と大きくズレる。 原因が分かるまで、ずいぶん時間を溶かしました。

正解:スコープ(適用範囲)が違う

しかし調べてみると、これらの項目は 同じ「参照元」を、違う粒度で持っているもの でした。

スコープ早見表
項目 スコープ 中身 GA4画面での対応
traffic_source
.source / .medium / .name
ユーザー(初回接触) そのユーザーを初めて連れてきた参照元。 そのため全イベントに同じ値が入り、以降ずっと変わらない 「ユーザーの最初の参照元/メディア」=ユーザー獲得レポート
session_traffic_source_last_click
.cross_channel_campaign.source など
セッション そのセッションのラストクリック(非直接)アトリビューション結果。 なお、セッション内の全イベントに同じ値が入る 「セッションの参照元/メディア」=トラフィック獲得レポート
collected_traffic_source
.manual_source / .gclid など
イベント そのイベント時点で収集された生の値。 URLの utm_* や gclid が入る。ただしUTM無しの流入では空 (直接対応する指標はない。収集された生値)
event_params の source / medium イベント 収集時の値が入っているケース。プロパティや開始時期によって有無が違う (同上)

つまり、私が最初に選んだ traffic_source は 「このイベントの参照元」ではなく「このユーザーを最初に連れてきた参照元」 でした。しかも、名前からはそれがまったく読み取れません。

実務での使い分け ― 「何を再現したいのか」から選ぶ

したがって、選び方は「どの項目が便利そうか」ではなく 「どのレポートを再現したいのか」 から入るのが正解です。

Q. GA4の「トラフィック獲得」レポートを再現したい
   → session_traffic_source_last_click を使う(セッションスコープ)

Q. GA4の「ユーザー獲得」レポートを再現したい
    / 初回流入チャネル別のLTVを見たい
   → traffic_source を使う(ユーザースコープ)

Q. 特定キャンペーンのUTMが正しく飛んでいるか検証したい
   → collected_traffic_source を使う(イベントスコープ・生値)

💡 さらにハマりやすい2点

① まず session_traffic_source_last_click は、比較的あとから追加された項目です。 エクスポートを始めた時期が古いと、過去のテーブルには列そのものが存在しません。 長期間を横断するクエリを書くと、そこでエラーになります。古い期間は session_start イベントの event_params から組み立てるしかありません。

② また、ノーリファラー(直接流入)では値がNULLになります。 COALESCE(…, ‘(direct)’) を必ず挟まないと、集計から行が落ちたり、NULLのまま大きな塊になったりします。

実際のSQL ― 参照元/メディア別のセッション数

では、実際に書いてみます。GA4の「トラフィック獲得」レポートに相当する、セッション単位の参照元/メディア別セッション数です。

SQL

SELECT
  COALESCE(session_traffic_source_last_click.cross_channel_campaign.source, '(direct)') AS session_source,
  COALESCE(session_traffic_source_last_click.cross_channel_campaign.medium, '(none)')   AS session_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 `my-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260901' AND '20260907'
GROUP BY 1, 2
ORDER BY sessions DESC

3-3. 同じように混乱した、その他の項目

参照元は代表例ですが、「名前から中身が読めない」「似た項目が複数ある」系の罠は他にもあります。 私が実際にやらかしたものを挙げておきます。

① セッション数:session_id という列は存在しない

GA4の主要指標であるセッション数を数えようとして、まず列を探しました。ところが ありません。 代わりに event_params の ga_session_id を使います。

そしてもう一段の罠があります。ga_session_id は、そのユーザーの中でしかユニークではありません。 別のユーザーが同じ値を持ち得ます。

SQL

-- ✗ 間違い:ユーザー間で衝突するため、GA4より少ない数字になる
COUNT(DISTINCT (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id'))

-- ○ 正しい:user_pseudo_id と組み合わせて初めてセッションを一意に識別できる
COUNT(DISTINCT CONCAT(user_pseudo_id, '-',
  CAST((SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS STRING)))

実際、私は最初は前者で数えて「GA4の画面より少し少ないけど、まあ近いから合ってるだろう」と流しかけました。危ないところでした。

② session_engaged の値は、整数の1ではなく 文字列の ‘1’

次は「エンゲージのあったセッション数」を出そうとしたときの話です。

SQL

-- ✗ これだと全部 NULL になり、エンゲージセッションがゼロになる
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'session_engaged')

-- ○ 文字列側に入っている
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'session_engaged')

ところが engagement_time_msec は int_value なのに、session_engaged は string_value。同じ event_params の中で型が揃っていません。 これはハマると原因に気づきにくいタイプの罠です。エラーにならず、静かにゼロになる のが悪質です。

なお、実装や時期によって int_value 側に入るケースもあるため、私は保険をかけています。

SQL

COALESCE(
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'session_engaged'),
  CAST((SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'session_engaged') AS STRING)
) AS session_engaged

③ 日付が2種類あり、タイムゾーンが違う

なお event_date と event_timestamp は、基準が違います。

項目 型 基準
event_date STRING ‘20260921’ プロパティのタイムゾーン(日本設定ならJST)
event_timestamp INTEGER(マイクロ秒) UTC

そのため、こう書くと日付がズレます。

SQL

-- ✗ UTCで日付を作っているので、JSTの深夜帯が前日に寄る
DATE(TIMESTAMP_MICROS(event_timestamp)) AS dt

-- ○ タイムゾーンを明示する
DATE(TIMESTAMP_MICROS(event_timestamp), 'Asia/Tokyo') AS dt

しかも _TABLE_SUFFIX はテーブル名(=event_date 相当)です。そのため 「suffixで9月1日を指定したのに、timestampから作った日付列では8月31日の行が混じる」 という現象が起きます。私はこれで日次グラフが1日ズレたレポートを一度出しました。

④ items を UNNEST すると売上が二重計上される

これはEC案件の定番です。items は商品の配列なので、UNNEST すると 1つの購入イベントが、商品数ぶんの行に増えます。

SQL

-- ✗ 3商品買った1件の購入が3行になり、購入金額が3倍になる
SELECT SUM(ecommerce.purchase_revenue)
FROM `my-project.analytics_123456789.events_*`, UNNEST(items)

-- ○ 売上合計はイベント単位で集計する
SELECT SUM(ecommerce.purchase_revenue)
FROM `my-project.analytics_123456789.events_*`
WHERE event_name = 'purchase'

-- ○ 商品別の内訳が欲しいときだけ items 側の金額を使う
SELECT i.item_name, SUM(i.item_revenue)
FROM `my-project.analytics_123456789.events_*`, UNNEST(items) AS i
WHERE event_name = 'purchase'
GROUP BY 1

つまり UNNEST は行を増やす操作 であることを、指標ごとに意識する必要があります。

⑤ user_id と user_pseudo_id は別物

項目 中身 注意
user_pseudo_id Cookie(ブラウザ)単位のID 端末・ブラウザを変えれば別人扱い。なお、同意が拒否された場合はNULLになる行もある
user_id 自社が明示的に設定したログインID 設定していなければ常にNULL。また、ログイン前の行動には付かない

なお「ユーザー数」を COUNT(DISTINCT user_pseudo_id) で出すと、GA4画面の「アクティブユーザー」とは一致しません。 GA4側は独自の判定(is_active_user 列が参考になります)やモデル化を含んでいるためです。

⑥ page_location はフルURL

クエリパラメータが付いたままなので、そのまま GROUP BY するとページが無限に分裂します。したがって、パスだけ欲しいなら加工が必要です。

SQL

REGEXP_EXTRACT(
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location'),
  r'^https?://[^/]+([^?#]*)'
) AS page_path

3-4. 私が行き着いた対処法

正直なところ、項目の暗記は無理です。そのため私は、次の4つで対処しています。

やること なぜ効くか
① 公式のスキーマ定義を、必ず一次資料として開く 「たぶんこういう意味だろう」が一番危険です。なぜなら、スコープ(ユーザー/セッション/イベント)が書かれているのは公式だけだからです
② GA4の画面の指標名から逆引きする 先に「再現したいのはトラフィック獲得レポートか、ユーザー獲得レポートか」を決める。目的を決めてから項目を選ぶ 順序にすると、参照元3兄弟の選択を間違えません
③ 1日分・小さい範囲でGA4画面と突き合わせる 数字が合っているかを 最初に 検算する。逆に、全期間分を作り込んでから合わないことに気づくと、手戻りが最大化します
④ 「項目辞書」を社内に残す 選んだ項目とその理由(なぜ traffic_source ではなく session_traffic_source_last_click なのか)を書き残す。次に触る人の時間を丸ごと節約できます

いちばん効いたのは「②の逆引き」

このうち、特に効いたのは ② です。スキーマを上から眺めて「使えそうな項目」を探すアプローチをやめ、「GA4の画面で言うとこの数字」から入る ようにしました。それだけで、手戻りが激減しています。

💡 検算のコツ

まず、GA4の画面とBigQueryの数字は 完全一致しないのが普通 です。サンプリング、しきい値処理、モデル化データ、(other) 行、タイムゾーン処理など理由は複数あります。

したがって目標は「完全一致」ではなく 「乖離の理由を説明できる状態」 です。「2〜3%ズレるが、原因はしきい値処理なので問題ない」と言えれば、それは合っています。ここを最初にお客様と合意しておくと、後々ものすごく楽になります。


4. 苦労したポイント② ― クエリ容量が膨れ上がり、課金額が問題になる

こちらは、手戻りではなく 請求書 で殴られるタイプの壁です。

私は実際に、「クエリ料金が高すぎるので改善してほしい」という依頼を、何度も受けました。 しかもこれは、設計を間違えたから起きたというより、GA4のデータ構造上、素直に作ると必ずそうなる 類の問題です。

4-1. なぜGA4は、こんなにクエリ容量を食うのか

理由は単純です。つまり 粒度が極端に細かいから にほかなりません。

というのも、GA4はサイト上のあらゆる行動を イベント単位 で記録するからです。1人のユーザーが1回訪問して5ページ見れば、それだけで session_start、first_visit、page_view × 5、scroll、user_engagement …… と、軽く10行以上が積まれます。

ユーザー1人の1セッション ≒ 10〜30イベント(=10〜30行)
        ↓
1日1万セッションのサイト  ≒ 10万〜30万行 / 日
        ↓
1週間                    ≒ 70万〜200万行

そして行数もさることながら、効いてくるのは 1行あたりの重さ です。event_params、user_properties、items といった REPEATED RECORD(繰り返し構造) が入っているため、1行が驚くほど太い。結果として、

1週間分を集計するだけで、数GB規模のスキャンが発生する ―― これがGA4では普通に起きます。

4-2. BigQueryの課金の仕組み ― 「スキャンしたバイト数」で決まる

ただし、ここを正確に押さえないと対策の方向を間違えます。BigQueryのオンデマンド課金は、

クエリが読み取った(スキャンした)データ量のバイト数 で決まります。

クエリの実行回数でも、返ってきた行数でもありません。 そして重要なのは、BigQueryが 列指向 データベースであることです。

操作 課金への影響
SELECT する列を減らす そのぶん直接安くなる。最も効く
WHERE で行を絞る 絞り込みに使った列は全件スキャンされる。 そのため、パーティション/シャーディングで効かせる必要がある
LIMIT 10 を付ける 一切安くならない。 全スキャンしてから10行返すだけ
SELECT * 最悪。 全列=そのテーブルの全容量が課金対象

⚠️ LIMIT は節約になりません

例えば「とりあえず中身を見たいから SELECT * FROM events_* LIMIT 10」。これは 全期間の全列をスキャンします。 GA4のテーブルで数年分溜まっていれば、この1回で数TBです。

そのため、中身を見たいだけならコンソールの 「プレビュー」タブ(課金ゼロ) を使ってください。私はこれを知らずに一度やりました。

そしてGA4特有の必須テクニックが、_TABLE_SUFFIX での期間絞り込み です。

SQL

-- ✗ 全期間の全テーブルをスキャン
FROM `my-project.analytics_123456789.events_*`

-- ○ 7テーブル分しか読まない
FROM `my-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260901' AND '20260907'

つまり 日付シャーディングされているので、_TABLE_SUFFIX で絞れば、その日数分のテーブルしか読みません。 これは必ず入れます。

4-3. 爆発するのは「BIツールに繋いだ瞬間」

ただし、ここまではまだ問題になりません。なぜなら、数GBのクエリを1日に数回投げるだけなら BigQueryには月1TiBの無料枠があるので、実質ゼロ円で済む からです。

しかし問題が起きるのは、BIツールと連携して、複数ユーザーがダッシュボードを使い始めたとき です。

【ダッシュボードの利用実態】

  ユーザーが期間フィルタを「先月」に変更   → クエリ1回
  チャネルを「Organic」に絞る              → クエリ1回
  デバイスを「Mobile」に絞る               → クエリ1回
  ページを見比べるためタブを切り替える      → クエリ1回
  ...

  ダッシュボードのフィルタ操作 1回 = クエリ 1回

BIツールは、ユーザーが操作するたびに裏でクエリを投げます。 もちろんこれは当たり前の挙動です。ところが、GA4の生テーブルに直結していると、その1回1回が数GBのスキャンになります。

そして、こうなります。

  利用者 10人 × 1日20操作 × 月20営業日 = 4,000クエリ / 月

試算してみる ― 読ませるテーブルを変えるだけで2桁変わる

想像どおり、とんでもないスキャン量 になります。実際に試算してみましょう(オンデマンド課金・1 TiB あたり $6.25、月1 TiB の無料枠あり、という条件での概算です)。

GA4生テーブルに直結 マートテーブル経由
1クエリのスキャン量 5 GB 50 MB
月間クエリ数 4,000 4,000
月間スキャン量 約 19.5 TiB 約 195 GiB
無料枠(1 TiB)差引後 約 18.5 TiB 0(無料枠内)
月額の目安 約 $116(1.7万円前後) $0

※ 上記はあくまで構造を示すための試算です。 実際の単価はリージョンによって異なり、無料枠は請求先アカウント単位で他の用途とも共有されます。必ず公式の料金表と、自分のプロジェクトの実績値で確認してください。

金額の絶対値以上に見ていただきたいのは、同じ分析内容でも、読ませるテーブルを変えるだけで2桁変わる という点です。しかも利用者が増えるほど、この差は線形に開いていきます。

要するに、「データ活用を社内に広めましょう」と言った矢先に、広まるほど課金が増える という構造になっているわけです。

4-4. 解決策:マートテーブルを作る ― ただし「粒度」が命

答えはシンプルです。つまり あらかじめ加工・軽量化したテーブル(マートテーブル)を作り、BIツールにはそちらを読ませる ことです。

ただし、やみくもに作っても効果は出ません。 ここが本題です。前提として押さえるべきなのは、

クエリ容量は、読ませるテーブルのデータ量に比例して膨らむ。

という一点です。つまり、マートテーブルの価値は「どれだけ削れたか」で決まります。 加工してあることではなく、軽いことが本質です。

その前に ― パーティションの設定は「絶対条件」です

ただし、削り方の話に入る前に、ひとつだけ絶対に外せない条件があります。

マートテーブルには、必ずパーティションを設定すること。これは「やったほうがよい工夫」ではなく、絶対条件です。

なぜなら パーティションを切っていないテーブルは、WHERE で期間を絞っても、その列が全期間ぶんスキャンされる からです。

【パーティションなしのマート】
  WHERE event_date BETWEEN '2026-09-01' AND '2026-09-07'
    → 絞り込み自体はできる。が、event_date 列は全期間ぶん読まれる
    → 1年分溜まっていれば、7日分を見るだけで 1年分の課金

【パーティションあり(PARTITION BY event_date)】
  WHERE event_date BETWEEN '2026-09-01' AND '2026-09-07'
    → 該当する 7 パーティションだけを読む
    → 課金は 7/365 に落ちる

「作った当初は安かったのに、半年で高くなった」の正体

どれだけ列を削って軽いマートを作っても、毎回全期間がスキャンされるなら、データが溜まるほど課金は線形に増え続けます。 実際、「作った当初は安かったのに、半年経って高くなった」という相談は、ほぼこれが原因です。

逆に言えば、パーティションさえ切ってあれば、「直近28日だけ見る」という普通の使い方が、そのまま課金の削減として効きます。 そして何より大きいのは、利用者に節約を意識させずに済む ことです。

⚠️ GA4の生テーブルと違い、マートは自分で書かないと効きません

GA4のエクスポートは日付シャーディング(events_YYYYMMDD)なので、_TABLE_SUFFIX で絞るだけでパーティションと同じ効果が得られています。ところが、自分で作るマートは PARTITION BY を明示的に書かないと、その恩恵が消えます。

つまり 「生テーブルより軽いマートを作ったのに、課金がほとんど減らない」 という状態が普通に起こり得ます。私は一度これをやって、原因が分かるまで首をかしげていました。列を削ることと、パーティションを切ることは、別の対策です。両方やって初めて効きます。

削る対象には、効く順番がある

そのうえで、何から削るかを考えます。効果の大きさには、はっきりした順番があります。

優先度 削るもの 効果
1位 列(特に event_params / items / user_properties などのREPEATED RECORD) 最大。 テーブル容量の大半をここが占めているケースが多い
2位 行(不要なイベント、保持不要な古い期間) 中〜大
3位 粒度(イベント単位 → セッション単位 → 日次集計) 大きいが、答えられる問いも減る

配列(REPEATED RECORD)が特に高くつく理由

そして列が1位なのには、配列特有の理由 があります。

UNNEST は行数を変える演算です。 そのため、配列を UNNEST しているクエリでは、たとえ配列の中身を1つも参照していなくても、BigQuery は行数を確定させるために配列を読まなければなりません。

これが何を生むかというと、「配列のフィールドを使わないクエリのほうが、使うクエリより高い」という逆転 です。実際、マートに配列列を持たせ、利用側のビューで常時 UNNEST して展開していたケースを計測(dry run)すると、こうなりました。

クエリのパターン スキャン量
配列を UNNEST しているが、フィールドは 参照しない 最大
UNNEST して、フィールドも 参照する 中
そもそも UNNEST していない 最小(一番上と5倍前後の差)

「内訳を配列で持たせておけば、必要なときだけ展開すればいい」――これは直感的には正しく見えます。ところが、利用側が常時 UNNEST する作りになっていると、逆に全クエリを重くします。

対処はシンプルで、配列をやめて、素直にフラットな行に展開してしまう ことです。もちろん行数は増えます。ただし、配列の中身が1件だけの行が大半なら、行数の増加はごくわずか で済みます(上のケースでは約1.13倍でした)。したがって 「配列で圧縮する」より「フラットにして列を減らす」ほうが、BigQuery では安くなる と考えておくのが実務的です。

原則は「最小構成」 ― ただし、そのままでは終われない

以上をふまえると、原則としては 目的に合わせて最小構成にするのが、クエリ容量としては最安 です。例えば「チャネル別の日次セッション数だけ見たい」なら、日次集計テーブルを作れば1クエリ数MBで済みます。

ところが、それで終わりにはできませんでした。

4-5. 「最小構成」と「汎用性」のトレードオフ ― 私が汎用マートを選んだ理由

目的別に作り続けると、2つの問題が出る

たしかに理屈はそうです。ところが、目的別に最小構成のマートを作っていくと、マートテーブルが増え続けます。 その結果、2つの問題が出ました。

【問題1:管理が大変になる】
  マートが30本あれば、定義30本・更新ジョブ30本・依存関係30本。
  項目追加のたびに「どれを直すのか」を追う作業が発生する。

【問題2:ユーザーがテーブルを探せなくなる】
  「チャネル別のCVRを見たい」
    → ga4_daily_channel? ga4_session_summary? ga4_cv_by_source?
      ……どれ?
  分析を始める前に、テーブル探しで止まる。

しかし、より深刻なのは 問題2のほう でした。

膨大なマートテーブルの中から、自分がやりたい分析に最適なものを探す作業は、それ自体がデータ活用のボトルネックになります。 そして、ボトルネックがある基盤は使われません。つまり、使われないデータ基盤は、どれだけ綺麗に設計されていても価値がゼロです。

つまり データ活用の最優先事項は「使ってもらうこと」 なのです。そして、ここを外すと、コスト最適化に成功して事業価値をゼロにする、という笑えない結果になります。

私の落としどころ ― event_params だけを落とした汎用マート

そこで私は、ある程度の汎用性を確保したマートを1本立てる 運用にしています。具体的には、

event_params などの「最も粒度の細かいイベント単位の項目」だけを排除し、それ以外は残した、イベント単位の汎用テーブル。

まず event_params から必要なキー(ga_session_id、page_location、session_engaged など)を あらかじめ平坦な列として抽出しておきます。 そのうえで、REPEATED RECORD 本体は落とす。こうすれば 容量の大部分を削りながら、イベント単位の柔軟性は維持できます。

⚠️ これは、データ設計のお作法としては善し悪しが分かれる選択です

マートテーブルは本来 「分析目的に応じて作るもの」 です。「イベント関連以外をクエリする」という目的でまとめている以上、筋は通っていると考えていますが、目的の範囲が広すぎる とも言えます。しかも下にDWH層を持っているので、「それはマートではなく、DWHの延長(中間テーブル)だろう」 という指摘は正しいと思います。人によって議論が分かれるところでしょう。

それでも私がこの形にしているのは、理屈上の綺麗さより、テーブルの管理コストと、ユーザーがBIツールを通して分析する際の使い勝手を優先しているから です。ここは意識的なトレードオフです。

結果的に落ち着いた構成 ― DWHを土台に、マートを横に並べる

実運用では、結果的にこういう構成に落ち着いています。

【L0】GA4生エクスポート(analytics_XXXXXXXXX.events_*)
   │
   ▼
【L1】DWH ― イベント粒度を保持したテーブル
   │      ★ すべてのマートが参照する土台
   │
   ├──▶ 【L2】汎用マート(イベント粒度のまま、配列だけを排除)
   │
   └──▶ 【L2】目的別マート/日次集計(粒度も落とした最小構成)

ここで大事なのは、L2の2種類は積み重なっているのではなく、L1(DWH)の上に「並列に」並んでいる という点です。つまり どのマートも、DWHという同じ土台から作られます。

層 何を置くか 誰が使うか
L0 GA4生エクスポート events_YYYYMMDD をそのまま 基盤担当のみ。BIツールからは直接繋がない
L1 DWH イベント粒度は落とさない。 そのうえでURL正規化・チャネル分類・セッションフラグなどの派生列を作り込む すべてのマートの参照先。利用者が直接見ることは想定しない
L2 汎用マート イベント粒度のまま、event_params 等の REPEATED RECORD だけを排除 ★ BIツールとアナリストの標準的な入口。 「とりあえずここを見れば大抵の分析はできる」
L2 目的別マート/日次集計 セッション単位・日次まで 粒度も落とした最小構成 アクセス頻度が高い定番ダッシュボード専用。徹底的に軽くする

そして 汎用マートをきちんと1本用意しておくと、目的別マートは「本当に頻繁に見られるものだけ」で済みます。 目的別マートを無限に作らなくてよくなる、という意味でも、この層は効いています。

DWH層(L1)の役割 ― 派生計算を1回で済ませる

さらに、土台であるDWH層には重要な役割があります。それは 「重い派生計算を、ここで1回だけ済ませておく」 ことです。

DWH層に持たせておくもの なぜここでやるのか
URLの正規化(クエリパラメータを落としたパス) 毎回 REGEXP_EXTRACT を走らせない。さらに、表記ゆれも一箇所で吸収できる
チャネル分類などの CASE 文 「この流入は広告か、SNSか」の判定を、ダッシュボードのクエリ実行時ではなくDWH構築時に1回だけ 行う。利用側は文字列列を見るだけで済む
セッション単位のフラグ(カート追加あり/購入ありなど) 利用側で毎回ウィンドウ関数を書かせない。しかも 定義が人によってブレるのも防げる
テスト・社内トラフィックの除外フラグ 各自が WHERE で除外し忘れる事故を防ぐ。つまり 「除外済みが標準」の状態を作る
特に効くのは「分類ロジックの前倒し」

このうち、特に2番目が効きます。なぜなら 分類ロジックをダッシュボード側に書くと、クエリのたびに巨大な構造体を読んで CASE を評価し直すことになる からです。逆に、これをDWH構築時の1回に寄せるだけで、そのタイルのスキャン量が劇的に落ちることがあります。つまり 「計算を、実行時から構築時に移す」 ―― これもコスト削減の立派な手段です。

⚠️ 外部データをJOINするときは、結合キーの一意性を必ず確認する

まず、DWHやマートに受注データなどをJOINする場合は 結合先が1対1(または多対一)になっているかを、必ず先に確認してください。 結合先が複数行あると、GA4側の行が複製され(fanout)、PV数や売上が黙って水増しされます。

つまり、3章の items の話と同じ構図です。行を増やす操作は、指標を壊します。 JOINする前に GROUP BY で結合キー単位に畳んでおく、というのが安全な作法です。

4-6. 実装 ― 汎用マートのつくり方

では、実際のSQLを見ていきます。ポイントはコメントに書き込んでいます。

このSQLが作るのは「L2の汎用マート」です

前節の3層構成でいうと、下のDDLは L2の汎用マート を作るものです。ただし 分かりやすさのため、L1のDWHを経由せず生エクスポートから直接作る形 で書いています。

実運用でDWH層を挟む場合は、FROM を DWHのテーブル に差し替えてください。そのぶん event_params からの抽出やURL正規化はDWH側に移るので、このSELECT句はもっと短くなります。

掲載しているSQLの前提

検証状況について

本記事のSQLは、BigQueryの dry run(構文と項目パスの検証)を通したうえで掲載しています。 ただし 2026年9月時点のエクスポートスキーマ前提 です。session_traffic_source_last_click のように プロパティやエクスポート開始時期によって存在しない項目もある ため、ご自身のテーブルのスキーマに合わせて調整してください。

SQL

-- 【L2】汎用マート:イベント単位のまま、event_params 等の REPEATED RECORD を排除する
CREATE OR REPLACE TABLE `my-project.mart.ga4_events_base`
PARTITION BY event_date                                -- ① 日付でパーティション分割【絶対条件】
CLUSTER BY event_name, session_source, device_category -- ② よく絞る列でクラスタリング【強く推奨】
OPTIONS (require_partition_filter = TRUE)              -- ③ 期間指定なしのクエリを禁止する【強く推奨】
AS
SELECT
  -- ===== 日付・時刻(タイムゾーンを明示して揃える) =====
  PARSE_DATE('%Y%m%d', event_date)                          AS event_date,
  DATETIME(TIMESTAMP_MICROS(event_timestamp), 'Asia/Tokyo') AS event_datetime,

  -- ===== イベント・ユーザー =====
  event_name,
  user_pseudo_id,
  user_id,

  -- ===== セッションキー(user_pseudo_id と組み合わせて一意にする) =====
  CONCAT(user_pseudo_id, '-', CAST(
    (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS STRING
  )) AS session_key,

  -- ===== event_params から「使うキーだけ」平坦な列として取り出す =====
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page_location,
  REGEXP_EXTRACT(
    (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location'),
    r'^https?://[^/]+([^?#]*)'
  ) AS page_path,
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_title')            AS page_title,
  (SELECT value.int_value    FROM UNNEST(event_params) WHERE key = 'engagement_time_msec')  AS engagement_time_msec,
  (SELECT value.int_value    FROM UNNEST(event_params) WHERE key = 'entrances')             AS entrances,
  COALESCE(
    (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'session_engaged'),
    CAST((SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'session_engaged') AS STRING)
  ) AS session_engaged,

  -- ===== 参照元(スコープごとに、名前で区別できるようリネームしておく) =====
  COALESCE(session_traffic_source_last_click.cross_channel_campaign.source, '(direct)') AS session_source,
  COALESCE(session_traffic_source_last_click.cross_channel_campaign.medium, '(none)')   AS session_medium,
  session_traffic_source_last_click.cross_channel_campaign.campaign_name                AS session_campaign,
  session_traffic_source_last_click.cross_channel_campaign.default_channel_group        AS session_channel_group,
  COALESCE(traffic_source.source, '(direct)') AS first_user_source,   -- ユーザースコープ(初回接触)
  COALESCE(traffic_source.medium, '(none)')   AS first_user_medium,

  -- ===== 端末・地域(RECORD はフラット化。必ず別名を付ける ※後述) =====
  device.category                 AS device_category,
  device.operating_system         AS device_os,
  device.web_info.browser         AS device_browser,
  geo.country                     AS geo_country,
  geo.region                      AS geo_region,
  geo.city                        AS geo_city,

  -- ===== EC(イベント単位の金額。items は載せない) =====
  ecommerce.transaction_id        AS transaction_id,
  ecommerce.purchase_revenue      AS purchase_revenue,

  platform,
  stream_id

-- ★ event_params / user_properties / items は「意図して」載せない。ここが容量削減の本体
FROM `my-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260101' AND FORMAT_DATE('%Y%m%d', CURRENT_DATE('Asia/Tokyo'))

💡 device.operating_system に別名を付け忘れると、列名が operating_system になります

というのも、ネストした項目を別名なしで SELECT すると 出力列名は一番下の階層の名前だけ になるからです。geo.country は country、device.web_info.browser は browser です。

エラーにはなりません。しかし BIツールに並んだときに、それが端末の情報なのか地域の情報なのか分からない列 ができあがります。マートは人が見る前提のテーブルなので、フラット化する項目には必ず接頭辞付きの別名を付ける のを癖にしておくのが安全です。私はこれを最初サボって、後から全部リネームする羽目になりました。

パーティション・クラスタリング・期間フィルタ強制の3点セット

繰り返しになりますが、① PARTITION BY は絶対条件 です。ここを書き忘れると、列を削った効果が期間の伸びで打ち消されていきます。一方 ② CLUSTER BY は強く推奨、という位置づけになります。

設定 位置づけ 効果
PARTITION BY event_date 絶対条件 WHERE event_date BETWEEN … で その期間のパーティションしか読まなくなる。BIツールの期間フィルタが、そのまま課金の削減に直結する
CLUSTER BY event_name, … 強く推奨 WHERE event_name = ‘page_view’ のような定番の絞り込みで さらにスキャン量が減る
require_partition_filter = TRUE 強く推奨 期間を指定していないクエリを、実行前にエラーで弾く。 つまりパーティションの「切りっぱなし」を防ぐ

③ require_partition_filter ― 「絶対条件」を仕組みで守る

ただし、パーティションを切っても 利用者が期間を指定してくれなければ意味がありません。 これを性善説に任せないためのオプションが require_partition_filter です。

そして有効にしておくと、期間で絞っていないクエリは 実行される前に こう弾かれます。

Cannot query over table 'my-project.mart.ga4_events_base' without a filter
over column(s) 'event_date' that can be used for partition elimination

課金される前に止まる のがポイントです。そのため「全期間を舐めるクエリを、うっかり誰かが投げる」という、いちばん高くつく事故を構造的に防げます。要するに、パーティションを切ることが絶対条件なら、これはその絶対条件を運用に定着させるための設定 です。したがって、セットで入れることをお勧めします。

💡 パーティションの絞り込みは「BIツール側の設定」も重要です

ただし、せっかくパーティションを切っても、ダッシュボードのデフォルト期間が「全期間」になっていると、開いた瞬間に全パーティションを読みます。デフォルトは「直近28日」など短い期間にしておく のが鉄則です。ここは地味ですが、実測で一番効いた設定変更のひとつでした。

なお require_partition_filter = TRUE を入れると、BIツール側が期間フィルタを付けずに投げてくる箇所が、エラーになって全部あぶり出せます。 導入直後は少し騒がしいですが、結果的に「どこが無駄にスキャンしていたか」の棚卸しになります。

増分更新(毎日フル再作成はしない)

ここでひとつ注意があります。上のDDLを毎日 CREATE OR REPLACE で回すと、毎回全期間をスキャンすることになり、本末転倒です。 そのため、直近数日だけを作り直す形にします。

SQL

-- 直近4日ぶんだけ作り直す(スケジュールされたクエリで毎日実行)
DECLARE start_date DATE DEFAULT DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 4 DAY);

-- ① 再処理ウィンドウのパーティションを削除
--    (event_date で絞っているのでパーティション単位の削除になり、スキャンは発生しない)
DELETE FROM `my-project.mart.ga4_events_base`
WHERE event_date >= start_date;

-- ② 同じ範囲を入れ直す
INSERT INTO `my-project.mart.ga4_events_base`
SELECT /* 上のDDLと完全に同じ SELECT 句 */
FROM `my-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', start_date)
                        AND FORMAT_DATE('%Y%m%d', CURRENT_DATE('Asia/Tokyo'));

⚠️ なぜ「前日ぶんだけ」ではなく「直近4日」なのか

というのも GA4のエクスポートは、過去日のテーブルが後から書き換わることがある からです。つまり、遅延して届いたイベントが、あとで該当日のテーブルに追加されるわけです。

前日ぶんだけを取り込む実装にすると、その遅延分が永久に欠損します。 私は実務では 直近4日 を再処理ウィンドウにしています。3日でも大きな問題は出ていませんが、日付の境界とバッチの実行時刻が絡むので、1日ぶん余裕を持たせておくほうが安心 です。ここで一度、日次の数字が微妙に少ないまま数週間気づかない、という事故をやりました。

素のSQLの弱点と、Dataform / dbt という答え

ただし、この INSERT INTO … SELECT には弱点があります。列の並び順と型が、テーブル定義と完全に一致している必要がある のです。つまり DDL側とINSERT側で同じSELECT句を二重管理することになる わけで、ここが素のSQLで運用する場合の一番の弱点になります。

そこで実務では、この二重管理を避けるために Dataform や dbt のような変換ツールを使うのが現実的な答え になります。Dataform なら type: “incremental” を指定するだけで、「初回はCREATE、以降は差分だけINSERT」をSELECT句1本のまま面倒を見てくれます。 パーティションと再処理ウィンドウも設定として書けます。

config {
  type: "incremental",
  bigquery: {
    partitionBy: "event_date",
    updatePartitionFilter: "event_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 4 DAY)"
  }
}
確定済みの過去日を直すときは、作り直さず MERGE で

もうひとつ、運用で必ず出てくる話があります。例えば外部データ(受注実績など)をJOINしているマートでは、GA4側は変わっていないのに、JOIN先の値だけが後から変わる ことがあるのです。返品やキャンセルの反映が典型でしょう。

このとき、その日のパーティションを作り直すとどうなるか。GA4の巨大なテーブルを再スキャンすることになり、非常に高くつきます。 そこで私は、確定済みの日については MERGE で「変わった列だけ」をピンポイントに UPDATE する 形にしています。こうすればGA4側の再スキャンをゼロにしたまま、金額だけを最新化できます。

要するに「作り直す(DELETE + INSERT)」と「部分的に直す(MERGE)」を、再処理ウィンドウの内か外かで使い分ける ということです。これは覚えておくと効きます。

粒度を落とす目的別マートを作るときの定石 ― ANY_VALUE

DWHと汎用マートはイベント単位なので GROUP BY は要りません。ところが、目的別マートでセッション単位・日次に粒度を落とすとき には、ひとつ定石があります。

というのも、集計してしまうと device_category や session_source のような 「セッションの中では絶対に変わらない列」も、集計関数を通さないと SELECT できない からです。そこで使うのが ANY_VALUE です。

セッション内で不変な列は ANY_VALUE で残す

具体的には、こう書きます。なお前節のとおり 目的別マートも汎用マートの上に積むのではなく、DWHから直接作ります。

SQL

SELECT
  event_date,
  session_key,
  -- ① セッション内で不変な列は ANY_VALUE で1値だけ残す(GROUP BY を増やさずに済む)
  ANY_VALUE(session_source)    AS session_source,
  ANY_VALUE(session_medium)    AS session_medium,
  ANY_VALUE(device_category)   AS device_category,
  ANY_VALUE(geo_country)       AS geo_country,
  -- ② 加算できる指標は普通に集計する
  COUNTIF(event_name = 'page_view')   AS pv_count,
  COUNTIF(event_name = 'purchase')    AS purchase_count,
  SUM(engagement_time_msec)           AS engagement_time_msec_sum
FROM `my-project.dwh.ga4_events`          -- ★ 汎用マートではなくDWHから直接作る
WHERE event_date BETWEEN '2026-09-01' AND '2026-09-07'
GROUP BY event_date, session_key
GROUP BY に足すか、ANY_VALUE にするか

では、どちらを選ぶのか。判断基準はひとつです。

  • セッション内で値が1つに定まる列 → ANY_VALUE(GROUP BY に足しても行は増えません。ただし、そのぶん管理が煩雑になります)
  • セッション内で複数の値を取りうる列 → GROUP BY に足す(なぜなら ANY_VALUE にすると、どれか1つが選ばれて 他が消える からです)

そして後者を間違えると、エラーにならずに静かに値が落ちます。 例えば地域(geo_*)は、1セッションが移動中だと複数の値を持ち得ます。にもかかわらず「セッション内で不変だろう」と決めつけて ANY_VALUE にすると、集計が合わなくなります。したがって 迷ったら GROUP BY に足して、行数がどれだけ増えるかを実測してから決める のが安全です。

ウィンドウ関数で「セッション単位に持ち上げる」のは慎重に

最後に、もうひとつ落とし穴があります。エンゲージメントのようなフラグを、MAX(…) OVER (PARTITION BY session_key) でセッション全体に広げたくなることがあるのです。ところが、これをやると指標の定義そのものが変わります。

例えばエンゲージセッションのフラグは session_start イベントに付く値です。そのため、それをセッション内の全行に広げると ページ単位で見たときに「本当はそのページでは発生していないフラグ」が立ちます。 実際、私はこれでURL絞り込み時の数字が3倍になったことがあります。

したがって 広げるのではなく、元の粒度のまま持たせる のが正解です。そのうえでBIツール側の指標定義を COUNT(DISTINCT …) にして重複を吸収する。こちらのほうが、GA4側の定義とズレません。

4-7. それでも張っておく、コストの安全網

ただし、マートを作っても 誰かが生テーブルに SELECT * を投げれば一撃です。 そのため、設計だけに頼らず仕組みで止めます。

手段 内容
課金される最大バイト数(maximum bytes billed) クエリ単位の上限。超えるクエリは 実行される前に失敗 します。そのため「保険」として最も確実
カスタム割り当て(custom quota) プロジェクト単位・ユーザー単位で「1日あたりのクエリ処理量」に上限を設定できる。暴走を日次で止められる
予算アラート Cloud Billing の予算とアラート。なにしろ 気づくのが請求日では遅すぎる ので、必ず設定
BIツール側のキャッシュ・抽出 Looker Studio ならデータ抽出(extract)やキャッシュ。つまり 同じクエリを何度も投げさせない
BI Engine 定額の容量課金でインメモリ高速化。そしてオンデマンド課金から切り離せるため、ダッシュボードの利用が多い案件では検討価値が高い

何より重要なのは「誰のどのクエリが高いか」の可視化

そして、仕組みで止める以上に重要なことがあります。それは 「誰の・どのクエリが高いのか」を可視化しておく ことです。幸い、これは INFORMATION_SCHEMA で取れます。

SQL

-- 直近30日で、課金バイト数が多いユーザーを洗い出す
SELECT
  user_email,
  COUNT(*)                                                    AS job_count,
  ROUND(SUM(total_bytes_billed) / POW(1024, 4), 3)            AS billed_tib,
  ROUND(SUM(total_bytes_billed) / POW(1024, 4) * 6.25, 2)     AS est_usd  -- 単価は要確認
FROM `region-asia-northeast1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  AND job_type = 'QUERY'
  AND state    = 'DONE'
GROUP BY user_email
ORDER BY billed_tib DESC

これを見れば、「高いのはダッシュボードなのか、特定の人のアドホッククエリなのか」が一目で分かります。 そして原因の切り分けができれば、対策は具体的になります。逆に、これを見ずに「クエリを減らしましょう」と言っても、何も変わりません。

💡 「改善してほしい」と言われたら、まずここを見る

実際、私の経験上 原因の内訳は毎回違います。 ダッシュボードが原因のこともあれば、誰かが組んだ検証用のスケジュールクエリが毎朝全期間をスキャンし続けていた、ということもありました。

したがって 推測で設計を作り直す前に、実績値で原因を特定する。 これが一番の近道です。


5. まとめ

GA4 × BigQuery は、間違いなく強力な組み合わせです。ただし、GA4の画面という「親切な世界」から、生データという「自由だが何も保証されない世界」に出る ということでもあります。つまり自由と引き換えに、データの意味づけと、コストを自分で背負う ことになる。そして私がぶつかった2つの壁は、どちらもその代償でした。

壁① 項目が膨大で、どれが何のデータか分からない

  • source / medium は3か所以上にあり、違いは「スコープ」
    • traffic_source → ユーザースコープ(初回接触)。ただし名前が短いだけで、「このイベントの参照元」ではない。ここが最大の罠
    • session_traffic_source_last_click → セッションスコープ。GA4の トラフィック獲得レポート を再現するならこれ
    • collected_traffic_source → イベントスコープの生値。UTMの検証用
  • セッションを数えるには user_pseudo_id + ga_session_id の組み合わせが必要。なお session_id という列は存在しない
  • session_engaged は文字列の ‘1’。int_value で取ると エラーにならず静かにゼロになる
  • event_date(プロパティTZ)と event_timestamp(UTC) は基準が違う。そのため、混ぜると日付がズレる
  • _TABLE_SUFFIX は BETWEEN で上下を閉じる。 >= だけで書くと events_intraday_* が混ざり、当日分が二重計上される
  • items の UNNEST は行を増やす。売上を二重計上しないよう指標ごとに使い分ける。外部データのJOINも同じ(fanout)
  • 対処法は 「スキーマから探す」のをやめて「GA4の画面の指標名から逆引きする」。そして 選んだ理由を項目辞書に残す

壁② クエリ容量が膨れ上がる

  • 原因は イベント単位という極端に細かい粒度 × REPEATED RECORD の重さ。1週間で数GBは普通に起きる

課金の仕組みと、爆発する場所

  • 課金は スキャンしたバイト数。SELECT * は最悪、LIMIT は無意味、_TABLE_SUFFIX での期間絞りは必須
  • BIツール × 複数ユーザー × フィルタ操作 で爆発する。つまりフィルタ1操作=クエリ1回
  • 解決策は マートテーブル。ただし価値は「加工してあること」ではなく「軽いこと」
  • マートへの PARTITION BY は「絶対条件」。 なぜなら、パーティションが無いテーブルは期間で絞っても全期間がスキャンされるため、列を削って軽くした効果がデータの蓄積とともに消えていく。GA4の生テーブルは日付シャーディングで自動的に効いているぶん、自分で作るマートで書き忘れやすい最大の落とし穴
  • 削る優先順位は 列(REPEATED RECORD)→ 行 → 粒度。最も効くのは列。ただし これはパーティションを切ったうえでの話
  • require_partition_filter = TRUE をセットで入れる。 期間を指定していないクエリを 課金される前にエラーで弾ける。絶対条件を仕組みで守るための設定

運用でやること

  • CLUSTER BY は強く推奨。BIツール側のデフォルト期間を短くする のも同じくらい効く
  • 配列(REPEATED RECORD)は特に高い。 というのも UNNEST は行数を変える演算なので、中身を1つも参照しないクエリでも配列は必ず読まれる。「配列で持つ」より「フラットにして列を減らす」ほうが安い
  • 分類ロジックは、クエリ実行時ではなくマート構築時に1回だけ計算して列にしておく。つまり計算を実行時から構築時に移すのも、立派なコスト削減
  • 増分更新は「直近3〜4日」の再処理ウィンドウを持たせる。なぜならGA4は過去日のデータが後から書き換わるからです。ウィンドウの外の確定済み日は、作り直さず MERGE で部分更新する
  • 素のSQLだと DDLとINSERTでSELECT句を二重管理する ことになる。Dataform / dbt の incremental を使うのが現実的な答え
  • また、設計に頼りきらず 課金される最大バイト数・カスタム割り当て・予算アラート で仕組みとして止める
  • そして INFORMATION_SCHEMA で「誰の・どのクエリが高いか」を必ず可視化する

最後に ― 「最安の設計」と「使われる設計」は違う

要するに、ここまで書いてきたマートテーブルの話は 技術的な最適解と、運用上の最適解が一致しなかった 例です。

たしかに、目的別に最小構成のマートを並べるほうが、クエリ容量は安く、設計としては綺麗に見えます。それは分かっています。それでも私は、event_params だけを落とした汎用テーブルを1本立てる、という「お作法的には議論が分かれる」形を選びました。

理由は、ユーザーが膨大なテーブルの中から最適なものを探す作業が、データ活用そのもののボトルネックになるから です。つまり、テーブル探しで止まる基盤は使われません。そして 使われないデータ基盤の価値は、設計の美しさに関係なくゼロ です。

一番大事なのは、使ってもらうこと

データ活用支援という仕事をしていると、ここは何度も突きつけられます。一番大事なのは、使ってもらうこと。 したがって、コスト最適化も設計の綺麗さも、その手段でしかない。私が汎用マートという運用形態をとっている一番の理由は、結局そこにあります。

もちろん、これが唯一の正解だとは思っていません。組織の規模や、利用者のSQLスキル、案件の性質によって答えは変わるはずです。この記事を「そういうトレードオフがあるのか」と知った状態で設計を始めるための材料 にしていただければ、それで十分です。

最後にもう一度。冒頭に書いたとおり、当時の私はこの2つの壁について書かれた記事をほとんど見つけられず、手戻りと請求書で学びました。この記事が、その屍を踏み越えるための足場になれば幸いです。


参考リンク

GA4側のドキュメント(3章の裏取りに)

BigQueryのコスト管理(4章の裏取りに)

テーブル設計と運用(4-6の実装まわり)

ご意見・ご相談・料金のお見積もりなど、お気軽にお問い合わせください!

ご相談はこちら

2026年9月25日 GA4×BigQuery活用で苦労したポイント

Category Google Cloud

ご意見・ご相談・料金のお見積もりなど、
お気軽にお問い合わせください。

お問い合わせはこちら