How to unnest GA4 event_params in BigQuery: page_location, ga_session_id and items
In the GA4 BigQuery Export, values like page_location and ga_session_id sit inside the event_params array, one key and four possible value fields per parameter. This guide shows the subquery pattern that reads one parameter per column, which value field each common key uses, a parameter that arrives as two types, and how a plain UNNEST turns 22,096 events into 215,602 rows or drops half of them. Every query runs on Google's public GA4 sample.
In the GA4 BigQuery Export, an event’s details are not columns. They sit in event_params, an array of key-value pairs, and each value is stored in one of four fields: string_value, int_value, float_value or double_value. To read one parameter as a column, use a subquery over the array:
SELECT
event_name,
(SELECT value.string_value FROM UNNEST(event_params)
WHERE key = 'page_location') AS page_location,
(SELECT value.int_value FROM UNNEST(event_params)
WHERE key = 'ga_session_id') AS ga_session_id
FROM `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_20210101`
WHERE event_name = 'page_view';
| event_name | page_location | ga_session_id |
|---|---|---|
| page_view | https://shop.googlemerchandisestore.com/Google+Redesign/Apparel/Google+Dino+Game+Tee | 3209612510 |
| page_view | https://shop.googlemerchandisestore.com/ | 7473279052 |
One row per event, one column per parameter, and nothing multiplied. Change the key and the value field for any other parameter.
Note: Originally published in August 2024. Fully rewritten in October 2026: every query below was run on Google’s public GA4 sample, bigquery-public-data.ga4_obfuscated_sample_ecommerce, on 16 October 2026.
Which value field to read
Pick the wrong field and the column is NULL, with no error: reading ga_session_id from string_value returned NULL on every row. This query shows which field each key uses on one day of the sample:
SELECT
p.key,
COUNT(*) AS occurrences,
COUNTIF(p.value.string_value IS NOT NULL) AS as_string,
COUNTIF(p.value.int_value IS NOT NULL) AS as_int
FROM `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_20210101`,
UNNEST(event_params) AS p
GROUP BY p.key
ORDER BY occurrences DESC;
| key | occurrences | as_string | as_int |
|---|---|---|---|
| page_location | 22096 | 22096 | 0 |
| ga_session_id | 22096 | 0 | 22096 |
| ga_session_number | 22096 | 0 | 22096 |
| page_title | 21985 | 21982 | 0 |
| engaged_session_event | 20776 | 0 | 20776 |
| session_engaged | 20090 | 17890 | 2200 |
| page_referrer | 17241 | 5795 | 0 |
| engagement_time_msec | 12965 | 0 | 12965 |
| source, medium, campaign | 6660 each | 6660 | 0 |
Run it on your own property once and keep the result: it is the map for every query you write later.
A parameter can arrive as two types
session_engaged came as a string 17,890 times and as an integer 2,200 times on the same day. A query that reads only string_value misses 2,200 values. Read both:
(SELECT COALESCE(value.string_value, CAST(value.int_value AS STRING))
FROM UNNEST(event_params)
WHERE key = 'session_engaged') AS session_engaged
Custom parameters do this often when different pages or apps send them differently. The type check above finds them.
Sessions: ga_session_id is not unique on its own
Sessions: ga_session_id is not unique on its own
ga_session_id is a timestamp of when the session started, so two users can share one. A session is the pair user_pseudo_id and ga_session_id:
COUNT(DISTINCT CONCAT(
user_pseudo_id, '-',
CAST((SELECT value.int_value FROM UNNEST(event_params)
WHERE key = 'ga_session_id') AS STRING)
)) AS sessions
A full sessions-per-day query, with its result, is in BigQuery query examples.
OWOX Data Marts
See your first report built in real time. 15 minutes.
- Connect your data warehouse
- Pick your metrics
- Get a live Google Sheets report
In the time it takes to write a ticket. Then imagine never writing that ticket again.
Book a DemoWe'll use your actual use case
What a plain UNNEST does to row counts
What a plain UNNEST does to row counts
The other way to read the array, joining it to the table, multiplies rows:
| Query on one day (22,096 events) | Rows |
|---|---|
FROM events_20210101 |
22,096 |
FROM events_20210101, UNNEST(event_params) |
215,602 |
FROM events_20210101, UNNEST(items) |
18,716 |
FROM events_20210101 LEFT JOIN UNNEST(items) |
38,427 |
UNNEST(event_params)gives one row per parameter, about ten per event. AnyCOUNT(*)orSUMafter it is ten times too large. Use it only to explore keys, as in the type query above., UNNEST(items)drops every event without items. 19,711 of the 22,096 events had none, and they disappear. UseLEFT JOIN UNNEST(items)when the other events must stay.
The items array
Ecommerce events carry products in items, which is itself an array of records. Unnest it when the question is about products, and keep event-level totals out of that query:
SELECT
i.item_name,
SUM(i.quantity) AS quantity,
ROUND(SUM(i.item_revenue_in_usd), 2) AS revenue
FROM `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*`,
UNNEST(items) AS i
WHERE _TABLE_SUFFIX BETWEEN '20210101' AND '20210107'
AND event_name = 'purchase'
GROUP BY i.item_name;
ecommerce.purchase_revenue_in_usd is an order total. Summed after unnesting items, it is counted once per product line. The result of this query and the fan-out effect are in query examples and JOIN and UNION.
Keep the cost down
- Always filter
_TABLE_SUFFIXonevents_*. The wildcard tables guide measures the difference. - Each subquery over
event_paramsreads the whole array column. Ten parameter columns cost about the same as one; the column is read once. What drives the cost is the number of days. - Flatten once. For daily reporting, a scheduled query that turns yesterday’s events into a table with one column per parameter you use is cheaper and simpler than unnesting in every report. The export itself is covered in the GA4 to BigQuery guide.
One flat table, one definition
One flat table, one definition
Every team that uses the GA4 export ends up writing the same unnesting: page location, session ID, source and medium, the session key. Copied into each report, it drifts: one copy reads session_engaged as a string only, another forgets the user in the session key. In OWOX Data Marts that flattening query is published once as a Data Mart, with a description per column, and reports in Google Sheets or dashboards read the flat result. OWOX Data Marts runs your SQL as written, in your own BigQuery project, and does not transform the data. Arrays in general are in the array functions guide.
Turn your data into decisions.
Governed data marts give you the clean foundation ML needs to actually work.
- No AI hallucinations
- Analyst-governed definitions
- Every number traces to SQL
Frequently Asked Questions
How do I count GA4 sessions in BigQuery?
A session is the pair of user_pseudo_id and the ga_session_id parameter: COUNT(DISTINCT CONCAT(user_pseudo_id, '-', CAST(ga_session_id AS STRING))). ga_session_id alone is not unique, because two users can start a session at the same moment.
How do I get page_location from the GA4 export in BigQuery?
With a subquery over event_params: (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page_location. It returns one value per event and does not multiply rows.
What are event_params in the GA4 BigQuery Export?
An array of key-value pairs on each event. Each value sits in one of four fields, string_value, int_value, float_value or double_value, depending on its type; page_location is a string and ga_session_id an integer.
Why does my GA4 parameter come back NULL in BigQuery?
Usually the wrong value field: ga_session_id is stored in int_value, so reading string_value returns NULL with no error. Some parameters arrive in two types: in Google's sample, session_engaged was a string 17,890 times and an integer 2,200 times in one day. Read both with COALESCE.
Why does UNNEST multiply rows in GA4 queries?
Joining the table to UNNEST(event_params) creates one row per parameter: 22,096 events became 215,602 rows in our test, so counts and sums after it are about ten times too large. A comma join to UNNEST(items) also drops events with no items; use LEFT JOIN UNNEST(items) to keep them.
