Google BigQuery skill for Claude Code
Scan less, pay less, know who spent what. Claude starts a BigQuery task by finding the real cost drivers in INFORMATION_SCHEMA, then fixes table design with partitioning, clustering and required partition filters, rewrites the worst queries and proves the savings with dry runs. It adds byte caps and daily quotas, compares on-demand with editions using measured slot-hours, and grants access through datasets, authorized views and row policies.
- name
gcp-bigquery - size235 lines · ≈2,700 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs and operates Google BigQuery for cost and speed - table partitioning and clustering, require_partition_filter and expirations, on-demand versus editions slot pricing, dry runs and maximum_bytes_billed guards, custom quotas, query optimization (pruning, column selection, join and window patterns, approximate functions), materialized views, MERGE-based incremental loads, scheduled queries, INFORMATION_SCHEMA cost audits, and IAM with authorized views and row-level security. Use when creating BigQuery tables, when queries are slow or expensive, when the bill spikes, or when setting up scheduled transformations and dataset access. Try asking
- Our BigQuery bill tripled last month. Find which queries caused it and fix them
- Design a partitioned and clustered events table for 2 billion rows a month
- Set up a daily scheduled MERGE into our customers table with cost limits
Use it when
- Creating or redesigning BigQuery tables
- Queries are slow or scan far more data than expected
- Spend spiked or you are choosing between on-demand and editions
- Setting up scheduled queries, materialized views or dataset access
Skip it when
- You are writing dbt models on top of BigQuery — use dbt-models
- The task is general SQL analysis without warehouse tuning — use analytics-sql
How to check the result
Dry runs show bytes processed dropping for each rewritten query, and the INFORMATION_SCHEMA spend audit a week later shows lower TiB billed and slot-hours for the top patterns.
The common mistake it prevents
Adding LIMIT 100 to an exploratory query and assuming it is cheap, when an unclustered table is billed for every byte of every column referenced.
BigQuery work is done when large tables are partitioned and clustered on the columns queries filter by, every heavy query is dry-run and capped, the top cost drivers in INFORMATION_SCHEMA are known and addressed, and access is granted at dataset or view level to groups, not individuals.
Ground rules
- Dry-run before running anything that might scan a large table. On-demand billing is per byte scanned (US multi-region list price $6.25 per TiB after the first free TiB each month; check current regional pricing), with a 10 MB minimum per table referenced.
- Set
maximum_bytes_billedon ad-hoc and scheduled queries, and custom daily quotas per project or user so one mistake cannot burn the month. LIMITdoes not reduce bytes billed on unclustered tables.SELECT *reads every column. Preview data withbq heador the table preview, which are free.DROP TABLE,DELETE/TRUNCATEwithout a partition filter,bq rm -ron a dataset, and changing partition expiration on existing tables destroy data; require explicit confirmation. Time travel (up to 7 days) is the safety net; confirm the window first.- Grant roles to groups/service accounts on datasets; avoid project-wide
bigquery.admin/dataEditor.
Workflow
- [ ] 1. Find the cost and latency drivers (INFORMATION_SCHEMA.JOBS)
- [ ] 2. Fix table design: partitioning, clustering, expiration
- [ ] 3. Rewrite the worst queries; dry-run before and after
- [ ] 4. Choose pricing model and set guardrails (quotas, max bytes)
- [ ] 5. Incremental pipelines: MERGE, scheduled queries, materialized views
- [ ] 6. Access: dataset IAM, authorized views, row/column security
- [ ] 7. Verify bytes and slot time dropped; report
1. Find the drivers
-- Top 20 query patterns by bytes billed, last 30 days (adjust region qualifier)
SELECT
user_email,
query_info.query_hashes.normalized_literals AS query_hash,
ANY_VALUE(LEFT(query, 200)) AS sample_query,
COUNT(*) AS runs,
ROUND(SUM(total_bytes_billed) / POW(1024, 4), 2) AS tib_billed,
ROUND(SUM(total_slot_ms) / 1000 / 3600, 1) AS slot_hours
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
AND job_type = 'QUERY' AND state = 'DONE'
GROUP BY user_email, query_hash
ORDER BY tib_billed DESC
LIMIT 20;
More audit queries (storage by table, scheduled query costs, partition skew, slot contention): read references/cost-queries.md when doing a full cost review.
bq query --use_legacy_sql=false --dry_run 'SELECT ...' # prints bytes that would be processed
bq show --format=prettyjson project:dataset.table | jq '{partitioning: .timePartitioning, clustering: .clustering, rows: .numRows, bytes: .numBytes}'
2. Table design
| Table shape | Partition | Cluster |
|---|---|---|
| Events/logs with timestamp, queried by date range | PARTITION BY DATE(event_ts) (daily; hourly only for very high volume short retention) | Top 1-4 filter/join columns: customer_id, event_name |
| Dimension tables < 1-10 GB | None | Optional on lookup key |
| Snapshot tables by load date | _PARTITIONDATE ingestion time or explicit snapshot_date | Business key |
| Integer-keyed data without time | Integer range partitioning | Secondary filters |
CREATE TABLE analytics.events
(
event_ts TIMESTAMP NOT NULL,
customer_id STRING,
event_name STRING,
properties JSON
)
PARTITION BY DATE(event_ts)
CLUSTER BY customer_id, event_name
OPTIONS (
require_partition_filter = TRUE,
partition_expiration_days = 400,
description = 'Raw product events, one row per event'
);
Rules:
- Partitions should hold at least ~1 GB on average; tiny daily partitions add overhead without pruning benefit (cluster instead, or use monthly partitions). A table can have up to 10,000 partitions.
- Clustering column order matters: most frequently filtered first.
- Do not shard tables by date suffix (
events_20260301); use partitioning. Wildcard queries over shards are slower and limited. - Storage pricing: long-term rate applies automatically to partitions unmodified for 90 days. Consider physical (compressed) storage billing per dataset when compression ratio is high; time travel and fail-safe bytes are billed under physical billing.
3. Query optimization
| Problem | Fix |
|---|---|
| Partition not pruned | Filter directly on the partition column with constants or parameters; avoid wrapping it in functions or filtering through a join/subquery result |
SELECT * on wide tables | Select needed columns; SELECT * EXCEPT(...) still reads the rest |
| Repeated scans of the same base | Materialize a filtered intermediate table or use a materialized view |
| Large joins slow | Filter before joining; join on INT64 or clustered keys; put the largest table first and let BigQuery broadcast small ones |
COUNT(DISTINCT) on huge sets | APPROX_COUNT_DISTINCT or HLL sketches when +-1% is acceptable |
| Window functions over everything | PARTITION BY a key in the window; QUALIFY to filter instead of subquery |
| Dedup latest row per key | QUALIFY ROW_NUMBER() OVER (PARTITION BY id ORDER BY updated_at DESC) = 1 |
| Querying JSON strings | Use the JSON type or extract to typed columns at load |
| Needle-in-haystack lookups on text | Search indexes + SEARCH() |
Read the execution graph (console Execution details or INFORMATION_SCHEMA.JOBS
job_stages) for: stages with high “wait” (slot contention), skew (max vs avg worker
time), and large shuffle bytes.
-- Parameterized, pruned, capped
DECLARE start_date DATE DEFAULT DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY);
SELECT event_name, COUNT(*) AS n
FROM analytics.events
WHERE DATE(event_ts) >= start_date -- prunes partitions
AND customer_id = 'c_123' -- uses clustering
GROUP BY event_name;
bq query --use_legacy_sql=false --maximum_bytes_billed=50000000000 < query.sql # fail if > 50 GB
4. Pricing model and guardrails
| Situation | Model |
|---|---|
| Spiky, modest usage (< a few hundred TiB/month scanned) | On-demand with max bytes and custom quotas |
| Steady heavy usage, many scheduled pipelines, predictable budget | Editions (Standard/Enterprise/Enterprise Plus) with a reservation, baseline + autoscaling slots |
| Mixed | Reservation for pipelines project; on-demand for ad-hoc project (assignments per project) |
Compare using slot-hours from INFORMATION_SCHEMA (SUM(total_slot_ms)/3.6e6) multiplied
by the edition slot-hour price versus on-demand TiB billed. Editions bill autoscaled
slots in increments and with a minimum window, so measured slot-hours understate cost
slightly; leave margin.
Guardrails:
- Custom quota “Query usage per day” at project and per-user level (Cloud console Quotas, BigQuery API).
- Budget alerts on the billing account with notifications.
- Default
maximum_bytes_billedin BI tools, dbt profiles (maximum_bytes_billed), and scheduled queries.
5. Incremental pipelines
MERGE analytics.customers T
USING (
SELECT * FROM staging.customers_changes
WHERE _PARTITIONDATE = CURRENT_DATE()
QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC) = 1
) S
ON T.customer_id = S.customer_id
WHEN MATCHED AND S.updated_at > T.updated_at THEN UPDATE SET name = S.name, plan = S.plan, updated_at = S.updated_at
WHEN NOT MATCHED THEN INSERT (customer_id, name, plan, updated_at) VALUES (S.customer_id, S.name, S.plan, S.updated_at);
- MERGE into a partitioned target: add a partition filter on
Tin the ON clause when possible so only recent partitions are scanned. - Scheduled queries (Data Transfer Service) for simple SQL jobs; use dbt (see dbt-models) or an orchestrator once there are dependencies between jobs.
- Materialized views for repeated aggregations on append-mostly tables; BigQuery rewrites matching queries to use them automatically.
- Loading: batch loads from GCS are free; streaming uses the Storage Write API (cheaper
and exactly-once with committed streams) rather than legacy
insertAll.
bq mk --transfer_config --data_source=scheduled_query --target_dataset=analytics \
--display_name="daily customers merge" --schedule="every day 03:00" \
--params='{"query":"<SQL here>"}' [email protected]
6. Access
| Need | Mechanism |
|---|---|
| Analysts read a dataset | roles/bigquery.dataViewer on dataset + roles/bigquery.jobUser on the billing project |
| Share a subset without exposing base tables | Authorized view (or authorized dataset) |
| Per-row restriction (tenant, region) | Row-level access policy |
| Sensitive columns (PII) | Policy tags with column-level access control, data masking |
| Pipelines | Dedicated service account with dataEditor on target datasets only |
CREATE ROW ACCESS POLICY eu_only ON analytics.orders
GRANT TO ('group:[email protected]')
FILTER USING (region = 'EU');
7. Verify
- Dry-run the rewritten query: bytes processed drop (record before/after).
- Re-run the JOBS audit after a week: top patterns’ TiB billed and slot-hours fell.
INFORMATION_SCHEMA.PARTITIONSshows expected partition sizes; queries without partition filters now fail fast on tables withrequire_partition_filter.
Gotchas
- Partition pruning does not happen when the filter value comes from a subquery in
many cases; compute it into a variable (
DECLARE/SET) first. - Clustering benefits shrink as new data arrives unsorted; automatic reclustering is free but not instant.
CREATE OR REPLACE TABLEdrops table options and policies not restated (row access policies, descriptions); preferALTERor restate everything.- BI tools issue many small queries; each referencing a table is billed at least 10 MB. Use BI Engine or extracts for dashboards with heavy concurrency.
- Region matters: datasets in different locations cannot be joined; INFORMATION_SCHEMA
queries need the right
region-xxqualifier. - Cross-project queries bill the project running the job, not the data owner.
Output
**Top drivers (30 d):** dashboard refresh query 41 TiB (no partition filter); dbt full refresh 18 TiB
**Changes:** events partitioned by DATE(event_ts), clustered customer_id,event_name, require_partition_filter;
dashboard query rewritten to 7-day window; dbt model incremental (MERGE)
**Dry run:** 2.3 TiB -> 38 GiB per dashboard refresh
**Guardrails:** max bytes 100 GB on scheduled queries; per-user daily quota 2 TiB; budget alert 80%
**Access:** analysts via authorized views; PII columns policy-tagged Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/cost-queries.md
# BigQuery cost and performance audit queries
Read during a full cost review or when the top-driver query in SKILL.md is not enough.
Replace `region-us` with the dataset location (`region-eu`, `region-europe-west1`, ...).
JOBS views keep about 180 days of history. Querying INFORMATION_SCHEMA is itself billed
(small), so add time filters.
## Spend by user and day
```sql
SELECT
DATE(creation_time) AS day,
user_email,
ROUND(SUM(total_bytes_billed) / POW(1024, 4), 3) AS tib_billed,
ROUND(SUM(total_bytes_billed) / POW(1024, 4) * 6.25, 2) AS approx_on_demand_usd -- adjust list price/region
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 14 DAY)
AND job_type = 'QUERY'
GROUP BY day, user_email
ORDER BY day DESC, tib_billed DESC;
```
## Scheduled queries and service accounts
```sql
SELECT
user_email,
labels,
COUNT(*) AS runs,
ROUND(SUM(total_bytes_billed) / POW(1024, 4), 2) AS tib_billed,
ROUND(SUM(total_slot_ms) / 3.6e6, 1) AS slot_hours
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
AND user_email LIKE '%gserviceaccount.com'
GROUP BY user_email, TO_JSON_STRING(labels)
ORDER BY tib_billed DESC;
```
Label jobs from pipelines (`--label team:growth`, dbt `query-comment` / job labels) so
this breakdown is meaningful.
## Queries that scanned without partition pruning
Find jobs that read a large share of a partitioned table:
```sql
SELECT
job_id,
user_email,
ref.table_id,
ROUND(total_bytes_processed / POW(1024, 3), 1) AS gib_processed,
LEFT(query, 160) AS query_head
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT,
UNNEST(referenced_tables) AS ref
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
AND job_type = 'QUERY'
AND ref.table_id IN ('events', 'pageviews')
ORDER BY total_bytes_processed DESC
LIMIT 50;
```
Compare `gib_processed` with the table size from `TABLE_STORAGE`; ratios near 1 mean
no pruning.
## Storage by table (logical vs physical, active vs long-term)
```sql
SELECT
table_schema AS dataset,
table_name,
ROUND(active_logical_bytes / POW(1024, 3), 1) AS active_logical_gib,
ROUND(long_term_logical_bytes / POW(1024, 3), 1) AS longterm_logical_gib,
ROUND(active_physical_bytes / POW(1024, 3), 1) AS active_physical_gib,
ROUND(time_travel_physical_bytes / POW(1024, 3), 1) AS time_travel_gib,
ROUND(SAFE_DIVIDE(active_logical_bytes + long_term_logical_bytes,
active_physical_bytes + long_term_physical_bytes), 1) AS compression_ratio
FROM `region-us`.INFORMATION_SCHEMA.TABLE_STORAGE
ORDER BY active_logical_bytes + long_term_logical_bytes DESC
LIMIT 30;
```
Physical billing tends to win when the compression ratio is well above the price ratio
between physical and logical rates and time-travel bytes are small. The billing model is
set per dataset (`ALTER SCHEMA ... SET OPTIONS (storage_billing_model = 'PHYSICAL')`);
check the current rules on how often it can be changed before switching.
## Partition sizes and skew
```sql
SELECT
partition_id,
total_rows,
ROUND(total_logical_bytes / POW(1024, 2), 1) AS mib,
last_modified_time
FROM analytics.INFORMATION_SCHEMA.PARTITIONS
WHERE table_name = 'events'
ORDER BY partition_id DESC
LIMIT 60;
```
Many partitions under ~100 MB: consider monthly partitioning or clustering only.
## Slot usage and contention (editions / reservations)
```sql
SELECT
TIMESTAMP_TRUNC(period_start, HOUR) AS hour,
ROUND(SUM(period_slot_ms) / (1000 * 3600), 1) AS avg_slots_used
FROM `region-us`.INFORMATION_SCHEMA.JOBS_TIMELINE_BY_PROJECT
WHERE job_creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 3 DAY)
GROUP BY hour
ORDER BY hour;
```
Use this to size a reservation baseline (the steady floor) and the autoscaling max
(the peaks you are willing to pay for).
## Stage-level skew for one slow job
```sql
SELECT
stage.name,
stage.wait_ms_avg, stage.wait_ms_max,
stage.compute_ms_avg, stage.compute_ms_max,
stage.shuffle_output_bytes
FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT,
UNNEST(job_stages) AS stage
WHERE job_id = 'bquxjob_123abc'
ORDER BY stage.compute_ms_max DESC;
```
`compute_ms_max` far above `compute_ms_avg` = skewed key (one hot customer or NULL key);
handle hot keys separately or filter NULLs before the join.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.