# A practical guide to the GA4 BigQuery export schema

**Author:** Piotr Litwa - GTM & Analytics Specialist  
**Published:** 2026-04-10  
**URL:** https://piotrlitwa.com/articles/en/ga4-bigquery-export-schema.html  
**Language:** en  
**Keywords:** ga4 bigquery export schema, ga4 bigquery schema, event_params unnest, ga4 export table structure, ga4 bigquery nested fields

---

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](https://piotrlitwa.com/articles/en/ga4-bigquery-setup.html) covers that part; this one assumes data is already landing and you need to understand its shape.

<div class="key-takeaway">
<strong>Key takeaway:</strong> The GA4 export stores one row per event in daily-sharded `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.
</div>

## 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_*`** and **`users_*`**: 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_value`
- `value.int_value`
- `value.float_value`
- `value.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](https://piotrlitwa.com/articles/en/bigquery-cost-optimization.html) covers keeping the bill sane while you explore. And if BigQuery itself is still new to you, the [plain-English BigQuery explainer](https://piotrlitwa.com/articles/en/bigquery-what-is-it-guide.html) 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](https://support.google.com/analytics/answer/7029846) 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](https://piotrlitwa.com/articles/en/ga4-consultant.html) does, and it saves weeks of nulls and double-counts. If you're still getting your bearings in GA4 itself, the [GA4 explainer](https://piotrlitwa.com/articles/en/google-analytics-4-what-is-it-how-it-works.html) 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.

---

*Written by [Piotr Litwa](https://piotrlitwa.com/about.html) - independent GTM & Analytics specialist.*
