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').
Consent and traffic source
| 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:00–23: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
- Zero engaged sessions when filtering
value.int_value = 1— cause: web export storessession_engagedas the string'1'invalue.string_value, soint_valueisNULL— fix: readvalue.string_value, or defensivelyCOALESCE(value.string_value, CAST(value.int_value AS STRING)) = '1'. - Engaged counts inflated or a whole session misclassified — cause:
session_engagedis not written on every event, only on engagement-bearing events — fix: promote to session scope withMAX(session_engaged) OVER (PARTITION BY CONCAT(user_pseudo_id, ga_session_id))before counting. COUNT(*)/SUM()inflated afterUNNEST— cause:UNNEST(event_params)in theFROMclause 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.- 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. - Sessions crossing midnight are undercounted — cause: events partition into
events_YYYYMMDDbyevent_date(property TZ), so one session splits across two daily tables — fix: queryevents_*with a buffered_TABLE_SUFFIXrange and aggregate byCONCAT(user_pseudo_id, ga_session_id)across the range. - 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 viaevents_*; union intraday only when needed and treat it as provisional. - Grouped totals by a high-cardinality dimension exceed the UI — cause: the UI folds any dimension with
> 500unique 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. user_pseudo_iduser 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 withis_active_user = TRUEand set the UI to Device-ID identity.
Quick recipes
-- UI-matching session count: ga_session_id alone is not globally uniqueSELECT COUNT(DISTINCT CONCAT( user_pseudo_id, (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') )) AS sessionsFROM `analytics_<property_id>.events_*`WHERE _TABLE_SUFFIX BETWEEN '20201201' AND '20201231';-- Read session_engaged as a string, promote to session scope, then countWITH 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_sessionsFROM sess;-- 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_numberFROM `analytics_<property_id>.events_*`WHERE _TABLE_SUFFIX = '20201231';-- Average transactions per purchaser (official pattern)SELECT COUNT(*) / COUNT(DISTINCT user_pseudo_id) AS avg_transaction_per_purchaserFROM `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*`WHERE _TABLE_SUFFIX BETWEEN '20201201' AND '20201231' AND event_name IN ('in_app_purchase', 'purchase');-- Each user's most recent ga_session_id via a descending windowSELECT 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_idFROM `analytics_<property_id>.events_*`WHERE _TABLE_SUFFIX BETWEEN '20201201' AND '20201231';-- Robust numeric parameter read: COALESCE across the numeric value.* fieldsSELECT event_timestamp, (SELECT COALESCE(value.int_value, value.float_value, value.double_value) FROM UNNEST(event_params) WHERE key = 'value') AS event_valueFROM `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*`WHERE _TABLE_SUFFIX BETWEEN '20201201' AND '20201231' AND event_name = 'purchase';Related
- Hub: GA4 guides
- GA4 limits reference — export volume caps and cardinality thresholds
- Consent Mode v2 reference — pairs with the
privacy_info.*consent fields
Sources
- BigQuery Export schema — Analytics Help (read 2026-07-20)
- Basic queries for GA4 event data export — Google for Developers (read 2026-07-20)
- Advanced queries — Google for Developers (read 2026-07-20)
- Compare Analytics reports and data exported to BigQuery — Analytics Help (read 2026-07-20)
- BigQuery Export — Analytics Help (read 2026-07-20)
- Reporting surfaces comparison — Analytics Help (read 2026-07-20)
- Session — Analytics Help (read 2026-07-20)
Changelog
- — Initial version, verified against official documentation.