How to Query GA4 Data in BigQuery

July 26, 2026 · By the SaturnSQL team

The GA4 interface answers the questions Google decided to build a report for. The BigQuery export answers the rest. It gives you every raw event as a row of SQL-queryable data, unsampled, and for most sites it costs nothing beyond what you would already pay for BigQuery. This is the guide to turning that export into answers: how to enable it, how the schema actually works, and four complete queries you can run today.

Why the export beats the GA4 UI and API

The GA4 UI applies sampling to large date ranges and only ever shows you the dimensions and metrics its reports were built for. The Data API removes some of that sampling but is still a fixed set of report shapes. The BigQuery export skips both limits: every event row, every parameter, unsampled, joinable against anything else in your warehouse. The catch is that you write SQL instead of clicking through a report builder, which is exactly the tradeoff this guide is for.

The export is free for standard GA4 properties, with one limit worth knowing: the daily export caps out at 1 million events per day. Below that, there is no charge from GA4, only BigQuery's own storage and query pricing, which for a small site is close to nothing.

Enabling the export

In GA4, go to Admin → Product Links → BigQuery Links and link the property to a BigQuery project. You will be asked to choose daily, streaming, or both. Daily export writes one table per day, named events_YYYYMMDD, and is the free option almost every site should start with. Streaming export additionally writes events within minutes into a rolling events_intraday_YYYYMMDD table, which is useful if you need near-real-time numbers, but it is a separate paid feature billed by event volume.

After linking, expect the first daily table to take up to about 24 hours to appear. From then on, each day's table typically lands a day behind, backfilled once that day is complete.

The schema, explained

Every table is one row per event: a page view, a click, a purchase, whatever you or GA4 chose to log. There is no separate sessions or users table; sessions and users are both derived from the event stream itself.

Most of what makes an event interesting lives in event_params, a repeated STRUCT: an array of key/value pairs, where the value is itself a struct with string_value, int_value, float_value, and double_value columns (only one is ever populated). Because it is repeated, you cannot just write event_params.page_location; you have to UNNEST it and pick out the key you want. This is the one pattern you will reuse in almost every query against this export:

(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location')

For dates, you have two tools that look similar but do different jobs. event_date is an ordinary STRING column on every row, formatted YYYYMMDD, and it is what you GROUP BY or display. _TABLE_SUFFIX is a pseudo-column that only exists when you query the events_* wildcard, and it is what determines which daily tables BigQuery actually opens. Filtering on event_date alone does not limit the scan, since it is just a normal column BigQuery has to read from every shard the wildcard matches; filtering on _TABLE_SUFFIX is what keeps the query cheap. The examples below always filter on _TABLE_SUFFIX to bound the scan; the daily active users query also selects event_date because the output needs to show the date.

Four queries you can run today

Replace project.analytics_XXXXX with your own export's project and dataset, found on the same BigQuery Links page where you enabled it.

Daily active users, last 28 days

Users are counted as distinct user_pseudo_id values. Group by event_date and bound the wildcard with _TABLE_SUFFIX so BigQuery only opens the 28 daily tables you need.

SELECT
  event_date,
  COUNT(DISTINCT user_pseudo_id) AS active_users
FROM `project.analytics_XXXXX.events_*`
WHERE _TABLE_SUFFIX BETWEEN
    FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 28 DAY))
    AND FORMAT_DATE('%Y%m%d', CURRENT_DATE())
GROUP BY event_date
ORDER BY event_date;

Cost: two narrow columns across 28 shards. One of the cheapest queries you can run against the export.

Top pages by views, last 7 days

page_view events carry the URL inside event_params, so pull it out with the UNNEST pattern above and filter event_name to just page_view.

SELECT
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page_location,
  COUNT(*) AS views
FROM `project.analytics_XXXXX.events_*`
WHERE _TABLE_SUFFIX BETWEEN
    FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
    AND FORMAT_DATE('%Y%m%d', CURRENT_DATE())
  AND event_name = 'page_view'
GROUP BY page_location
ORDER BY views DESC
LIMIT 20;

Cost: BigQuery bills by the columns it opens across the 7 shards, not by matching rows, so the event_name filter doesn't change which day-shards get opened, it just narrows the result.

Sign-ups by source and medium, last 28 days

