GA4 BigQuery Export Cost Optimization: A Practical Guide
GA4's BigQuery export is free, until it isn't. Storage, query processing, and streaming ingestion costs add up silently. This guide breaks down exactly where the money goes, what realistic numbers look like for different traffic levels, and the concrete SQL optimizations that can cut your bill by 80%.
Why GA4 BigQuery Export Costs Catch People Off Guard
GA4’s BigQuery export is marketed as “free” and technically the export itself has no charge from the GA4 side. But the costs accumulate on the BigQuery side: storage, query processing, and streaming ingestion. Many teams enable the export, connect Looker Studio directly to the raw tables, and only realize months later that they are burning through hundreds of dollars in query costs.
The problem is not that BigQuery is expensive. The problem is that the GA4 schema, deeply nested RECORD fields requiring UNNEST for every parameter access, naturally leads to full-table scans if you do not know what you are doing. A single SELECT * on a year of GA4 data can scan 50–200 GB. Run that via a dashboard refreshing for 10 users throughout the day and you have a real problem.
This article breaks down exactly where the costs come from, what realistic numbers look like for different traffic levels, and the concrete optimization techniques that can reduce your bill by 50–80%.
Understanding the Cost Components
There are four distinct cost buckets to track when running GA4 data in BigQuery. All prices below are for the EU multi-region (europe-west1), which is where you should be storing data if your users are in the EEA. Official BigQuery pricing is listed in USD.
1. Streaming Export Ingestion
Streaming export is charged at $0.05 per GB of data written via the BigQuery Storage Write API. Google estimates 1 GB equals roughly 600,000 GA4 events, so a site doing 10M events/month would pay around $0.83/month just for ingestion.
Daily export avoids this charge entirely but is capped at 1M events/day for standard GA4 properties (no limit for GA4 360). If you are under the cap and do not need near-real-time data, daily export is the obvious choice.
2. Storage
Active storage (data modified in the last 90 days) costs $0.023/GB/month in EU multi-region. Long-term storage (untouched for 90+ days) drops to $0.016/GB/month. Physical storage billing (opt-in) can reduce this further by charging based on compressed size rather than logical size.
The free tier covers 10 GB/month, which is enough for most small-to-medium sites running GA4 for their first year.
3. Query (Analysis) Costs
On-demand pricing is $6.25 per TB scanned, with the first 1 TB/month free. This is where the real money goes.
A careless SELECT * on a year of GA4 data can scan 50–200 GB depending on traffic, costing $0.31–$1.25 per query. That sounds trivial until Looker Studio refreshes that query for every user, every time they open the dashboard. Ten people opening the same dashboard three times a day? That is 30 full-table scans per day, potentially 30–60 TB/month, or $187–$375/month from a single dashboard.
4. BigQuery Editions (Slot-Based Pricing)
BigQuery Editions offer an alternative: instead of paying per TB scanned, you reserve compute slots. Standard Edition starts at $0.04/slot-hour, Enterprise at $0.06/slot-hour. For teams with heavy, predictable workloads, slots can be 40–60% cheaper than on-demand, but they require capacity planning. More on this below.
Real-World Cost Benchmarks
These estimates assume EU multi-region storage, on-demand query pricing, and a reasonably well-structured setup. Your mileage will vary based on how many event parameters you collect and how aggressively you query.
Small Site (50K events/day)
| Component | Monthly Cost |
|---|---|
| Storage (first year) | Free (under 10 GB) |
| Streaming export | ~$0.12 |
| Queries (optimized) | Free (under 1 TB) |
| Total | $0–2 |
At this scale, costs are negligible. Even a poorly optimized setup will rarely exceed a few dollars per month.
Medium Site (500K events/day)
| Component | Monthly Cost |
|---|---|
| Storage (~30 GB after 1 year) | ~$0.50 |
| Streaming export | ~$1.20 |
| Queries (optimized, summary tables) | Free – $5 |
| Queries (unoptimized, raw table dashboards) | $25–$60 |
| Total (optimized) | $2–7 |
| Total (unoptimized) | $27–62 |
This is where optimization starts to matter. The 10× difference between optimized and unoptimized is entirely about whether you query raw tables or summary tables.
Large Site (5M+ events/day)
| Component | Monthly Cost |
|---|---|
| Storage (~300 GB/year) | $5–$7 |
| Streaming export | ~$12 |
| Queries (optimized) | $20–$80 |
| Queries (unoptimized) | $500–$3,000+ |
| Total (optimized) | $37–100 |
| Total (unoptimized) | $500–$3,000+ |
At this scale, every optimization technique in this article becomes critical. One documented case from Scandiweb reported savings of $135,000/year by restructuring their GA4 BigQuery setup. At this traffic level, BigQuery Editions should be evaluated.
The GA4 Schema Problem
Before diving into optimizations, you need to understand why GA4 queries are expensive in the first place.
The GA4 export schema uses nested and repeated fields, event_params, user_properties, and items are stored as RECORD types. Every time you need to access a specific event parameter like page_location or ga_session_id, you must UNNEST the event_params array:
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 ga_session_id
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX = FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name = 'page_view'This forces BigQuery to read the entire event_params column, which contains ALL parameters for every event, not just the one you need. On a large dataset, event_params alone can account for 60–70% of the total table size.
The schema design is space-efficient for storage but hostile for analytics queries. A simple “give me pageviews by page” query ends up scanning significantly more data than the equivalent query on a flat table would. This is the root cause of most unexpected BigQuery bills.
Optimization 1: Build Flattened Summary Tables
This is the single highest-impact optimization. Stop querying raw GA4 export tables for routine analytics. Instead, create scheduled queries (or use Dataform/dbt) that run once daily and produce flattened, purpose-built summary tables.
Example: Daily Sessions Table
This scheduled query runs after the daily export lands and produces a clean sessions table:
CREATE OR REPLACE TABLE `project.dataset.sessions_daily`
PARTITION BY event_date
CLUSTER BY source, medium
AS
WITH session_data AS (
SELECT
PARSE_DATE('%Y%m%d', event_date) AS event_date,
user_pseudo_id,
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS session_id,
(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 = 'engagement_time_msec') AS engagement_time_msec,
event_name,
event_timestamp,
traffic_source.source AS source,
traffic_source.medium AS medium,
traffic_source.name AS campaign,
device.category AS device_category,
geo.country AS country
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX = FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
)
SELECT
event_date,
user_pseudo_id,
session_id,
source,
medium,
campaign,
device_category,
country,
MIN(event_timestamp) AS session_start,
MAX(event_timestamp) AS session_end,
COUNTIF(event_name = 'page_view') AS page_views,
SUM(COALESCE(engagement_time_msec, 0)) AS total_engagement_time_msec,
COUNTIF(event_name = 'purchase') AS purchases,
-- First page of the session
ARRAY_AGG(
IF(event_name = 'page_view', page_location, NULL) IGNORE NULLS
ORDER BY event_timestamp LIMIT 1
)[SAFE_OFFSET(0)] AS landing_page
FROM session_data
GROUP BY event_date, user_pseudo_id, session_id, source, medium, campaign, device_category, country;Example: Daily Pageviews Aggregate
A simpler table for content performance reporting:
CREATE OR REPLACE TABLE `project.dataset.pageviews_daily`
PARTITION BY event_date
CLUSTER BY page_location
AS
SELECT
PARSE_DATE('%Y%m%d', event_date) AS event_date,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page_location,
COUNT(*) AS pageviews,
COUNT(DISTINCT user_pseudo_id) AS users,
COUNT(DISTINCT CONCAT(
user_pseudo_id,
CAST((SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS STRING)
)) AS sessions
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX = FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name = 'page_view'
GROUP BY event_date, page_location;The impact
A raw GA4 table for a medium site might be 1 GB/day. The equivalent daily session summary might be 5–10 MB. When Looker Studio queries the summary table instead of the raw table, you reduce data scanned by 99%+. One documented example showed a query dropping from scanning 500 GB to 2.5 MB after switching to a materialized view.
The scheduled query itself costs a small, predictable amount each day, far cheaper than ad-hoc queries hitting the raw data repeatedly.
A note on UNNEST syntax
GoogleSQL does not have a FLATTEN function like legacy SQL did. Instead, you use the comma operator or explicit JOIN with UNNEST:
-- Comma syntax (implicit cross join)
SELECT
event_date,
param.key,
param.value.string_value
FROM `project.analytics_PROPERTY_ID.events_*`,
UNNEST(event_params) AS param
WHERE _TABLE_SUFFIX = '20260207'
AND param.key = 'page_location'
-- Explicit JOIN syntax (equivalent)
SELECT
event_date,
param.key,
param.value.string_value
FROM `project.analytics_PROPERTY_ID.events_*`
JOIN UNNEST(event_params) AS param
ON param.key = 'page_location'
WHERE _TABLE_SUFFIX = '20260207'Both produce identical results. The subquery approach (SELECT ... FROM UNNEST(event_params) WHERE key = ...) shown in the summary table examples above is generally more readable when extracting specific parameters into columns.
Optimization 2: Partitioning and Clustering
GA4 export tables are already date-sharded (each day is a separate table like events_20260207). This means date-filtered queries only scan the relevant days. Always use _TABLE_SUFFIX or event_date in your WHERE clause:
-- Good: partition pruning kicks in
SELECT *
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260201' AND '20260207'
-- Bad: scans ALL tables, then filters
SELECT *
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE PARSE_DATE('%Y%m%d', event_date) >= '2026-02-01'The second query looks equivalent but BigQuery cannot use partition pruning on a derived filter. This single mistake can 10–50× your query cost.
Cluster your summary tables
For your flattened summary tables, clustering groups rows by frequently filtered columns, so BigQuery can skip irrelevant blocks during scans:
CREATE OR REPLACE TABLE `project.dataset.sessions_daily`
PARTITION BY event_date
CLUSTER BY source, medium, device_category
AS
-- ... your queryWhen you then filter by WHERE source = 'google' AND medium = 'organic', BigQuery only reads the blocks containing organic Google traffic rather than the entire partition. Google’s benchmarks show partitioning can reduce scanned data by up to 80%, and clustering can cut an additional 60%+ on top of that for filtered queries.
Optimization 3: Query Discipline and Guardrails
Even with perfect table design, an undisciplined query can burn through budget. Set up guardrails before it happens.
Set custom quotas
Custom quotas cap daily TB processed at the project or user level. This prevents a single runaway dashboard or curious analyst from blowing the budget:
-- In GCP Console: IAM & Admin > Quotas
-- Or via gcloud:
gcloud alpha services quota update bigquery.googleapis.com \
--consumer=project:YOUR_PROJECT \
--metric=bigquery.googleapis.com/quota/query/usage \
--unit=1/d/{project} \
--value=5 # 5 TB per daySet billing alerts
In the GCP Billing Console, create budget alerts at 50%, 90%, and 100% of your threshold. You can trigger email notifications or Pub/Sub messages for automated responses.
Query rules to enforce
- Never
SELECT *on GA4 tables. Select only the columns you need - Always include a date filter using
_TABLE_SUFFIX - Use dry runs before executing expensive queries, the
--dry_runflag or the BigQuery UI cost estimator shows you how much data will be scanned before you execute - Cache results. BigQuery caches query results for 24 hours by default if the underlying data has not changed. Identical queries are free on the second run
- Do not run the same expensive query repeatedly. If you need the result in multiple places, materialize it into a table first
Dry run example
-- In the BigQuery CLI:
bq query --dry_run --use_legacy_sql=false \
'SELECT event_date, event_name FROM `project.analytics_PROPERTY_ID.events_*` WHERE _TABLE_SUFFIX = "20260207"'
-- Output: This query will process 234.5 MiB when run.In the BigQuery Console, the green checkmark next to the query editor shows the estimated bytes before you hit “Run”.
Optimization 4: Streaming vs. Daily Export
If your GA4 property generates fewer than 1M events/day, the daily export avoids streaming ingestion costs entirely. Daily export data typically lands next morning, or same-day with the “Fresh Daily” feature for GA4 360 properties.
For most reporting use cases, dashboards refreshed daily, weekly business reviews, monthly analysis, daily export is more than sufficient.
If you need near-real-time data or exceed the 1M daily event cap, streaming export is required. In that case:
- Factor in the $0.05/GB ingestion cost
- Consider whether you truly need streaming for all events. Could you reduce event volume by being more selective about what you track? Every unnecessary event parameter increases both storage and query costs
- Use the intraday table (
events_intraday_YYYYMMDD) for real-time monitoring and the finalized daily table for reporting
If you are building real-time use cases on the streaming export, consider reading about automated anomaly detection which leverages the intraday table for early alerting.
Optimization 5: BigQuery Editions for Heavy Users
If your monthly on-demand query bill consistently exceeds $500–$1,000, it is worth evaluating BigQuery Editions.
With Enterprise Edition at $0.06/slot-hour, a single slot running 24/7 costs about $43/month. Autoscaling lets you set a baseline of slots and scale up during peak hours. The key pricing comparison:
| Approach | 50 TB/month | 100 TB/month | 500 TB/month |
|---|---|---|---|
| On-demand ($6.25/TB) | $312 | $625 | $3,125 |
| Enterprise 100 slots (autoscale) | ~$4,300 | ~$4,300 | ~$4,300 |
At 100 TB/month, the costs are roughly equivalent. Above that, slots become significantly cheaper. Below 50 TB/month, on-demand is almost always the better choice.
One-year and three-year commitments offer further discounts of 25–40% over pay-as-you-go slot pricing. But commit only after you have stable, predictable query patterns.
Practical Cost-Optimization Checklist
If you take one thing from this article, it should be this: never point a dashboard at raw GA4 export tables. Everything else is secondary.
Here is the full checklist, ordered by impact:
- [] Audit your current costs in the GCP Billing Console. Know your baseline before optimizing
- [] Build flattened summary tables for your most common reporting needs (sessions, pageviews, conversions)
- [] Point all dashboards at summary tables, never at raw exports
- [] Set up billing alerts and custom quotas immediately
- [] Ensure all queries use partition filters (
_TABLE_SUFFIXorevent_date) - [] Cluster summary tables on frequently filtered columns (
source,medium,event_name) - [] Evaluate daily vs. streaming export based on your actual latency requirements
- [] Review tracked events and parameters in GA4, remove what you do not use. Fewer parameters = smaller
event_paramscolumn = cheaper queries - [] Use dry runs before executing ad-hoc queries on raw tables
- [] Re-evaluate Editions if on-demand spend consistently exceeds $500/month
Related Resources
- Automated Anomaly Detection for GA4 with BigQuery Build monitoring on top of your optimized tables
- Predicting Customer Value with BigQuery ML and GA4 Data ML models that benefit from well-structured summary tables
- AI-Powered Audience Segmentation with GA4 and BigQuery Clustering analysis that requires cost-efficient querying at scale
- Tracking Strategy: How to Build a Measurement System That Works Fewer unnecessary events = lower BigQuery costs