BigQuery Optimization skill for Codex
Scan gigabytes, not terabytes. Codex starts from the billing model, bytes scanned on-demand or slot-hours on Editions, and measures every change with dry runs and INFORMATION_SCHEMA.JOBS. It partitions and clusters tables on the columns queries actually filter by, rewrites filters that defeat pruning, adds require_partition_filter and maximum_bytes_billed guardrails, and attributes spend by user, label and query pattern.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- bigquery-optimization Installs to ~/.agents/skills/bigquery-optimization/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install bigquery-optimization The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project bigquery-optimization Installs to .agents/skills/bigquery-optimization/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/bigquery-optimization.zipunzip -o bigquery-optimization.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) bigquery-optimization PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $bigquery-optimization, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Cuts BigQuery cost and runtime - partitioned and clustered tables, require_partition_filter, dry runs and maximum_bytes_billed, reading the query execution graph for slot time and shuffle, INFORMATION_SCHEMA.JOBS cost attribution, on-demand versus Editions reservations and autoscaling, materialized views, and storage billing choices. Use when the user asks why a BigQuery query is slow or expensive, wants to partition or cluster a table, needs to find which jobs or users drive the bill, compares on-demand and capacity pricing, or wants guardrails so nobody scans a whole table by accident.
Use it when
- A BigQuery query is slow or bills too many bytes
- Designing partitioning and clustering for a large table
- Explaining or reducing a BigQuery bill
- Comparing on-demand pricing with Editions reservations
Use something else when
- The warehouse is Snowflake: use snowflake-sql
- You are writing analyst SQL for funnels and retention: use sql-for-analytics
- The problem is dbt model structure rather than BigQuery cost: use dbt-models
How to check the result
Dry runs show bytes processed dropping by the expected factor, and INFORMATION_SCHEMA.JOBS totals for the affected queries fall over the following week.
The mistake it prevents
Filtering a partitioned table through a function of the partition column or a subquery, so the query silently scans every partition.
Done well means: large tables are partitioned and clustered on the columns queries filter by,
queries read only the partitions and columns they need, every cost claim comes from a dry run
or INFORMATION_SCHEMA.JOBS, and guardrails stop accidental full scans.
Before changing anything
- Read
AGENTS.md; find how SQL runs (dbt withtype: bigquery,bqCLI, Python client, Dataform). Queries and dry runs need network and credentials; in a sandbox ask for approval or ask the user to run them. - Learn the billing model: on-demand (billed per TiB scanned) or capacity (Editions
reservations, billed per slot-hour). The fix differs:
Model What costs money Optimise for On-demand Bytes billed per query Fewer bytes: partitions, clustering, fewer columns Editions / reservations Slot-hours (baseline + autoscale) Less slot time: less shuffle, fewer repeated scans, right-sized baseline - Know the region (
region-us,region-eu, …); INFORMATION_SCHEMA views are regional. - Altering or recreating tables, changing reservations, and deleting data need explicit user confirmation.
Measure before and after
bq query --use_legacy_sql=false --dry_run 'select ... from `proj.ds.events` where ...'
# prints the bytes the query would process; costs nothing
-- top cost drivers in the last 7 days (on-demand view)
select user_email, query, total_bytes_billed / pow(1024, 4) as tib_billed,
total_slot_ms / 1000 / 3600 as slot_hours
from `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
where creation_time >= timestamp_sub(current_timestamp(), interval 7 day)
and job_type = 'QUERY' and statement_type != 'SCRIPT'
order by total_bytes_billed desc
limit 20;
More attribution queries (by query hash, by label, by table, reservation usage) are in references/information-schema-queries.md; read it when you need to explain a bill or find repeated expensive patterns.
Partitioning and clustering
create table `proj.analytics.events`
partition by date(event_ts)
cluster by tenant_id, event_name
options (
require_partition_filter = true,
partition_expiration_days = 400
) as
select * from `proj.raw.events`;
| Decision | Rule |
|---|---|
| Partition column | The date or timestamp almost every query filters on; daily granularity by default, monthly or yearly when daily partitions would be tiny |
| Partition limit | 10,000 partitions per table; do not partition by hour over years |
| Integer range partitioning | Only when queries filter on an integer range (customer bucket, ID ranges) |
| Clustering columns | Up to 4, ordered by how often they are filtered, high-to-medium cardinality (tenant, user, event type) |
| Small tables (under about 1 GB per partition) | Clustering alone often beats partitioning |
| Guardrail | require_partition_filter = true on large fact tables |
Pruning only happens when the filter is on the partition column with constant expressions (or query parameters). These defeat it:
-- prunes
where event_ts >= timestamp('2025-09-01') and event_ts < timestamp('2025-09-02')
where date(event_ts) = '2025-09-01' -- matches partition expression
-- often scans everything
where format_timestamp('%Y-%m', event_ts) = '2025-09'
where event_ts > (select max(loaded_at) from `proj.ops.watermarks`) -- check the dry run
Run a dry run to confirm bytes processed dropped; if a subquery-derived filter does not prune, compute the value first (scripting variable or parameter) and pass it in.
Query rewrites
| Pattern | Rewrite |
|---|---|
select * | Name columns; BigQuery bills by columns read. LIMIT does not reduce bytes billed on non-clustered tables |
| Repeated scans of the same big table in one script | Materialize once into a temp table |
count(distinct x) on huge data where exact is not needed | approx_count_distinct(x) |
| Join of two large tables then filter | Filter and aggregate each side first |
| Dedupe via self-join | qualify row_number() over (partition by id order by updated_at desc) = 1 |
Wildcard tables events_* | Filter _TABLE_SUFFIX with constants, or migrate to one partitioned table |
| Dashboard re-running the same aggregate | Materialized view or a scheduled summary table; BI Engine for interactive dashboards |
Read the execution graph (console Execution details, or JOBS job_stages): stages with large
“shuffle output” or “slot time” skewed between avg and max indicate data skew or exploding joins.
Guardrails
# per-query cap: the job fails instead of billing more than 50 GB
bq query --use_legacy_sql=false --maximum_bytes_billed=50000000000 'select ...'
from google.cloud import bigquery
job_config = bigquery.QueryJobConfig(maximum_bytes_billed=50 * 10**9, labels={"team": "growth"})
client.query(sql, job_config=job_config).result()
- Set custom quotas per user or project (query usage per day) in the Cloud console for on-demand projects.
- Label jobs (
--label team:growthorjob_config.labels) so cost can be attributed. - Use
require_partition_filteron big tables andpartition_expiration_daysfor data you do not need forever.
Capacity pricing (Editions)
- Compare: last 30 days of on-demand TiB billed times the on-demand rate versus slot-hours used
(
total_slot_ms) times the Edition rate. Fetch current prices from the BigQuery pricing page; they differ by region and edition. - Start with a small or zero baseline plus autoscaling max; raise the baseline only for steady load. Idle autoscaled slots still bill in increments, so short spiky workloads waste some.
- Separate reservations (or assignments) for ELT and BI so a heavy backfill cannot starve dashboards.
Storage
- Long-term storage (tables or partitions unmodified for 90 days) costs about half; avoid
rewriting old partitions needlessly (full-table
CREATE OR REPLACEresets it). - Physical (compressed) storage billing can be cheaper for well-compressed datasets but bills
time travel and fail-safe bytes; compare with
INFORMATION_SCHEMA.TABLE_STORAGEbefore switching.
Gotchas
- Cached results (identical query, unchanged tables, within 24 h) are free; benchmarks must
disable cache (
--nouse_cache) to compare fairly. - DML on partitioned tables should filter by partition; a
MERGEwithout a partition predicate on the target scans the whole table. - Streaming inserts land in a buffer that affects DML for a while; use the Storage Write API for new pipelines.
- Clustering is re-applied automatically in the background at no charge, but heavy small-batch writes leave recent data less well clustered for a while.
- Bytes estimates for clustered tables in dry runs are upper bounds; actual bytes billed can be lower.
Report template
**Scope:** project analytics-prod, on-demand, region US, last 30 days
**Top drivers:** looker dashboard "Daily KPIs" 41% of TiB billed (select * on events, no date filter)
**Changes:** events partitioned by date(event_ts), clustered by tenant_id, event_name; require_partition_filter on; KPI view rewritten with named columns
**Measured:** dry run 3.8 TiB -> 21 GiB per dashboard refresh
**Guardrails:** maximum_bytes_billed 100 GB on scheduled queries; job labels team:*
**Needs approval:** recreate events table (copy, validate counts, swap) during low traffic What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "BigQuery Optimization"
short_description: "Scan gigabytes, not terabytes"
default_prompt: "$bigquery-optimization Our BigQuery bill jumped last month, show me which queries and users caused it" references/information-schema-queries.md
# BigQuery cost attribution queries
Read this when you must explain a bill, find repeated expensive query patterns, or size a
reservation. Replace `region-us` with the dataset region. `JOBS_BY_PROJECT` needs
`bigquery.jobs.listAll` on the project; `JOBS_BY_ORGANIZATION` needs organization-level access.
Job history is kept for 180 days.
Convert units consistently: `total_bytes_billed / pow(1024, 4)` is TiB (on-demand is billed per
TiB), `total_slot_ms / 3600000` is slot-hours. Multiply by the current price for the region from
the pricing page rather than hard-coding a rate.
## Cost by user, last 30 days
```sql
select user_email,
count(*) as jobs,
round(sum(total_bytes_billed) / pow(1024, 4), 2) as tib_billed,
round(sum(total_slot_ms) / 3600000, 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 statement_type != 'SCRIPT' -- parent script rows double count their children
group by user_email
order by tib_billed desc;
```
## Repeated expensive patterns (same query shape)
`query_info.query_hashes.normalized_literals` groups queries that differ only in literals.
```sql
select query_info.query_hashes.normalized_literals as query_hash,
any_value(query) as sample_query,
count(*) as runs,
round(sum(total_bytes_billed) / pow(1024, 4), 2) as tib_billed,
round(avg(total_slot_ms) / 1000, 1) as avg_slot_seconds
from `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
where creation_time >= timestamp_sub(current_timestamp(), interval 14 day)
and job_type = 'QUERY' and statement_type != 'SCRIPT'
group by query_hash
order by tib_billed desc
limit 25;
```
A pattern with many runs and identical bytes is a candidate for a materialized view, a
scheduled summary table, or dashboard caching.
## Cost by label (team, pipeline, dashboard)
```sql
select l.key, l.value,
round(sum(total_bytes_billed) / pow(1024, 4), 2) as tib_billed,
round(sum(total_slot_ms) / 3600000, 1) as slot_hours
from `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT, unnest(labels) as l
where creation_time >= timestamp_sub(current_timestamp(), interval 30 day)
and job_type = 'QUERY' and statement_type != 'SCRIPT'
group by 1, 2
order by tib_billed desc;
```
Jobs with no labels do not appear here; compare the total with the per-user query to see how
much spend is unlabelled.
## Which tables are scanned the most
```sql
select concat(t.project_id, '.', t.dataset_id, '.', t.table_id) as table_name,
count(*) as jobs,
round(sum(j.total_bytes_billed) / pow(1024, 4), 2) as tib_billed_by_jobs_touching_it
from `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT j, unnest(j.referenced_tables) as t
where j.creation_time >= timestamp_sub(current_timestamp(), interval 30 day)
and j.job_type = 'QUERY' and j.statement_type != 'SCRIPT'
group by table_name
order by tib_billed_by_jobs_touching_it desc
limit 20;
```
Bytes are per job, not per table, so jobs joining several tables count toward each. Use it to
rank candidates for partitioning and clustering, not as an exact split.
## Full scans of partitioned tables
Jobs that billed a lot against tables that have `require_partition_filter` off are the usual
culprits. Cross-check candidates with a dry run of the job's query.
```sql
select job_id, user_email, total_bytes_billed / pow(1024, 3) as gib_billed, query
from `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT j, unnest(j.referenced_tables) t
where j.creation_time >= timestamp_sub(current_timestamp(), interval 7 day)
and t.table_id = 'events'
and j.total_bytes_billed > 500 * pow(1024, 3)
order by gib_billed desc;
```
## Slot usage over time (capacity planning)
```sql
select timestamp_trunc(period_start, hour) as hour,
-- slot-hours consumed in one hour equal the average number of busy slots in that hour
round(sum(period_slot_ms) / 3600000, 1) as avg_slots
from `region-us`.INFORMATION_SCHEMA.JOBS_TIMELINE_BY_PROJECT
where period_start >= timestamp_sub(current_timestamp(), interval 7 day)
and (statement_type != 'SCRIPT' or statement_type is null)
group by hour
order by hour;
```
The p50 of hourly average slots suggests a baseline; the peaks show how much autoscaling
headroom is needed. Compare the on-demand total from the per-user query with slot-hours times
the Edition price before recommending a switch.
## Storage by table
```sql
select table_schema, table_name,
round(active_logical_bytes / pow(1024, 3), 1) as active_logical_gib,
round(long_term_logical_bytes / pow(1024, 3), 1) as long_term_logical_gib,
round(active_physical_bytes / pow(1024, 3), 1) as active_physical_gib,
round(long_term_physical_bytes / pow(1024, 3), 1) as long_term_physical_gib,
round(time_travel_physical_bytes / pow(1024, 3), 1) as time_travel_gib
from `region-us`.INFORMATION_SCHEMA.TABLE_STORAGE
order by active_logical_bytes desc
limit 30;
```
A high logical-to-physical ratio favours physical storage billing for that dataset; large
`time_travel_gib` reduces the benefit. Billing model is set per dataset.
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.