The GA4 BigQuery export schema, explained (field by field, for marketers)

· learned UNNEST the way everyone does — by needing revenue by Tuesday🧩 with one UNNEST pattern to memorise, not a lecture

Published September 10, 2026

The 60-second version

  • The export is one table per day, not one flat table. Always filter `_TABLE_SUFFIX`, or one query can scan two years.
  • One scalar-subquery UNNEST pattern unpacks event_params, items and user properties while keeping one row per event.
  • user_pseudo_id measures reach, user_id measures customers. Label which one every user count means.
bottom line: filter the shards, unpack once, label the identity

You link GA4 to BigQuery, open the dataset expecting a table called events, and find 400 tables named events_20260112. You open one and the columns make no sense — where is the page URL, where is the transaction value, and why is everything inside something called event_params?

This is where most marketers bounce off the GA4 export. The interface thinks in dimensions and metrics; the export thinks in events, parameters, and nested records. Same data, reorganised for a warehouse — and four ideas unlock 90% of what you will ever query: date-sharded tables, one UNNEST pattern, two identity columns, and a short list of fields.

The mental model: the GA4 export is a schema mapping exercise, not a report. Each row is one event with nested arrays hanging off it. Learn to unpack the five nested structures while keeping bytes scanned low by filtering on the table date first.


Why the export is date-sharded

GA4 writes one table per day — events_20260812, events_20260813, and so on — plus an events_intraday_* table that fills during the current day and is deleted once the daily table lands. It looks messy in the explorer. It is a cost control.

Each daily table holds only that day's events, so a query over the last 7 days reads 7 tables and ignores the other 400. BigQuery charges for bytes scanned, so sharding keeps a "last week's purchases" query at megabytes instead of terabytes. You select across days with a wildcard plus a _TABLE_SUFFIX filter — BigQuery's way of saying "only open these shards":

