GCP BigQuery Cost Optimization: Slot Management and Query Patterns That Save Millions
Practical strategies for reducing BigQuery costs through slot management, query optimization, and architectural patterns tested at scale.

BigQuery's on-demand pricing model is deceptively simple: $6.25 per TB scanned. At small scale, this feels cheap. At 500TB/month, you're looking at a $3,125/month bill that can easily triple when analysts run exploratory queries on unpartitioned tables. Here's how we cut our BigQuery spend by 62% without sacrificing query performance.
The Problem: Runaway Query Costs at Scale
Our data platform processes 2.3PB of data monthly across 40+ teams. The billing pattern was predictable: costs grew linearly with team count, not data volume. Each new team brought new exploratory patterns, new dashboards that scanned full tables, and new scheduled queries without optimization.
The wake-up call came when a single analyst's exploratory query scanned 47TB in one execution — costing $293.75 for a query that returned 12 rows.
Slot-Based vs. On-Demand: The Crossover Point
BigQuery offers two pricing models, and choosing wrong costs real money.
On-demand: $6.25/TB scanned. No commitment. Burst capacity available.
Flat-rate (Editions): Purchase slot commitments. Standard edition starts at $0.04/slot-hour.
The crossover calculation:
-- Calculate your effective slot cost
-- If this exceeds your flat-rate price, switch to reservations
SELECT
project_id,
ROUND(SUM(total_bytes_billed) / POW(1024, 4), 2) AS tb_scanned,
ROUND(SUM(total_bytes_billed) / POW(1024, 4) * 6.25, 2) AS on_demand_cost,
ROUND(SUM(total_slot_ms) / 1000 / 3600, 0) AS total_slot_hours,
ROUND(SUM(total_slot_ms) / 1000 / 3600 * 0.04, 2) AS flat_rate_cost
FROM `region-us`.INFORMATION_SCHEMA.JOBS
WHERE creation_time > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY project_id
ORDER BY on_demand_cost DESC;
For our workload, the crossover was at approximately 180TB/month. Beyond that, flat-rate reservations saved us 40-55%.
Five Query Optimization Patterns
Pattern 1: Partition Pruning
The single highest-impact optimization. Every table that grows over time should be partitioned by date:
-- Before: Full table scan (4.2TB)
SELECT user_id, event_type, timestamp
FROM `project.analytics.events`
WHERE DATE(timestamp) = '2025-09-15';
-- After: Partition-pruned (12GB)
-- Table partitioned by DATE(timestamp)
SELECT user_id, event_type, timestamp
FROM `project.analytics.events`
WHERE timestamp >= '2025-09-15' AND timestamp < '2025-09-16';
Note the range predicate instead of DATE() function. Wrapping the partition column in a function defeats partition pruning.
Pattern 2: Clustering for Selective Queries
Clustering reduces bytes scanned when filtering on high-cardinality columns:
CREATE TABLE `project.analytics.events`
PARTITION BY DATE(timestamp)
CLUSTER BY user_id, event_type
AS SELECT * FROM `project.analytics.events_staging`;
For queries filtering by user_id, this reduced scans from 12GB to 400MB per partition — a 30x improvement.
Pattern 3: Materialized Views for Repeated Aggregations
Dashboard queries that run hourly on the same aggregation pattern waste money:
CREATE MATERIALIZED VIEW `project.analytics.daily_metrics`
PARTITION BY date
CLUSTER BY product_id
AS
SELECT
DATE(timestamp) AS date,
product_id,
COUNT(*) AS event_count,
COUNT(DISTINCT user_id) AS unique_users,
SUM(revenue_cents) / 100.0 AS revenue
FROM `project.analytics.events`
GROUP BY 1, 2;
BigQuery automatically routes queries to materialized views when possible. Our dashboard queries went from 500GB/execution to 2GB/execution.
Pattern 4: Approximate Aggregations
For dashboards where exact counts don't matter:
-- Exact distinct count: scans full column
SELECT COUNT(DISTINCT user_id) FROM events; -- 4.1TB scanned
-- Approximate: same result within 1% error, much cheaper
SELECT APPROX_COUNT_DISTINCT(user_id) FROM events; -- 800GB scanned
Pattern 5: BI Engine Reservations
For sub-second dashboard queries on hot data, BI Engine caches data in memory:
gcloud bigquery reservations create bi-engine-reservation \
--project=analytics-prod \
--location=us \
--size=10GB \
--edition=enterprise
10GB of BI Engine capacity costs ~$40/month and eliminates repeated scans entirely for cached tables.
Slot Management Architecture
We implemented a reservation hierarchy that matches our organizational structure:
# Create baseline reservation (committed capacity)
gcloud bigquery reservations create prod-baseline \
--slots=2000 \
--location=us \
--edition=standard \
--commitment-plan=annual
# Create autoscale reservation for burst
gcloud bigquery reservations create prod-burst \
--slots=0 \
--autoscale-max-slots=4000 \
--location=us \
--edition=standard
# Assign to production workloads
gcloud bigquery reservations assignments create \
--reservation=prod-baseline \
--job-type=QUERY \
--assignee-type=FOLDER \
--assignee-id=123456789
The key insight: separate your workloads into priority tiers and assign slot reservations accordingly. ETL pipelines get committed slots (predictable, scheduled). Ad-hoc analyst queries get autoscale slots (bursty, unpredictable).
Cost Governance Framework
Technical optimizations only work if you prevent regression. We implemented:
- Custom quotas per project: Limit daily TB scanned per team
- Query cost warnings: Dry-run estimation before execution for queries exceeding thresholds
- Automated tagging: All queries tagged with team/purpose for cost attribution
- Weekly cost reports: Automated reports showing cost per team with trend analysis
-- Weekly cost attribution query
SELECT
labels.value AS team,
ROUND(SUM(total_bytes_billed) / POW(1024, 4), 2) AS tb_scanned,
ROUND(SUM(total_bytes_billed) / POW(1024, 4) * 6.25, 2) AS estimated_cost
FROM `region-us`.INFORMATION_SCHEMA.JOBS,
UNNEST(labels) AS labels
WHERE
creation_time > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
AND labels.key = 'team'
GROUP BY 1
ORDER BY 3 DESC;
Results After 6 Months
| Metric | Before | After | Change |
|---|---|---|---|
| Monthly spend | $48,200 | $18,300 | -62% |
| Avg query bytes scanned | 1.2TB | 89GB | -93% |
| P95 query latency | 34s | 8s | -76% |
| Slot utilization | N/A | 78% | Healthy |
| Teams supported | 40 | 58 | +45% |
Key Takeaways
- Partition everything by time. It's the single highest-ROI optimization and should be table default.
- Switch to flat-rate once you cross 150-200TB/month. The math is unambiguous at that point.
- Materialized views are free performance. BigQuery maintains them automatically, and the optimizer routes queries transparently.
- Governance prevents regression. Technical wins erode without organizational controls.
- Separate committed and burst capacity. Predictable workloads get committed slots; exploratory work gets autoscale.
The biggest lesson: BigQuery cost optimization is 30% technical and 70% organizational. The query patterns above are straightforward, but making them stick across 50+ teams requires tooling, education, and accountability.
Recommended reading

Per-Team Cost Allocation in Shared Kubernetes Clusters: From Chaos to Clarity
Implementing accurate per-namespace cost allocation in multi-tenant Kubernetes clusters, covering request vs. usage attribution, shared resource amortization, and building showback dashboards that drive accountability.

Measuring and Eliminating Toil: From 40% to 12% of Engineering Time
A systematic approach to identifying, measuring, and automating toil—the repetitive operational work that scales linearly with service growth and prevents engineers from doing creative work.

Serverless Postgres in Production: Branching, Scale-to-Zero, and the End of Database Provisioning
Running Neon serverless Postgres in production for 8 months — covering database branching workflows, scale-to-zero economics, connection pooling, and migration from RDS.

Comments
No comments yet. Be the first to share your thoughts.