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.

  • Data & Analytics
  • $bigquery-optimization
  • 160 lines
  • 3 files
  • ~1,850 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- bigquery-optimization

Installs to ~/.agents/skills/bigquery-optimization/ for every project on this machine.

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

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.

~/.agents/skills/bigquery-optimization/SKILL.md

--- name: bigquery-optimization description: Cuts BigQuery cost and runtime - partitioned and clustered tables, require_partition_filter, dry runs and maximum_bytes_billed, reading the query… license: MIT ---

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

  1. Read AGENTS.md; find how SQL runs (dbt with type: bigquery, bq CLI, Python client, Dataform). Queries and dry runs need network and credentials; in a sandbox ask for approval or ask the user to run them.
  2. Learn the billing model: on-demand (billed per TiB scanned) or capacity (Editions reservations, billed per slot-hour). The fix differs:
    ModelWhat costs moneyOptimise for
    On-demandBytes billed per queryFewer bytes: partitions, clustering, fewer columns
    Editions / reservationsSlot-hours (baseline + autoscale)Less slot time: less shuffle, fewer repeated scans, right-sized baseline
  3. Know the region (region-us, region-eu, …); INFORMATION_SCHEMA views are regional.
  4. 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`;
DecisionRule
Partition columnThe date or timestamp almost every query filters on; daily granularity by default, monthly or yearly when daily partitions would be tiny
Partition limit10,000 partitions per table; do not partition by hour over years
Integer range partitioningOnly when queries filter on an integer range (customer bucket, ID ranges)
Clustering columnsUp 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
Guardrailrequire_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

PatternRewrite
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 scriptMaterialize once into a temp table
count(distinct x) on huge data where exact is not neededapprox_count_distinct(x)
Join of two large tables then filterFilter and aggregate each side first
Dedupe via self-joinqualify 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 aggregateMaterialized 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:growth or job_config.labels) so cost can be attributed.
  • Use require_partition_filter on big tables and partition_expiration_days for 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 REPLACE resets 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_STORAGE before 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 MERGE without 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.yamlgenerated from this page

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.md127 lines, 5.0 KB
# 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.

esc