The GA4 interface answers the questions Google anticipated. BigQuery answers the rest, and the price of admission is understanding one unusual thing about the schema. Once that clicks, most of the analysis you actually want is forty lines of SQL.

This assumes the export is already switched on. If you turned it on recently and need the months before that, the history cannot be backfilled from GA4 — worth knowing before you plan around it.

The one thing about the schema

In BigQuery, one row is one event. Not a session, not a user. And the parameters that event carried do not live in columns — they live in event_params, an array of key/value structs:

event_date      = '20260929'
event_timestamp = 1759104000000000      -- microseconds, not milliseconds
event_name      = 'purchase'
user_pseudo_id  = '1234567.8901234'
event_params    = [
  { key: 'ga_session_id',  value: { int_value: 1759103000 } },
  { key: 'page_location',  value: { string_value: 'https://...' } },
  { key: 'session_engaged', value: { string_value: '1' } }
]

So you cannot write WHERE page_location = .... You reach into the array with a scalar subquery, and this pattern is roughly 80% of all GA4 SQL:

SELECT
  event_name,
  (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 `my-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260901' AND '20260930'
  AND event_name = 'purchase'

Two details in there that matter more than they look:

events_* with _TABLE_SUFFIX is how you query date-sharded tables. The filter is not optional — leave it out and every query scans your entire history. That is the one way this gets expensive.

event_timestamp is in microseconds. Divide by 1,000,000 for seconds, or use TIMESTAMP_MICROS(). Treating it as milliseconds puts your data in 1970 and the mistake is not always obvious.

Sessions and users, counted properly

ga_session_id is not unique on its own — it is a timestamp-derived integer scoped to each user, so two users can share one. The session key is the pair:

SELECT
  PARSE_DATE('%Y%m%d', event_date) AS date,
  COUNT(DISTINCT user_pseudo_id) AS users,
  COUNT(DISTINCT CONCAT(
    user_pseudo_id,
    (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id')
  )) AS sessions,
  COUNTIF(event_name = 'purchase') AS purchases,
  ROUND(SUM(ecommerce.purchase_revenue), 2) AS revenue
FROM `my-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260901' AND '20260930'
GROUP BY date
ORDER BY date

This will not match the GA4 interface exactly, and that is expected rather than a bug. The interface models conversions it did not observe and reconciles identities; the export gives you what was collected. Differences under about 10% are normal. Above that, check your date range and your session key before you suspect the export.

A funnel that tells you where people leave

Funnel exploration in the interface is fine until you need a step defined by something it will not let you express. In SQL the step is just a COUNTIF:

WITH sessions AS (
  SELECT
    CONCAT(user_pseudo_id,
      (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id')
    ) AS session_key,
    COUNTIF(event_name = 'view_item')     > 0 AS saw_product,
    COUNTIF(event_name = 'add_to_cart')   > 0 AS added,
    COUNTIF(event_name = 'begin_checkout')> 0 AS checked_out,
    COUNTIF(event_name = 'purchase')      > 0 AS purchased
  FROM `my-project.analytics_123456789.events_*`
  WHERE _TABLE_SUFFIX BETWEEN '20260901' AND '20260930'
  GROUP BY session_key
)
SELECT
  COUNTIF(saw_product) AS step_1_product,
  COUNTIF(added) AS step_2_cart,
  COUNTIF(checked_out) AS step_3_checkout,
  COUNTIF(purchased) AS step_4_purchase,
  ROUND(100 * COUNTIF(added)       / NULLIF(COUNTIF(saw_product), 0), 1) AS pct_product_to_cart,
  ROUND(100 * COUNTIF(checked_out) / NULLIF(COUNTIF(added), 0), 1)       AS pct_cart_to_checkout,
  ROUND(100 * COUNTIF(purchased)   / NULLIF(COUNTIF(checked_out), 0), 1) AS pct_checkout_to_purchase
FROM sessions

Swap any step for a condition of your own — a specific page, a parameter value, a device class — and the shape of the query does not change. That substitution is the whole reason to be in here.

First-touch vs last-touch, on your own data

This is the query that justifies the export. GA4’s reporting no longer lets you compare models — first-click, linear and time-decay were removed in 2023 — but the raw events carry no attribution at all, which means you can assign credit however you like:

WITH touches AS (
  SELECT
    user_pseudo_id,
    event_timestamp,
    COALESCE(collected_traffic_source.manual_source, 'direct') AS source,
    COALESCE(collected_traffic_source.manual_medium, 'none')   AS medium,
    event_name,
    ecommerce.purchase_revenue AS revenue
  FROM `my-project.analytics_123456789.events_*`
  WHERE _TABLE_SUFFIX BETWEEN '20260701' AND '20260930'
),
converters AS (
  SELECT user_pseudo_id, MIN(event_timestamp) AS converted_at, SUM(revenue) AS revenue
  FROM touches
  WHERE event_name = 'purchase'
  GROUP BY user_pseudo_id
),
ranked AS (
  SELECT
    t.user_pseudo_id,
    c.revenue,
    CONCAT(t.source, ' / ', t.medium) AS channel,
    ROW_NUMBER() OVER (PARTITION BY t.user_pseudo_id ORDER BY t.event_timestamp ASC)  AS first_rank,
    ROW_NUMBER() OVER (PARTITION BY t.user_pseudo_id ORDER BY t.event_timestamp DESC) AS last_rank
  FROM touches t
  JOIN converters c USING (user_pseudo_id)
  WHERE t.event_timestamp <= c.converted_at
)
SELECT
  channel,
  ROUND(SUM(IF(first_rank = 1, revenue, 0)), 2) AS first_touch_revenue,
  ROUND(SUM(IF(last_rank  = 1, revenue, 0)), 2) AS last_touch_revenue
FROM ranked
GROUP BY channel
ORDER BY last_touch_revenue DESC

Run it and the two columns will disagree, sometimes severely. Branded search and direct inflate on the last-touch side; paid social and display inflate on the first-touch side. The size of that gap is the practical measure of how much your channel report depends on a bookkeeping choice.

One honest limit: this is keyed on user_pseudo_id, which is a cookie on one browser. Cross-device journeys appear as separate people, so the real path is longer than anything this query can see. If that matters for your business, the fix is user_id on logged-in traffic plus server-side tracking, not a cleverer window function.

Keeping the bill near zero

The first terabyte of queries each month is free and storage runs a couple of cents per gigabyte, so most properties pay nothing. Three habits keep it that way:

  • Always filter _TABLE_SUFFIX. This is the big one. Without it you scan every day you have ever collected, every time.
  • Never SELECT *. BigQuery bills by columns scanned. Naming five columns instead of selecting all of them is often a 10× difference on the same rows.
  • Materialise what you reuse. If a transformation feeds a dashboard, write it to a table on a schedule instead of recomputing it on every refresh. This is also what keeps a dashboard your team actually opens from being slow enough that they stop.

Use the dry-run byte estimate in the console before running anything new. It is free and it tells you immediately whether you forgot the date filter.

Four traps worth knowing in advance

events_intraday_* is a different table. If the export includes streaming, today’s data sits in a separate intraday table with a slightly different shape. events_* quietly matches both, so a wildcard query can mix finished days with a partial one. Be explicit when the distinction matters.

user_pseudo_id is null when consent is denied. Depending on your consent setup, some events arrive without an identifier. They are real events that cannot be grouped into a user, and ignoring that silently understates your totals.

(not set) is a real value. It means the dimension was not collected for that event, not that nothing happened. Filter it deliberately rather than letting it ride along in a GROUP BY.

Ecommerce revenue lives in two places. ecommerce.purchase_revenue at the event level and items as a repeated record for line-level detail. Summing both without thinking double-counts.

The short version

One row is one event, parameters hide in event_params behind UNNEST, timestamps are microseconds, and _TABLE_SUFFIX is what stands between you and a surprising invoice. With those four facts you can count sessions correctly, build a funnel with any step definition you like, and compare attribution models on your own paths — which is the analysis the interface will not give you at any price.

If your reports and your revenue disagree and you want to know which one is lying, that is usually where I start.