collected_traffic_source is the traffic source recorded on the event itself (from UTM parameters or click IDs), which is what you want for attributing a specific conversion. It's a plain STRUCT, not a repeated field, so there's no UNNEST here, just dot access. Contrast that with traffic_source, which records only a user's very first-ever acquisition and stays fixed after that, useful for user-acquisition reports but not for crediting this month's conversions.

SELECT
  collected_traffic_source.manual_source AS source,
  collected_traffic_source.manual_medium AS medium,
  COUNT(*) AS sign_ups
FROM `project.analytics_XXXXX.events_*`
WHERE _TABLE_SUFFIX BETWEEN
    FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 28 DAY))
    AND FORMAT_DATE('%Y%m%d', CURRENT_DATE())
  AND event_name = 'sign_up'
GROUP BY source, medium
ORDER BY sign_ups DESC;

Cost: same shape as the previous query, 28 shards, a handful of scalar columns. Swap sign_up for whatever event your app fires on conversion.

Simple 7-day retention

GA4 automatically logs a first_visit event the first time a user is ever seen, so you don't need to scan your whole history to build a cohort. Pull the cohort from the shard 7 days ago, check who came back today, and divide.

WITH cohort AS (
  SELECT DISTINCT user_pseudo_id
  FROM `project.analytics_XXXXX.events_*`
  WHERE _TABLE_SUFFIX = FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
    AND event_name = 'first_visit'
),
returned AS (
  SELECT DISTINCT user_pseudo_id
  FROM `project.analytics_XXXXX.events_*`
  WHERE _TABLE_SUFFIX = FORMAT_DATE('%Y%m%d', CURRENT_DATE())
)
SELECT
  (SELECT COUNT(*) FROM cohort) AS cohort_size,
  COUNT(*) AS returned_day_7,
  ROUND(SAFE_DIVIDE(COUNT(*), (SELECT COUNT(*) FROM cohort)) * 100, 1) AS retention_pct
FROM cohort
JOIN returned USING (user_pseudo_id);

Cost: touches exactly two daily shards, the cheapest way to compute retention without scanning your entire event history.

Controlling cost on the wildcard table

Three habits keep the export cheap. First, always filter _TABLE_SUFFIX: querying events_* with no date bound opens every daily table you have ever exported. Second, never SELECT * from events_*: the repeated event_params, items, and user_properties columns are the largest in the table by far, and BigQuery bills for every column your query touches, whether or not you use its values. Select only the fields you need.

Third, if the BigQuery console or your client lets you cap a query's maximum bytes billed as a safety net, a low ceiling like 10 GB goes a long way for a small site. A single day's events_* shard for a modest site is typically a few megabytes to a few hundred megabytes across the columns a normal query touches, so a well-pruned query covering weeks or months of history stays far under that ceiling, while an unbounded SELECT * across a year of tables is exactly the kind of accident such a cap is meant to catch. On-demand BigQuery pricing is currently $6.25 per TB scanned, so the difference between a pruned query and an unbounded one is usually the difference between fractions of a cent and real money.

Frequently asked questions

Is the GA4 BigQuery export free?

Yes, for standard GA4 properties the daily export is free, capped at 1 million events per day. Streaming export (near-real-time events_intraday_ tables) is a separate, additional paid feature billed by volume. You still pay BigQuery's own storage and query costs, but there is no charge from GA4 for the daily export itself.

How long until data appears in BigQuery?

After you link a GA4 property to BigQuery, the first daily events_YYYYMMDD table typically appears within about 24 hours. From then on, each day's table lands roughly a day behind. If you also enable streaming export, that day's events show up within minutes in a separate events_intraday_ table.

Can I send GA4 data to Google Sheets?

Yes. Once GA4's BigQuery export is enabled, a tool like SaturnSQL can connect to that BigQuery project with a service account, run a saved SQL query like the ones above on a schedule, and write the results into a Google Sheet that refreshes automatically.

From BigQuery to a living Google Sheet

Running these queries once in the BigQuery console answers today's question. The team that asks it again next week needs something better: connect BigQuery to SaturnSQL with a service account, save one of these queries, and schedule it to write into a Google Sheet on whatever cadence makes sense, daily active users each morning, top pages every Monday, sign-ups by channel once a week. No GA4 API integration to maintain, just SQL against the export you already have. See the full walkthrough in BigQuery to Google Sheets.