GA4 BigQuery SQL cookbook

last verified · against GA4 BigQuery export schema as of 2026-07

GA4 BigQuery export reference: event_params schema, sessionization SQL keyed on ga_session_id, engaged-session gotchas, and UI-vs-BigQuery reconciliation.

What this is

A lookup reference for querying the GA4 BigQuery event export: the nested schema, the read patterns for event_params, the sessionization primitive, and the reasons BigQuery counts diverge from the GA4 UI. Every value below is drawn from Google’s export schema, basic/advanced query docs, and the reporting-surface comparison pages.

Core reference tables

Top-level columns (one row per event)

Field Type Notes
event_date STRING YYYYMMDD in the property’s registered (reporting) time zone
event_timestamp INTEGER Microseconds, UTC — time the event was received
event_name STRING Event name
user_pseudo_id STRING Pseudonymous id (app instance / client ID)
user_id STRING Id you assign
is_active_user BOOLEAN True if user was active at any point in the calendar day; underpins the default Active users metric
event_params RECORD REPEATED Per-event key/value parameters
user_properties RECORD REPEATED Per-user key/value properties
ecommerce RECORD Order-level ecommerce totals
items RECORD REPEATED Per-line products with nested item_params
privacy_info RECORD Per-event consent state
traffic_source RECORD User FIRST-touch acquisition (static)
session_traffic_source_last_click RECORD Session last-click attribution

event_params value structure

Exactly one value.* field is populated per parameter; the others are NULL.

Path Type
event_params.key STRING
event_params.value.string_value STRING
event_params.value.int_value INTEGER
event_params.value.double_value FLOAT
event_params.value.float_value FLOAT

user_properties mirrors this shape and adds one extra field that distinguishes it: user_properties.value.set_timestamp_micros (INTEGER).

Common event parameters and how to read them

key Read from Type Meaning
ga_session_id value.int_value INTEGER Session id — unique per user, can repeat across users
ga_session_number value.int_value INTEGER Count of sessions the user has started incl. current; = 1 is first session
session_engaged value.string_value STRING '1' engaged / '0' not, in the web export (literal string, not int)
engagement_time_msec value.int_value INTEGER Incremental foreground/active time in ms; SUM() then /1000 for seconds
value COALESCE(int_value, float_value, double_value) numeric Robust numeric read across the value fields

Note: session_engaged living in value.string_value (the literal '1' / '0') is documented for the web export by practitioner sources, but Google’s official schema page does not state which value.* field it uses or guarantee it is identical across web and app streams. Coalesce defensively where streams may differ — see the Gotchas section.

Session definitions and metrics

Item Value
Engaged session Lasts > 10s OR has >= 1 key event OR has >= 2 page/screen views (any one qualifies)
Engaged-session timer Default 10s, configurable per web stream up to a maximum of 60s
UI-matching session count COUNT(DISTINCT CONCAT(user_pseudo_id, ga_session_id))
Alternative session count COUNTIF(event_name = 'session_start') — returns a different number than the CONCAT method
Engaged sessions COUNT(DISTINCT ...) of sessions where session-scoped session_engaged = '1'

Ecommerce fields

Field Type
ecommerce.purchase_revenue FLOAT
ecommerce.purchase_revenue_in_usd FLOAT
ecommerce.transaction_id STRING
ecommerce.unique_items numeric
ecommerce.total_item_quantity numeric
items RECORD REPEATED (nested item_params)

Purchase-type events: event_name IN ('in_app_purchase', 'purchase').

Field Values / scope
privacy_info.analytics_storage 'Yes' / 'No' / 'Unset'
privacy_info.ads_storage 'Yes' / 'No' / 'Unset'
privacy_info.uses_transient_token 'Yes' / 'No' / 'Unset'
traffic_source.{name, medium, source} User FIRST-touch — does NOT change on later campaigns
session_traffic_source_last_click.* Session last-click: manual_campaign, google_ads_campaign, cross_channel_campaign, sa360_campaign, dv360_campaign, cm360_campaign

Datasets, tables, and export limits

Item Value
Dataset name analytics_<property_id>
Daily table events_YYYYMMDD
Streaming/intraday table events_intraday_YYYYMMDD (staging; deleted once the daily table completes)
Update window Daily table may keep updating up to 3 days after the event date
Intraday updates Continuous, 00:00:0023:59:59 in the property’s time zone
Standard (free) export limit ~1M events/day; export can stop/pause if a property exceeds it
Analytics 360 limit Up to ~20B events/day
Streaming export No volume limit, but billed
Public practice dataset bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*

Note: export volume limits and the exact behavior when the Standard limit is exceeded are periodically revised by Google — confirm the current caps against the BigQuery Export doc before relying on them.

Why BigQuery differs from the GA4 UI

Reason Detail
Reporting identity BigQuery exports Device-ID identity only; UI Blended/Observed will not match
Time-zone mismatch UI vs BigQuery may compare different periods; day boundaries align to event_date (property TZ)
Excluded streams/events Match filters in an exploration to compare accurately
Sampling Reports/Explorations/Data API can sample over quota; BigQuery applies none
High-cardinality UI collapses dimensions with > 500 unique values/day into an (other) row; BigQuery has none
Modeling / Signals No data-driven attribution, key-event modeling, behavioral (consent) modeling, or Google Signals in BigQuery
Expected band A 2–5% event-count discrepancy is documented as normal

