Why your GA4 export to BigQuery needs a data model, and how to build it
The GA4 export to BigQuery is raw material: one row per event, with the useful fields nested inside arrays. Reports need sessions and conversions, which the export does not contain as tables. This guide shows the data model to build on top of it, with SQL you can run on Google's public GA4 sample, and where the definitions should live once they are written.
The GA4 export to BigQuery gives you every event, and none of the tables a report needs. There is no sessions table and no conversions table. There is one row per event, in a table per day, with most of the useful fields nested inside arrays. User tables exist only if you turn on the optional user-data export, and they hold user attributes, not sessions.
A GA4 model is the set of tables you build on that export so that a question like “sessions and revenue by channel” is one short query that returns the same answer for everyone. This guide shows the model, the SQL for its central table, and the one decision that most often makes two analysts’ numbers disagree.
What the GA4 BigQuery Export contains
What the GA4 BigQuery Export contains
The export writes one table per day, named events_YYYYMMDD, plus an events_intraday_ table for the current day if streaming is on. Each row is one event.
Three things about the shape matter for modeling:
- Event parameters are nested.
event_paramsis an array of key and value records. The page URL, the session id and the engagement time are all inside it, so reading one of them needsUNNEST. - There is no session row. A session exists only as a
ga_session_idparameter repeated on each of its events. - Users are pseudonymous ids.
user_pseudo_ididentifies a browser or device.user_idis filled only if the site sends it.
Google documents the full schema in the GA4 BigQuery Export schema reference.
The user-data export is a separate option. Google’s user-data queries page (checked October 2026) says it “creates two tables for each day”: users_YYYYMMDD, with a row for every user ID that changed, and pseudonymous_users_YYYYMMDD, with a row for every pseudonymous identifier that changed. They carry lifetime totals, audiences and predictions per user. They do not carry sessions, and a day’s table lists only the users whose data changed that day, not every user.
Why numbers from the raw export disagree
Why numbers from the raw export disagree
Because the export has no sessions table, every analyst who needs sessions builds one inside their own query. The definitions differ in small ways, and the totals differ with them.
The commonest example is the session key. ga_session_id is the timestamp of the session start, in seconds. Two users who start a session in the same second get the same value. It identifies a session only together with user_pseudo_id.
On Google’s public GA4 sample dataset, for January 2021:
| How sessions are counted | Result |
|---|---|
COUNT(DISTINCT ga_session_id) |
114,882 |
| Distinct user and session id together | 118,380 |
The first query is not a syntax error and its answer looks reasonable. It is 3,498 sessions short, about 3%. A conversion rate built on it is wrong by the same margin, and nothing flags it.
The same kind of choice appears for every derived number: which events count as a conversion, whether a session that crosses midnight is one session or two, which source a session is credited to. A data model is where each choice is made once.
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
The GA4 data model: three tables
The GA4 data model: three tables
A workable model has three layers. Each is a query on the one below.
| Table | One row per | Built from | Answers |
|---|---|---|---|
| Events, flattened | Event | The raw export | What happened, with parameters as columns |
| Sessions | Session | Flattened events | Traffic, engagement, conversion rate |
| Users | User | Sessions | New and returning, lifetime value |
Add dimension tables as needed: a channel mapping from source and medium, a page table, a calendar. With those, the model is a star schema with sessions or events as the fact table. See star schema for the pattern.
Flattened events
The first table pulls the parameters you use out of the array and gives them names. It keeps event grain.
SELECT
user_pseudo_id,
(
SELECT value.int_value
FROM UNNEST(event_params)
WHERE key = 'ga_session_id'
) AS ga_session_id,
event_name,
event_timestamp,
ecommerce.purchase_revenue
AS revenue
FROM
`bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*`
WHERE _TABLE_SUFFIX
BETWEEN '20210101' AND '20210131'Sessions
The sessions table groups events by the combined key. Assuming the query above is saved as events_flat:
SELECT
CONCAT(
user_pseudo_id, '-',
CAST(ga_session_id AS STRING)
) AS session_id,
user_pseudo_id,
MIN(TIMESTAMP_MICROS(
event_timestamp))
AS session_start,
COUNTIF(event_name = 'page_view')
AS page_views,
COUNTIF(event_name = 'purchase')
AS purchases,
SUM(revenue) AS revenue
FROM events_flat
WHERE ga_session_id IS NOT NULL
GROUP BY 1, 2Run on 7 October 2026 with the first query inlined as a WITH clause, this produced 118,380 sessions from 94,790 users, with 1,204 purchases and revenue of 57,350, out of 1,210,147 event rows.
Those four numbers are now fixed by the table’s definition. Anyone who reads the sessions table gets them, whatever tool they use.
Users
The users table groups sessions by user_pseudo_id: first session date, number of sessions, total revenue. If the site sends user_id, this is also where the two ids are reconciled, and that rule deserves a written sentence of its own. If the user-data export is on, join pseudonymous_users_ on pseudo_user_id for audiences and predictions, taking the most recent row for each id, since each daily table holds only the users that changed.
Decisions to write down
A GA4 model is mostly a list of decisions. These five change the numbers most, so state each one in the table’s description.
- The session key. User and session id together, as above.
- What a conversion is. Name the events. “Purchase” and “generate_lead” are different businesses.
- Which source a session gets. The export has
traffic_sourcefor the user’s first visit and source parameters on events. Say which one feeds the channel column. - The date of a session. The start date, in which time zone.
- Which days are final. Daily tables can be updated after they first appear, so say how many recent days a scheduled rebuild covers.
For the general versions of these mistakes, see mistakes in data modeling.
Joining GA4 to ad spend and CRM data
Joining GA4 to ad spend and CRM data
Sessions on their own answer traffic questions. The business questions need other sources: what the sessions cost and what they led to.
Two joins cover most of it:
- Ad spend, by date, source, medium and campaign. This works only if campaign names in the ad platforms match the UTM values on the site, so the channel mapping table matters more than it looks.
- CRM or order data, by a user id or a transaction id that the site sends to GA4 and the backend also stores.
Both are joins between tables of different grain. Aggregate each side to the shared grain first, for example date and campaign, and then join. Joining session rows to daily spend rows directly repeats the spend once per session and inflates cost.
Where the definitions should live
Where the definitions should live
Once the three queries exist, the remaining risk is that people do not use them. If the raw events_* tables are what a colleague or an AI assistant can see, they will count sessions from them, and the 3% gap comes back.
The fix is to publish the modeled tables as the thing people read. In OWOX Data Marts each of the three queries becomes a Data Mart: the SQL, a description that carries the decisions above, an owner and described output fields. Relationships between Data Marts are declared on keys such as user_pseudo_id. Reports in Google Sheets or Data Studio read the Sessions Data Mart, not the export.
Two limits to be clear about. GA4 is not a source OWOX Data Marts connects to directly. The data arrives through Google’s own BigQuery export, and the Data Marts are defined on top of it. And the product does not write the sessionization or the conversion logic. Those are the analyst’s SQL, published and governed as written.
Related: Joinable Data Marts for declaring relationships, and the E-commerce data model, which includes sessions and traffic sources.
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
Does OWOX Data Marts connect to GA4 directly?
No. GA4 data reaches the warehouse through Google's own BigQuery export. Data Marts are then defined on top of it: the analyst's SQL for events, sessions and users, each with a description, an owner and declared relationships. The product publishes and governs that SQL; it does not write the sessionization.
What does a GA4 data model include?
Three layers: a flattened events table with parameters as columns, a sessions table with one row per user and session id, and a users table built from sessions. Dimension tables such as a channel mapping and a calendar turn it into a star schema.
How do you count sessions in the GA4 BigQuery Export?
Extract ga_session_id from event_params with UNNEST, combine it with user_pseudo_id into one session key, and group events by that key. Counting distinct ga_session_id alone undercounts, because two users can start a session in the same second.
Why do GA4 session counts differ between queries and tools?
Because each query defines a session itself. The most common difference is the key: ga_session_id is a start timestamp and is unique only together with user_pseudo_id. On Google's public sample for January 2021, counting distinct ga_session_id gives 114,882 sessions and the combined key gives 118,380.
What is missing from the GA4 BigQuery Export?
Modeled tables. The export has events only. A session exists just as a ga_session_id parameter repeated on its events, and a user as a pseudonymous id. Sessions, users, conversions and channel groupings all have to be derived.
Why is GA4 data in BigQuery hard to work with?
The export is one row per event in a table per day, and most useful fields, including the session id and page URL, are nested inside the event_params array. There are no sessions, users or conversions tables, so each has to be built with SQL before a report can use it.