-- BigQuery prunes shards before reading anything. This filter is load-bearing.
SELECT event_name, COUNT(*) AS events
FROM `my-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260801' AND '20260807'
GROUP BY event_name
ORDER BY events DESC;

Three rules keep the shards cheap:

Always filter the suffix

A bare wildcard opens every daily table back to the start of the export — the most common GA4 cost accident. The suffix predicate is load-bearing.

Never report on intraday

Intraday rows are deleted and re-written into the daily table, so anything built on them double-counts or vanishes. Same-day debugging only.

Expect late data

Daily tables finalise 24–72 hours after the day ends with conversions backfilled. Yesterday's numbers move slightly when re-run next week.

Without the suffix filter, one query can scan the entire export history. A two-year export at 50 GB per day is roughly 36 TB — about a $225 scan — for a query that needed one week. The _TABLE_SUFFIX predicate is partition pruning for GA4 tables: shards outside the range are skipped without being opened.

LIVE · DRAG ITPrice your GA4 query before running
50
7

The _TABLE_SUFFIX range decides 95% of the cost

$6.25
Data scanned (TB)
0.3
Estimated cost
$2.14
HEALTHY SCAN

A tight suffix range over a modest export — this is what cheap GA4 analysis looks like.

live mathsestimates are fine — this is a what-if sandbox

How a GA4 Query Should Flow

Data Journey
Stage 1Date range
Pick the shards

Wildcard over events_* with a _TABLE_SUFFIX range. Decides 95% of your cost before any other logic runs.

7 shards, not 400
Stage 2UNNEST once
Unpack the nesting

Flatten event_params, items, or user_properties with the subquery pattern below. One UNNEST per nested field.

1 pattern
Stage 3User or day
Aggregate at the right grain

Count events, distinct pseudo-ids, or summed ecommerce values. Filter test and internal traffic first.

Day or user

event_params: one UNNEST pattern

Everything interesting lives inside event_params — page location, campaign source, transaction id, value, search term. The table itself carries only the skeleton: event_name, event_timestamp, user_pseudo_id, geo, device, traffic_source. The flesh is a repeated record of key-value pairs, unpacked with exactly one pattern. Learn it once and you are done.

The UNNEST Pattern Every GA4 Query Uses

Why scalar subqueries instead of CROSS JOIN UNNEST? A cross join multiplies rows — one per parameter — forcing you to collapse them back with conditional aggregation. The subquery form keeps one row per event and performs the same. Each parameter has a typed value — string_value, int_value, float_value — and you must pick correctly: purchase value is a float, ga_session_id is an int, page_location is a string. The wrong type returns NULL silently, the cause of half of all "my GA4 query returns nothing" sessions.

The same pattern unpacks the rest. items holds one row per product on purchase events — unnest it for line-item detail rather than the order total. user_properties mirrors event_params at user scope. ecommerce and collected_traffic_source are flattened conveniences, but event_params is the source of truth when they disagree, because it is what the SDK actually sent.

Never SELECT * on a GA4 table. Nested records make each row enormous, and SELECT * scans every column of every open shard. Name your columns, filter _TABLE_SUFFIX, and exploratory queries stay under a gigabyte.


user_pseudo_id vs user_id

GA4 gives you two identity columns for different questions. user_pseudo_id is the device-and-browser identifier GA4 assigns itself — on every event, stable until cookies clear, and the only identity most events carry. user_id is your identifier, present only when you set it — after login or checkout. Confusing them skews user counts in whichever direction embarrasses you most.

Top of funnel · complete

📱 user_pseudo_id: reach

Count sessions, landing-page users, campaign reach. Phone plus laptop is two pseudo-ids — it overcounts humans, but it is complete.

  • On every event, GA4-assigned
  • Stable until cookies clear
  • Completeness beats precision here
Bottom of funnel · verified

🧑 user_id: customers

Count purchasers, subscribers, repeat buyers. Logged-out purchases are NULL — it undercounts, but everyone counted is real.

  • Only where you set it
  • The only key joining to orders
  • Label every user count with one

The honest setup reports both and names the gap. Distinct user_pseudo_id is reach, distinct user_id is identified customers, and their ratio is login coverage — typically 5% to 25% on content and commerce sites. Ten times more pseudo-users than user_ids is normal, not broken; presenting one as the other is what breaks.

The Two Identity Columns, Side by Side

Identity
user_pseudo_idEvery eventDevice-scoped, GA4-assigned, cleared with cookies. For reach and sessions.
user_idLogged-in onlyYour id, set by your site. NULL elsewhere. For customers and LTV.
Typical ratio4–20xPseudo-users exceed identified users. Anonymous browsing, not a bug.
Join keyuser_id onlyOnly user_id joins to warehouse orders and CRM. Pseudo-ids join to nothing.
Reach questions use user_pseudo_id, revenue questions use user_id, and every user count on a dashboard should label which one it means.

One consequence: joining GA4 to orders must go through user_id, never user_pseudo_id, and only for logged-in purchasers. There is no click id in the export, so campaign-level joins still aggregate both sides to date-plus-source rather than any row-level key. The export is your behavioural source of truth, the warehouse your revenue source of truth, reconciled at the day grain.


Fields behind 90% of questions

Strip the nesting and the export reduces to a short list — the fields behind nearly every marketing question:

QuestionFieldWhere it lives
How many users?user_pseudo_id (distinct)Top-level column
How many sessions?ga_session_id + user_pseudo_idevent_params, concatenated
Where did they come from?session_source, session_medium, campaignevent_params or collected_traffic_source
What page?page_location, page_titleevent_params
What did they buy?transaction_id, value, currencyevent_params on purchase
Which product?item_id, item_name, price, quantityitems array on purchase
What device and geo?device.category, geo.country, geo.cityTop-level structs
Which event, when?event_name, event_timestampTop-level columns

Sessions confuse everyone once: GA4 has no session id column — a session is user_pseudo_id plus the ga_session_id parameter, and counting sessions means counting distinct pairs. The query below is the template: daily active users, sessions, purchases and revenue in one pass, with the schema mapping from parameters to columns made explicit.

Daily Active Users, Sessions, and Revenue

Partition-pruning note: the _TABLE_SUFFIX predicate is what makes this cheap. BigQuery resolves the wildcard before reading data and opens only the 31 matching shards; the other 370-odd tables cost nothing. Write the range as BETWEEN on the raw suffix string — wrapping it in PARSE_DATE or CAST in the WHERE clause can defeat pruning, silently turning a 2 GB query into a 200 GB one. Parse dates in the SELECT, filter raw strings in the WHERE.

From raw export to marketing report shards first, always →

  • Filter shards by _TABLE_SUFFIX — decide the date range first. Everything downstream inherits this cost boundary.
  • Unpack event_params with scalar subqueries — one subquery per parameter with the correct typed value. Keep one row per event.
  • Aggregate to day or user grain — daily rollups for trends, user grain for cohorts. Exclude internal traffic first.
  • Materialise the daily rollup — schedule the query nightly into a small flat table. Dashboards read the rollup, never the raw export.

Frequently asked questions

Why do my BigQuery user counts not match the GA4 interface?

Small differences are expected. The interface applies thresholding, sampling on large ranges, and identity resolution (blended or device-based, per your settings) that the raw export does not. The export counts exactly what was collected. If the gap exceeds a few percent, check you excluded events_intraday_* and that both sides use the same timezone.

How do I get session source and medium reliably?

Prefer collected_traffic_source.manual_source and manual_medium where populated, falling back to the session_source and session_medium parameters. First-touch source lives on session_start; stamping it onto every event needs a window function over the session pair. For channel reporting, aggregate session_start events rather than stamping every page view.

Should I query the streaming intraday tables?

Only for same-day spot checks. Intraday tables are best-effort, can contain duplicates, and are deleted when the daily export lands. Scheduled queries and dashboards must read events_* with a suffix range ending at least two days back, treating the most recent two days as provisional. intraday is a peek, not a source

You query events_* with no _TABLE_SUFFIX filter over a two-year export. What happens?

How far back should I keep daily tables?

A busy site generates tens of gigabytes per day. Keep raw dailies for 13–14 months to cover year-over-year comparisons, then rely on your own materialised daily rollups for longer history. The rollup is gigabytes per year; the raw export is terabytes — the standard bytes scanned advice: shrink what recurring queries read.

Declined-consent events arrive with limited identifiers — often no user_id and reduced traffic_source fidelity. Do not try to recover them with clever SQL. Report the identified share separately, keep blended totals as the headline, and treat the gap like unattributed orders: visible, labelled, part of the number.


The summary

  • The export is one table per day plus a transient intraday table — always query events_* with a _TABLE_SUFFIX range, never the bare wildcard.
  • The suffix filter is your cost control: it decides bytes scanned before anything else runs, and wrapping it in date functions can defeat pruning.
  • event_params holds everything interesting; the scalar-subquery UNNEST pattern unpacks it while keeping one row per event, with the correct typed value per key.
  • user_pseudo_id measures reach, user_id measures known customers — label which one every user count means, and join to the warehouse only on user_id.
  • Eight fields — pseudo-id, session pair, source and medium, page, transaction fields, items, device and geo — answer 90% of marketing questions once the schema mapping is written down.

Takeaways for your next report

  • The export is one table per day plus a transient intraday table — always query events_* with a _TABLE_SUFFIX range, never the bare wildcard.
  • The suffix filter is your cost control: it decides bytes scanned before anything else runs, so filter raw strings, never wrapped dates.
  • event_params holds everything interesting; the scalar-subquery UNNEST pattern unpacks it with the correct typed value per key.
  • user_pseudo_id measures reach, user_id measures known customers — label which one every user count means, and join only on user_id.
stick this on your Monday report
Free tool

BigQuery Cost Estimator

Estimate what a BigQuery query will cost before you run it, from validator bytes, with a table-scan simulator and a paste-your-query cost audit.

Chinmay Raibagkar

Chinmay Raibagkar

About author →

Founder of DataLens AI. He helps non-technical teams read their ad and database numbers with confidence — which number to trust, what to do next, and what to ignore.

Glossary terms referenced