Gotchas

  1. Zero engaged sessions when filtering value.int_value = 1 — cause: web export stores session_engaged as the string '1' in value.string_value, so int_value is NULL — fix: read value.string_value, or defensively COALESCE(value.string_value, CAST(value.int_value AS STRING)) = '1'.
  2. Engaged counts inflated or a whole session misclassified — cause: session_engaged is not written on every event, only on engagement-bearing events — fix: promote to session scope with MAX(session_engaged) OVER (PARTITION BY CONCAT(user_pseudo_id, ga_session_id)) before counting.
  3. COUNT(*)/SUM() inflated after UNNEST — cause: UNNEST(event_params) in the FROM clause cross-joins each event to one row per parameter — fix: use a correlated scalar subquery (SELECT value.int_value FROM UNNEST(event_params) WHERE key = '...') to keep one row per event.
  4. BigQuery session count is a few percent off the UI and never ties — cause: by design (UI sampling, (other) collapse, modeling, Google Signals, reporting identity) — fix: accept the 2–5% band; set both to Device-ID identity, match time zone, exclude the same streams/events, and treat BigQuery as the raw source.
  5. Sessions crossing midnight are undercounted — cause: events partition into events_YYYYMMDD by event_date (property TZ), so one session splits across two daily tables — fix: query events_* with a buffered _TABLE_SUFFIX range and aggregate by CONCAT(user_pseudo_id, ga_session_id) across the range.
  6. Counts change between runs, or today is partial — cause: daily tables can update for up to 3 days, and same-day data lives in events_intraday_YYYYMMDD — fix: for finalized reporting query dates older than the 3-day window via events_*; union intraday only when needed and treat it as provisional.
  7. Grouped totals by a high-cardinality dimension exceed the UI — cause: the UI folds any dimension with > 500 unique values/day into (other); BigQuery keeps every value — fix: expected; replicate the collapse in SQL (rank, bucket beyond top N) only if you must match the UI.
  8. user_pseudo_id user counts are below the UI’s Active/Total users — cause: the default UI metric is Active users plus behavioral modeling and Signals de-duplication, none exported — fix: compare like-for-like with is_active_user = TRUE and set the UI to Device-ID identity.

Quick recipes

sessions.sql
-- UI-matching session count: ga_session_id alone is not globally unique
SELECT
COUNT(DISTINCT CONCAT(
user_pseudo_id,
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id')
)) AS sessions
FROM `analytics_<property_id>.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20201201' AND '20201231';
engaged-sessions.sql
-- Read session_engaged as a string, promote to session scope, then count
WITH ev AS (
SELECT
CONCAT(
user_pseudo_id,
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id')
) AS unique_session_id,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'session_engaged') AS session_engaged
FROM `analytics_<property_id>.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20201201' AND '20201231'
),
sess AS (
SELECT
unique_session_id,
MAX(session_engaged) OVER (PARTITION BY unique_session_id) AS session_engaged
FROM ev
)
SELECT COUNT(DISTINCT CASE WHEN session_engaged = '1' THEN unique_session_id END) AS engaged_sessions
FROM sess;
get-param-int.sql
-- Reusable temp UDF for a named integer parameter (avoids repeated UNNEST)
CREATE TEMP FUNCTION GetParamInt(event_params ANY TYPE, param_name STRING) AS (
(SELECT ANY_VALUE(value.int_value) FROM UNNEST(event_params) WHERE key = param_name)
);
SELECT
event_timestamp,
GetParamInt(event_params, 'ga_session_id') AS ga_session_id,
GetParamInt(event_params, 'ga_session_number') AS ga_session_number
FROM `analytics_<property_id>.events_*`
WHERE _TABLE_SUFFIX = '20201231';
avg-transactions.sql
-- Average transactions per purchaser (official pattern)
SELECT
COUNT(*) / COUNT(DISTINCT user_pseudo_id) AS avg_transaction_per_purchaser
FROM `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20201201' AND '20201231'
AND event_name IN ('in_app_purchase', 'purchase');
latest-session.sql
-- Each user's most recent ga_session_id via a descending window
SELECT DISTINCT
user_pseudo_id,
FIRST_VALUE(
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id')
) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp DESC) AS latest_session_id
FROM `analytics_<property_id>.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20201201' AND '20201231';
event-value.sql
-- Robust numeric parameter read: COALESCE across the numeric value.* fields
SELECT
event_timestamp,
(SELECT COALESCE(value.int_value, value.float_value, value.double_value)
FROM UNNEST(event_params) WHERE key = 'value') AS event_value
FROM `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20201201' AND '20201231'
AND event_name = 'purchase';

Sources

Changelog

  • — Initial version, verified against official documentation.

dataLayer

0 events · 0 sent

    • home /
      writing /writing
      guides /guides
      work /#work
      about /about
      colophon /colophon
      toggle analyst mode ctrl+.
      print session receipt /#receipt