2026年9月25日 GA4×BigQuery活用で苦労したポイント BigQuery Google Cloud 検索する Popular tags 事例紹介 GEN-STEP 生成AI(Generative AI) Vertex AI Search Looker Studio BigQuery AlloyDB Google Workspace Cloud SQL Category Google Cloud Author Tera SHARE 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章の裏取りに) エクスポートされるスキーマの定義(Google アナリティクス ヘルプ) BigQuery Export の設定方法と制限事項 データ保持期間の設定について 試すだけなら便利な、GA4のBigQueryサンプルデータセット BigQueryのコスト管理(4章の裏取りに) 料金体系の一覧(Google Cloud) クエリのコストを見積もる・制御する カスタム割り当てでクエリ使用量に上限を設ける ジョブのメタデータを調べる(INFORMATION_SCHEMA.JOBS) BI Engine の概要 テーブル設計と運用(4-6の実装まわり) パーティション分割テーブルの概要 require_partition_filter を設定する(パーティション分割テーブルの管理) クラスタ化テーブルの概要 Dataform で増分テーブルを作成する(type: “incremental”) ご意見・ご相談・料金のお見積もりなど、お気軽にお問い合わせください! ご相談はこちら 頂きましたご意見につきましては、今後のより良い商品開発・サービス改善に活かしていきたいと考えております。 面白かった 面白くなかった 興味深かった 興味深くなかった 使ってみたい Author Tera 2024年4月に新卒入社、理系出身でAIエージェントやデータ分析の業務を行っています。趣味はテニスでスクールにも通っています。 BigQuery Google Cloud 2026年9月25日 GA4×BigQuery活用で苦労したポイント Category Google Cloud 前の記事を読む 【応用編】Veoを使った長尺動画作成方法 Recommendation オススメ記事 2023年9月5日 Google Cloud 【Google Cloud】Looker Studio × Looker Studio Pro × Looker を徹底比較!機能・選び方を解説 2023年8月24日 Google Cloud 【Google Cloud】Migrate for Anthos and GKEでVMを移行してみた(1:概要編) 2022年10月10日 Google Cloud 【Google Cloud】AlloyDB と Cloud SQL を徹底比較してみた!!(第1回:AlloyDB の概要、性能検証編) GEN-STEP Gemini Enterprise 導入パッケージ 生成AI導入支援サービス Google Cloud 資料ダウンロード 新着記事 2026年9月25日 Google Cloud GA4×BigQuery活用で苦労したポイント 2026年9月25日 Google Cloud 【応用編】Veoを使った長尺動画作成方法 2026年9月24日 Google Cloud 【2026年9月版】Antigravity活用術~Hook完全攻略!ライフサイクルイベントによる自動ガードレールと品質統制~ HOME Google Cloud GA4×BigQuery活用で苦労したポイント