The bad news first, because everything else follows from it: the GA4 BigQuery export is not retroactive. When you link a GA4 property to BigQuery, the export begins from that day forward. There is no setting, no support request and no paid tier that fills in the months before the link existed.
That surprises people every week, and it’s the single strongest argument for linking BigQuery on day one of any GA4 property, even if nobody intends to query it for a year. The export is free up to BigQuery’s sandbox limits and the data has no value until it exists.
If you’ve just discovered this, here’s what you can and can’t do about it.
Why there’s no true backfill
The daily export writes one table per day — events_YYYYMMDD — containing every event GA4
collected, at event level, with the full parameter payload. That granularity only exists because
the export pipeline wrote it at collection time. GA4’s own reporting interface doesn’t retain
event-level rows in a form that can be replayed; it holds aggregated, processed reporting data.
So there’s nothing to backfill from. The raw rows were never kept anywhere the export could reach.
What you can actually recover
You can’t get event-level history. You can get aggregated history, via the GA4 Data API, and load it into BigQuery as its own table. That’s worth doing — just be precise about what it is.
The approach:
- Query the GA4 Data API for the dimensions and metrics you need, day by day.
- Write the results into a separate BigQuery table — something like
ga4_api_history, and deliberately not mixed into theevents_*dataset. - Union it with your real export only in a reporting view, with a column flagging the source.
The scripted version of this is a well-trodden path; several open-source connectors and the
googleanalyticsdata client libraries handle the pagination and retries for you. Budget a day,
not a week.
The four limits that bite
It’s aggregated, not event-level. You get sessions by source/medium by day. You cannot reconstruct a user’s path, recompute a custom session definition, or apply a different attribution model after the fact. Any analysis that needs the raw sequence is not recoverable.
Cardinality limits collapse the long tail. When a dimension exceeds GA4’s cardinality limits,
values get bucketed into (other). On high-cardinality dimensions — page path, item name, search
term — this can swallow a meaningful share of your history, and it’s applied before you ever see
the data.
Thresholding hides rows. When Google Signals is on and row counts are small, GA4 withholds data for privacy. Your API pull will quietly be missing those rows, and the totals won’t reconcile. Turning Google Signals off for the reporting identity can reduce this, but it doesn’t retroactively reveal what was already withheld.
Sampling on complex queries. Large date ranges with several dimensions can return sampled results. Check the sampling metadata in the response rather than assuming.
The practical upshot: numbers from the API backfill will not tie out to numbers from the export for the same period, even in principle. Don’t build a reconciliation process that assumes they should. Label the seam and move on.
The schema difference that breaks joins
This is where most backfill projects go wrong. The two sources aren’t just different in granularity — they’re shaped differently.
The export’s events_* tables are nested. event_params is a repeated record: each row holds a
key and a value struct with string_value, int_value, float_value and double_value
fields, only one of which is populated. Getting a single parameter out means unnesting:
SELECT
event_date,
(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 `project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260101' AND '20260131'
user_properties, items and the traffic source fields follow the same repeated-record pattern.
The Data API returns none of this — it returns flat rows of dimensions and metrics.
Three more things that catch people out:
events_intraday_is a separate table with the same shape, present only if streaming export is enabled, and it’s replaced by the daily table once the day closes. Query it deliberately or you’ll double-count today.- Sessions aren’t a column. A session is
user_pseudo_idplus thega_session_idparameter. Counting sessions means constructing them. - There is no single “source/medium” field per session. Traffic source attribution in the export is not the same object GA4’s UI reports, and recreating the UI’s numbers from raw events is a project in itself, not a query.
Stop the clock, then decide
Whatever you do about history, do this today, in this order:
- Link BigQuery now. Admin → Product links → BigQuery links. Pick the daily export at minimum; add streaming if you want intraday. Every day you wait is a day permanently missing.
- Turn on the export for every property, including staging and low-traffic ones. Storage is cheap; the gap isn’t recoverable.
- Check the export is actually running two days later. A linked property with a billing or permissions problem silently produces nothing, and people discover it months later.
- Then decide whether the aggregated backfill is worth the day of work. Often it is, for year-over-year comparisons on a handful of top-line metrics. Rarely is it worth trying to reconstruct anything granular.
When the gap genuinely matters
If you need real event-level history for a period the export doesn’t cover, the honest answers are limited:
- Your server logs, if the events you care about also hit your backend. For ecommerce, order data in the transactional database is usually a better record than analytics ever was.
- Your CRM, for lead-gen. Closed-won history lives there and is more trustworthy than GA4 for revenue questions.
- A warehouse-first setup going forward, so this can’t happen again — collection that writes to your own store first and forwards to analytics second, rather than the reverse. That’s the same architecture as server-side tracking.
For everything else, accept the seam. A clearly labelled discontinuity in a dashboard is far better than a reconciled number built on two incompatible definitions.
More on setting the export up properly, including partitioning and cost control, in the GA4 BigQuery export. And if you’re still deciding which property type you should be running, GA4 vs Firebase covers the difference.
If you’ve inherited a property with no export and a reporting deadline, this is the kind of thing I untangle — usually in the first week of an audit.