A practical guide to the GA4 BigQuery export schema
The first time you open the GA4 export in BigQuery and run SELECT * FROM events_*, it looks deceptively simple, until you try to read a single page URL and realize it's buried three levels deep inside something called event_params. That moment is where most people get stuck, and it's not your SQL that's the problem. It's that the GA4 schema is shaped differently from anything you've queried before.
Here's the mental model that fixes it. The GA4 BigQuery export is one row per event, stored in daily tables, with the interesting details tucked inside nested, repeated fields you have to UNNEST to read. Once that clicks, the whole schema stops being intimidating and starts being the most powerful version of your analytics data you'll ever touch.
This guide walks the structure: the tables, the top-level columns, the nested fields everyone trips on, and how to actually pull a value out. If you haven't switched the export on yet, the GA4 BigQuery setup guide covers that part; this one assumes data is already landing and you need to understand its shape.
events_YYYYMMDD tables. The columns you care about most, page URLs, session IDs, custom parameters, live inside repeated RECORD fields like event_params, and you read them with an UNNEST subquery. Master that one pattern and the rest of the schema is straightforward.
The shape of the export
When you link GA4 to BigQuery, you get a dataset named analytics_<property_id>, and inside it a set of tables that follow a fixed naming pattern.
events_YYYYMMDD: one table per day, the daily export. This is where almost all your queries go.events_intraday_YYYYMMDD: a same-day, streaming table if you enabled streaming export. It's continuously updated and gets replaced by the final daily table once the day closes.pseudonymous_users_*andusers_*: user-level tables, available with the newer export options, holding the latest state per user rather than per event.
The daily tables are sharded, not partitioned, which matters for how you query across dates. To scan a date range you use a wildcard table and filter on the suffix:
SELECT event_name, event_date
FROM `your-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260101' AND '20260131'
_TABLE_SUFFIX is the YYYYMMDD part of each table name. Filtering on it is what keeps BigQuery from scanning your entire history (and your whole budget) on every query.
One row equals one event
This is the heart of the schema. Every interaction, every page_view, scroll, purchase, custom event, is a single row. There's no "sessions table" and no "pageviews table" the way Universal Analytics had. A session is something you reconstruct from events that share a ga_session_id.
The top-level columns describe the event itself. These are the ones you can read directly, no unnesting required:
| Column | Type | What it is |
|---|---|---|
event_date | STRING | The date, as YYYYMMDD |
event_timestamp | INTEGER | When it fired, in microseconds since epoch |
event_name | STRING | page_view, purchase, your custom name |
user_pseudo_id | STRING | The device/client identifier |
user_id | STRING | Your own logged-in ID, if you set one |
event_params | RECORD (repeated) | The per-event parameters (nested) |
user_properties | RECORD (repeated) | User-scoped properties (nested) |
items | RECORD (repeated) | E-commerce line items (nested) |
device, geo, traffic_source | RECORD | Structured context (nested, not repeated) |
Notice that everything genuinely useful, the parameters, properties, and items, is a RECORD. That's BigQuery's name for a nested structure, and the repeated ones are arrays. That's the part worth slowing down on.
The nested fields everyone trips on
event_params is the one you'll fight first. It's a repeated record, an array, where each entry is a key/value pair. So a single page_view row carries an event_params array that might hold page_location, page_title, ga_session_id, engagement_time_msec, and any custom parameters you sent.
The twist that catches everyone: the value isn't a plain field. It's itself a record with four typed sub-fields, and only one of them is populated per parameter:
value.string_valuevalue.int_valuevalue.float_valuevalue.double_value
A page_location lives in string_value. A ga_session_id lives in int_value. Pick the wrong one and you get NULL, with no error to tell you why. user_properties works exactly the same way, key plus a typed value, and items is an array of records with its own fields like item_id, item_name, price, and quantity.
How to actually read a parameter
You don't reach into event_params with dot notation. You UNNEST it in a subquery and filter for the key you want. This is the single most important pattern in the whole schema:
SELECT
event_date,
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 session_id
FROM `your-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX = '20260410'
AND event_name = 'page_view'
Each (SELECT ... FROM UNNEST(event_params) WHERE key = '...') pulls one parameter into its own column, picking the right typed sub-field. Once you've written it twice, it's muscle memory. Build a base query that flattens the five or six parameters you use constantly and save it; you'll reuse it on every project.
When Daniel, an analyst new to the export, tried to total a custom value parameter with a plain SUM(event_params.value), BigQuery threw type errors, and when he forced it, he got nulls. The data was fine. He was summing a struct instead of reaching into value.double_value through an UNNEST. Ten minutes of confusion, one pattern to learn, never a problem again.
Where your custom dimensions actually land
This is the question every GA4 setup eventually raises: you registered custom dimensions in the interface, so where are they in the export? The answer depends entirely on the scope you gave them, and the schema mirrors that scope exactly.
| Custom dimension scope | Where it lands | How you read it |
|---|---|---|
| Event-scoped | event_params | UNNEST(event_params) on its parameter name |
| User-scoped | user_properties | UNNEST(user_properties) on its property name |
| Item-scoped | items | UNNEST(items), then the item field |
So a custom parameter you send with an event, say content_group or logged_in_status, shows up as a key inside event_params, read with the same UNNEST pattern as any built-in parameter. A user-scoped dimension like membership_tier lands in user_properties instead, because it describes the person, not the single event. And item-scoped custom dimensions ride along inside the items array, next to item_id and price.
The practical upshot: there's no separate "custom dimensions" table to look in, and nothing is renamed for you. Whatever name you sent the parameter under is the exact key you filter on in BigQuery. If you can't find a dimension, you're almost always looking in the wrong scope's record, not missing data. Check how it was registered in GA4, then unnest the matching field.
Gotchas worth knowing before you trust a number
The schema is logical once you know it, but a handful of details will quietly skew results if you miss them.
| Gotcha | Why it bites | What to do |
|---|---|---|
| Sharded, not partitioned | WHERE event_date = ... still scans every table | Filter on _TABLE_SUFFIX with events_* |
| Microsecond timestamps | event_timestamp is micros, not millis | Divide by 1,000,000, or use TIMESTAMP_MICROS() |
Typed value sub-fields | Wrong sub-field returns NULL silently | Match the type: string vs int vs double |
| Intraday plus daily | Querying both double-counts today | Query daily tables; treat intraday as provisional |
| Late-arriving data | Today's table fills in over ~72 hours | Use closed days for anything you report on |
| No unique event ID | Dedup isn't built in | Build a key from user_pseudo_id + timestamp + name |
The intraday-plus-daily one catches people running events_* without thinking. If both events_20260410 and events_intraday_20260410 exist, a wildcard scan reads both, and your current day looks inflated. For anything you'll act on, query closed daily tables and leave intraday for live debugging.
Two patterns that cover most of your work
Beyond flattening parameters, two query shapes do most of the heavy lifting. First, rebuilding a session metric: count distinct ga_session_id per user_pseudo_id to get sessions, since the schema doesn't hand you one. Second, e-commerce from items: CROSS JOIN UNNEST(items) to explode each purchase into its line items, then aggregate revenue by item_name or item_category. Those two, plus the parameter-flattening base query, are 80% of what real analysis on the export looks like.
If your queries start getting expensive as you do this, that's a separate skill, and the BigQuery cost optimization guide covers keeping the bill sane while you explore. And if BigQuery itself is still new to you, the plain-English BigQuery explainer is the place to start before the schema.
When you actually need the raw export
You don't query BigQuery for everything. The standard GA4 reports are faster for day-to-day questions, and they don't cost anything to run. The raw export earns its place when you hit the walls the interface puts up: you need unsampled data, you want to join GA4 against your CRM or cost data, you're modeling custom attribution, or you need event-level detail the reports aggregate away. The official GA4 export schema reference is worth bookmarking for the full field list.
For most teams, the export becomes essential the moment a question starts with "can we combine our GA4 data with..." That's also the point where it's worth having someone who's lived in this schema set up your base queries and data model. That's part of what a GA4 consultant does, and it saves weeks of nulls and double-counts. If you're still getting your bearings in GA4 itself, the GA4 explainer covers the model the export is built on.
FAQ
What is the GA4 BigQuery export schema?
It's the structure GA4 uses when it streams your raw event data into BigQuery: a dataset named analytics_<property_id> containing daily events_YYYYMMDD tables, where each row is a single event and the detailed parameters are stored in nested, repeated RECORD fields like event_params, user_properties, and items.
Why do I need UNNEST to query GA4 in BigQuery?
Because the parameters you want, like page_location or ga_session_id, live inside event_params, which is a repeated record (an array) rather than a plain column. UNNEST flattens that array so you can filter to a specific key and read its value, usually as a subquery that returns one parameter per column.
Why is event_params.value returning NULL?
Because value is a record with four typed sub-fields, string_value, int_value, float_value, and double_value, and only the one matching the parameter's type is populated. If you read value.int_value for a parameter stored as a string, you get NULL with no error. Match the sub-field to the parameter's actual type.
What's the difference between events and events_intraday tables?
events_YYYYMMDD is the finalized daily table, while events_intraday_YYYYMMDD is a continuously updated same-day table that only exists if you enabled streaming export. The intraday table is provisional and gets replaced by the daily table once the day closes, so querying both at once double-counts the current day.
Are GA4 BigQuery tables partitioned or sharded?
They're sharded, meaning one physical table per day with a date suffix, not a single partitioned table. To query a date range efficiently you use a wildcard table (events_*) and filter on _TABLE_SUFFIX, which limits how many daily tables BigQuery scans and keeps query costs down.
Is the event_timestamp in seconds or milliseconds?
Neither, it's in microseconds since the Unix epoch. To convert it to a readable timestamp, use TIMESTAMP_MICROS(event_timestamp), or divide by 1,000,000 if you need seconds. Treating it as milliseconds is a common mistake that puts your events thousands of years into the future.
Drowning in nulls and double-counts?
I set up GA4 BigQuery exports, base queries, and data models so your raw event data is actually usable, not a wall of nested fields. See how that works.
See analytics services