GA4 Standard
The Google Analytics 4 export to BigQuery as ten Data Marts: sessions at the hub, with the events, page views and purchases that join them and the traffic sources, devices, locations and users behind them.
A ready-made reporting layer over the Google Analytics 4 export to BigQuery. It turns the raw events_* tables into ten analytics-ready tables: sessions, events, page views and purchases, plus the traffic sources, devices, locations and users they point at by hash. Every table is built from a staging table of sessions, and sessions are cut at the day boundary.
The SQL that builds the tables is part of the model, in references/sql-transformation, with a runbook for the first load and the daily run. It is written for the OWOX Transformations plugin in the BigQuery (GoogleSQL) dialect, and you can adapt it to your needs.
Sessions are the hub. Events, page views and purchases join them on date and the session key together, because the session number restarts every day. A session row is often a fragment of a GA4 session: the model breaks a visit after 30 idle minutes in the runbook's setup, at midnight, and on every event that carries a source, medium or gclid. So Sessions can hold several times more rows than GA4 counts sessions; on Google's sample, about six times. Count sessions with the sum of the sessions field, which tracks GA4's count closely, and do not compare a bounce rate built per row with GA4's. And the dimension tables carry no date: each run appends the same hashes again, so deduplicate them before joining in your own SQL.
Example Questions
- Which sources and campaigns bring the
sessionsthat end in a purchase? - How does revenue per
sessiondiffer between mobile and desktop, by country? - On which visit do
usersmake their first purchase?
Devices
The device a session ran on: category, operating system and its version, browser and its version, one row per combination. When a session's events disagree, the value from its latest event wins. Sessions point here through deviceHash, a FARM_FINGERPRINT of the five values. The table has no date, so a hash repeats once per run (once per day for daily runs). Count distinct hashes. In your own SQL over these tables, deduplicate it before joining, or rows multiply; OWOX Data Marts reports collapse joined fields per source row.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
deviceHash | STRING | Device Hash | PK. FARM_FINGERPRINT of the five device values, joined without a separator, with an empty value in place of NULL. Unique within one run; repeats across daily runs. |
deviceCategory | STRING | Device Category | The device category as GA4 reports it, such as desktop, mobile or tablet. |
operatingSystem | STRING | Operating System | The operating system, such as Windows, iOS or Android. |
operatingSystemInfo | STRING | Operating System Version | The version of the operating system. |
browser | STRING | Browser | The browser, such as Chrome or Safari. Empty for app traffic. |
browserVersion | STRING | Browser Version | The version of the browser. Empty for app traffic. |
Event Traffic Sources Data Mart
Event Traffic Sources
The traffic source GA4 records on a single event or page view, from its source, medium, campaign, term and content parameters: UTM tags, or GA4's own attribution such as organic or referral. One row per combination. Events and Pageviews point here through eventTrafficSourceHash. Most hits carry none of these parameters: they share one hash, whose row has every value empty, meaning nothing was recorded on the hit, not a missing record. The table has no date, so a hash repeats once per run (once per day for daily runs). Count distinct hashes. In your own SQL over these tables, deduplicate it before joining, or rows multiply; OWOX Data Marts reports collapse joined fields per source row.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
eventTrafficSourceHash | STRING | Event Traffic Source Hash | PK. FARM_FINGERPRINT of source, medium, campaign, term and content, joined without a separator, with an empty value in place of NULL. Unique within one run; repeats across daily runs. |
source | STRING | Event Source | The source parameter GA4 records on the hit: a UTM source, or GA4's own such as google. Empty when the hit carries none. |
medium | STRING | Event Medium | The medium parameter GA4 records on the hit: a UTM medium, or GA4's own such as organic or referral. Empty when the hit carries none. |
campaign | STRING | Event Campaign | The campaign parameter on the hit. Empty when the hit carries none. |
keyword | STRING | Event Keyword | The term parameter on the hit, such as a paid keyword. Empty when the hit carries none. |
adContent | STRING | Event Ad Content | The content parameter on the hit, such as an ad variant. Empty when the hit carries none. |
Events
Every GA4 event other than a page view, one row each, tied to the session it fell in. Page views are in Pageviews. Join to Sessions on date and pseudo_session_id together, because the session number restarts every day. hitId identifies an event: a rerun of the same window gives the same value, a different window does not, and Pageviews numbers its hits separately, so one value can appear in both. time is a timestamp in microseconds. eventTrafficSourceHash points at the source GA4 records on this one event (UTM tags or GA4's own attribution); most events carry none. The table holds no event parameters beyond the event name.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
date | DATE | Date | PK. The day of the event's session. |
pseudo_session_id | STRING | Session Key | The session the event belongs to: the GA4 pseudo user ID plus the session's number on that day. Join to Sessions on this and date together. FK to Sessions |
hitId | STRING | Hit ID | PK. MD5 of the pseudo user ID, the event timestamp and the event's order for that user. Stable for a rerun of the same window, not across different windows. |
time | INTEGER | Event Time | Timestamp of the event, in microseconds since 1970-01-01 UTC. |
events | INTEGER | Events | Always 1. Sum it to count events. |
event_name | STRING | Event Name | The GA4 event name, such as add_to_cart, scroll or purchase. Never page_view. |
eventTrafficSourceHash | STRING | Event Traffic Source Hash | FARM_FINGERPRINT of the source, medium, campaign, term and content GA4 records on this event. An event with none of them has the hash of empty values. FK to Event Traffic Sources |
Relationships
| Related data mart | On | Cardinality | Meaning |
|---|---|---|---|
| Sessions | date = date, pseudo_session_id = pseudo_sessionId | N:1 | The session the event fell in. |
| Event Traffic Sources | eventTrafficSourceHash = eventTrafficSourceHash | N:1 | The traffic source recorded on this one event; the hash repeats across runs. |
Geo
Where a session came from, as GA4 resolves it from the visitor's IP address: country, region and city, one row per combination. When a session's events disagree, the value from its latest event wins. Sessions point here through geoHash, a FARM_FINGERPRINT of the three values. The table has no date, so a hash repeats once per run (once per day for daily runs). Count distinct hashes. In your own SQL over these tables, deduplicate it before joining, or rows multiply; OWOX Data Marts reports collapse joined fields per source row.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
geoHash | STRING | Geo Hash | PK. FARM_FINGERPRINT of country, region and city, joined without a separator, with an empty value in place of NULL. Unique within one run; repeats across daily runs. |
country | STRING | Country | The country, by name, as GA4 reports it. |
region | STRING | Region | The region or state within the country. |
city | STRING | City | The city. |
Pageviews
Every GA4 page_view event, one row each, tied to the session it fell in. Other events are in Events. The table carries no page path: the landing page is on Sessions. Join to Sessions on date and pseudo_session_id together, because the session number restarts every day. hitId identifies a page view: a rerun of the same window gives the same value, a different window does not, and Events numbers its hits separately, so one value can appear in both. time is a timestamp in microseconds. eventTrafficSourceHash points at the source GA4 records on this one page view (UTM tags or GA4's own attribution); most carry none.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
date | DATE | Date | PK. The day of the page view's session. |
pseudo_session_id | STRING | Session Key | The session the page view belongs to: the GA4 pseudo user ID plus the session's number on that day. Join to Sessions on this and date together. FK to Sessions |
hitId | STRING | Hit ID | PK. MD5 of the pseudo user ID, the event timestamp and the page view's order for that user. Stable for a rerun of the same window, not across different windows. |
time | INTEGER | Page View Time | Timestamp of the page view, in microseconds since 1970-01-01 UTC. |
pageviews | INTEGER | Pageviews | Always 1. Sum it to count page views. |
event_name | STRING | Event Name | Always page_view. |
eventTrafficSourceHash | STRING | Event Traffic Source Hash | FARM_FINGERPRINT of the source, medium, campaign, term and content GA4 records on this page view. A page view with none of them has the hash of empty values. FK to Event Traffic Sources |
Relationships
| Related data mart | On | Cardinality | Meaning |
|---|---|---|---|
| Sessions | date = date, pseudo_session_id = pseudo_sessionId | N:1 | The session the page view fell in. |
| Event Traffic Sources | eventTrafficSourceHash = eventTrafficSourceHash | N:1 | The traffic source recorded on this one page view; the hash repeats across runs. |
Sessions
One row per session as this model cuts it, with its landing page, engagement, purchases and keys to its source, device, location and user. A new row starts at GA4's session_start, after 30 idle minutes in the runbook's setup, at midnight, and on every event that carries a source, medium or gclid. So rows can outnumber GA4's sessions several times; on Google's sample, about six times. Count sessions with the sum of sessions, which tracks GA4's count closely. A bounce rate built per row is not comparable to GA4's. A row is keyed by date and pseudo_sessionId. Times are in microseconds; cost, impressions and clicks are always 0. For deduplicated orders, use Transactions. Events without a user_pseudo_id are lost.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
date | DATE | Date | PK. The day of the session, from the export table it was read from. A visit that crosses midnight is two sessions. |
pseudo_sessionId | STRING | Session Key | PK. The GA4 pseudo user ID plus the session's number for that user on that day. The number restarts every day, so pair it with date. Events, page views and purchases carry it as pseudo_session_id. |
sessionId | STRING | GA4 Session Start ID | The GA4 pseudo user ID plus the timestamp of the session_start event that opened the session. Empty for a session the model split off mid-visit, after a pause or on an event that carries a source, medium or gclid. |
landingPage | STRING | Landing Page | Path of the page the session started on, without host or query string. Empty when the session has no session_start event. |
landingHost | STRING | Landing Host | Host name of the page the session started on. Empty when the session has no session_start event. |
touchpoint | STRING | Platform | The GA4 platform the session's events came from: WEB, IOS or ANDROID. |
dataSource | STRING | Data Source | The GA4 export the session was built from, as project.dataset.events_. |
visitStartTime | INTEGER | Session Start Time | Timestamp of the session's first event, in microseconds since 1970-01-01 UTC. |
visitNumber | INTEGER | Session Number | The user's visit count as GA4 reports it (ga_session_number): 1 for a first visit. |
sessions | INTEGER | Sessions | 1 when the row opens with GA4's session_start event, 0 for a row the model split off mid-visit. Count sessions with its sum, which tracks GA4's session count closely; the row count can be several times higher. |
hits | INTEGER | Hits | Number of events in the session, page views included. |
pageviews | INTEGER | Pageviews | Number of page_view events in the session. |
events | INTEGER | Events | Number of events other than page_view in the session. |
transactions | INTEGER | Purchases | Distinct purchase IDs in the session. Not deduplicated across sessions, so the total can exceed the row count of Transactions. |
transactionRevenue | FLOAT | Purchase Revenue | Revenue of the session's purchase events, in the currency the site reported. Not deduplicated across sessions. |
bounces | INTEGER | Bounces | 1 when the row has no user_engagement event, otherwise 0. Set per row, and on Google's sample most rows hold a single event, so a bounce rate built on it is not comparable to GA4's. |
timeOnSite | INTEGER | Time on Site (microseconds) | Time from the session's first event to its last, in microseconds; divide by 1,000,000 for seconds. 0 for a session with one event. |
cost | FLOAT | Cost | Always 0. A slot for ad cost blended in later. |
impressions | INTEGER | Impressions | Always 0. A slot for ad impressions blended in later. |
clicks | INTEGER | Clicks | Always 0. A slot for ad clicks blended in later. |
userHash | STRING | User Hash | FARM_FINGERPRINT of the user's identifiers, today the GA4 pseudo user ID alone. FK to Users |
userTrafficSourceHash | STRING | User Traffic Source Hash | FARM_FINGERPRINT of the source, medium and campaign the user was first acquired by. Matches trafficSourceHash in User Traffic Sources. FK to User Traffic Sources |
trafficSourceHash | STRING | Traffic Source Hash | FARM_FINGERPRINT of the session's source, medium, campaign, keyword and ad content. FK to Traffic Sources |
deviceHash | STRING | Device Hash | FARM_FINGERPRINT of the session's device category, operating system and version, browser and version. FK to Devices |
geoHash | STRING | Geo Hash | FARM_FINGERPRINT of the session's country, region and city. FK to Geo |
Relationships
| Related data mart | On | Cardinality | Meaning |
|---|---|---|---|
| Traffic Sources | trafficSourceHash = trafficSourceHash | N:1 | The session's own source and campaign; the hash repeats across runs. |
| User Traffic Sources | userTrafficSourceHash = trafficSourceHash | N:1 | How the user was first acquired; the hash repeats across runs. |
| Devices | deviceHash = deviceHash | N:1 | The device the session ran on; the hash repeats across runs. |
| Geo | geoHash = geoHash | N:1 | Where the session came from; the hash repeats across runs. |
| Users | userHash = userHash | N:1 | The browser or app instance behind the session; the hash repeats across runs. |
Traffic Sources
The source, medium, campaign, keyword and ad content a session is attributed to, one row per combination. They come from parameters GA4 records on events: UTM tags, or GA4's own attribution such as organic or referral. A session opened by session_start takes the first values its GA4 session carries; one split off mid-visit takes those of the event that split it. With none it reads (direct) / (none). Sessions point here through trafficSourceHash. The table has no date, so a hash repeats once per run (once per day for daily runs). Count distinct hashes. In your own SQL over these tables, deduplicate it before joining, or rows multiply; OWOX Data Marts reports collapse joined fields per source row.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
trafficSourceHash | STRING | Traffic Source Hash | PK. FARM_FINGERPRINT of source, medium, campaign, keyword and ad content, joined without a separator, with an empty value in place of NULL. Unique within one run; repeats across daily runs. |
source | STRING | Source | Where the session came from: a UTM source or GA4's own, such as google. (direct) when the session carries none; google for a session split off mid-visit by an event with a gclid. |
medium | STRING | Medium | The kind of traffic: a UTM medium or GA4's own, such as organic or referral. (none) when the session carries none; cpc for a session split off mid-visit by an event with a gclid. |
campaign | STRING | Campaign | The campaign name, from the campaign parameter. Empty when the session carries none. |
keyword | STRING | Keyword | The keyword or search term, from the term parameter. Empty when the session carries none. |
adContent | STRING | Ad Content | The ad variant, from the content parameter. Empty when the session carries none. |
Transactions
One row per purchase, deduplicated by its transaction ID: when GA4 records the same order more than once within the deduplication window (7 days in the runbook's setup), only the first is kept. Revenue is in the currency the site reported. Per deduplication window, at most one purchase with the ID (not set) and one with an empty ID are kept, so their revenue is understated. Purchases on events without a user_pseudo_id are missing. status is always empty and touchpoint is always WEB; both are slots. Join to Sessions on date and pseudo_session_id for the source and device behind each order. Totals here can differ from the purchase counts on Sessions, which are not deduplicated.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
date | DATE | Date | PK. The day of the purchase's session. |
pseudo_session_id | STRING | Session Key | PK. The session the purchase was made in: the GA4 pseudo user ID plus the session's number on that day. Join to Sessions on this and date together. FK to Sessions |
transactionId | STRING | Transaction ID | PK. The order ID the site sent with the purchase event. (not set) and empty IDs each stand for many purchases collapsed into one. |
transactionRevenue | FLOAT | Revenue | Revenue of the purchase, in the currency the site reported it in. |
status | STRING | Status | Always empty. A slot for an order status from another source. |
touchpoint | STRING | Touchpoint | Always WEB. |
Relationships
| Related data mart | On | Cardinality | Meaning |
|---|---|---|---|
| Sessions | date = date, pseudo_session_id = pseudo_sessionId | N:1 | The session the purchase was made in. |
User Traffic Sources Data Mart
User Traffic Sources
How each user was first acquired: the source, medium and campaign GA4 records for the user's first visit, not for any later session. Missing values read (not set). Sessions point here through userTrafficSourceHash, which matches trafficSourceHash in this table although the names differ; both are a FARM_FINGERPRINT of the three values. The table has no date, so a hash repeats once per run (once per day for daily runs). Count distinct hashes. In your own SQL over these tables, deduplicate it before joining, or rows multiply; OWOX Data Marts reports collapse joined fields per source row.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
trafficSourceHash | STRING | User Traffic Source Hash | PK. FARM_FINGERPRINT of the user's first source, medium and campaign, joined without a separator. Sessions carry it as userTrafficSourceHash. Unique within one run; repeats across daily runs. |
user_source | STRING | First User Source | The source of the user's first visit, from GA4's traffic_source.source. (not set) when GA4 has none. |
user_medium | STRING | First User Medium | The medium of the user's first visit, from GA4's traffic_source.medium. (not set) when GA4 has none. |
user_campaign | STRING | First User Campaign | The campaign of the user's first visit, from GA4's traffic_source.name. (not set) when GA4 has none. |
Users
The visitors behind sessions, one row per GA4 pseudo user ID: the identifier GA4 gives a browser or app instance, not a person. The same person on two devices, or after clearing cookies, is two users here. clientId, userId, visitorId, phone and email are always empty: slots for identifiers from other sources. Sessions point here through userHash, a FARM_FINGERPRINT of all six identifiers, today the pseudo user ID alone. The table has no date, so a hash repeats once per run (once per day for daily runs). Count distinct hashes. In your own SQL over these tables, deduplicate it before joining, or rows multiply; OWOX Data Marts reports collapse joined fields per source row.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
userHash | STRING | User Hash | PK. FARM_FINGERPRINT of the six identifiers below, joined without a separator, with an empty value in place of NULL. Unique within one run; repeats across daily runs. |
clientId | STRING | Client ID | Always empty. A slot for a client ID from another source. |
userId | STRING | User ID | Always empty. A slot for your own user ID; GA4's user_id is not loaded here. |
visitorId | STRING | Visitor ID | Always empty. A slot for a visitor ID from another source. |
userPseudoId | STRING | GA4 Pseudo User ID | GA4's user_pseudo_id: one browser or app instance. |
phone | STRING | Phone | Always empty. A slot for a phone number from your CRM. |
email | STRING | Always empty. A slot for an email address from your CRM. |
- 1
Install the Import Model plugin
One plugin, installed once, in your own OWOX workspace.
- 2
Import this model
Point it at this bundle and it creates every data mart above, joins and all.
- 3
Plug in your data and destinations
Connect your own sources and send the results where your team already works.
4. Optional — customize as you wish. Rename a column, drop a mart, add your own: once it is imported it is yours, and nothing here syncs back.
You are booked. The invitation is on its way to , with the calendar invite and the meeting link.