Guide
BigQuery cost audit: 4 INFORMATION_SCHEMA queries that find waste
The GCP invoice tells you what BigQuery cost last month, not which table, user or query drove it. A BigQuery cost audit answers that with plain SQL on INFORMATION_SCHEMA, reading metadata only: repeated queries, logical vs physical storage, on-demand vs slots, forgotten backup tables.
·
Costory team
·
13 min read
The GCP invoice tells you what BigQuery cost last month, not which table, user or query drove it. A BigQuery cost audit answers that with plain SQL on INFORMATION_SCHEMA, reading metadata only.
What we keep finding on customer projects:
A modest query can become expensive through repetition. Dashboards, scheduled queries and internal tools can rerun it all day.
Physical storage can cost more than logical, even with great compression. A nightly
CREATE OR REPLACEkeeps up to 14 days of history, and physical billing charges for all of it.Fluid scaling changed the on-demand vs slots math. Per-second billing with no one-minute minimum removes the scale-down billing floor, so scan-heavy, low-CPU work now often runs cheaper on slots. One customer saved about €4,300 a month by moving two workloads and keeping a third on-demand.
Many ad hoc backup copies duplicate time travel. The default recovery window is 7 days, and
bq cpcan restore a table even after aDROP. Longer retention and independent backups still need a separate plan.
Below: one INFORMATION_SCHEMA query per finding, and how to act on the result.
Before you run the queries
Project and region: replace
<your-project>with the project that runs the jobs or owns the tables. All queries use the EU multi-region (region-eu) and EU list prices in USD; change both if your data lives elsewhere.Several projects: organization views need project-aware grouping, not just a view-name substitution. For checks 2 and 4, use
TABLE_STORAGE_BY_ORGANIZATIONand includeproject_idin the results and dataset grouping. For check 3, useJOBS_BY_ORGANIZATIONand includedestination_table.project_idin table-based workload keys; include executionproject_idin user-based keys if projects should stay separate. Check 1 needsJOBS_BY_PROJECT, because the organization view does not expose query text.Cost: these queries read metadata only and usually cost cents to a few dollars. Under on-demand pricing,
INFORMATION_SCHEMAqueries have a 10 MB billing minimum; under capacity pricing, they consume slots.Storage views:
TABLE_STORAGEis off by default on new projects, and checks 2 and 4 fail with instructions until you enable it (per project, or withALTER ORGANIZATIONfor all of them). History takes about a day to backfill.
Forecast vs invoice:
TABLE_STORAGEis a current snapshot, good for forecasting. To reconcile with the invoice,TABLE_STORAGE_USAGE_TIMELINEgives daily billable usage per table, a few days behind; the billing export remains the reference.
EU multi-region list prices used in this article, from the BigQuery pricing page as of September 2026:
Price (USD) | |
On-demand queries | $6.25 / TiB (first TiB per month free) |
Standard edition slots | $0.044 / slot-hour |
Enterprise edition slots | $0.066 / slot-hour |
Logical storage, active / long-term | $0.02 / $0.01 per GiB per month |
Physical storage, active / long-term | $0.044 / $0.022 per GiB per month |
1. Repeated queries
Start with the jobs that run repeatedly:
A Looker or Looker Studio dashboard with auto refresh every 5 minutes, left open on an office screen. Refreshes can trigger repeated BigQuery jobs when they are not served by the BI tool’s cache.
A scheduled query running every 15 minutes on data that changes once a day.
An internal tool that queries BigQuery on every page load.
This query groups identical SQL run at least 20 times in the past week. It counts each multi-statement script once and applies on-demand list prices, including to jobs run on slots.
Observed output, anonymized (29 October 2026). The 7-day check returned 45 SQL groups. The largest by billed bytes was:
Query | Runs | Cache hits | TiB billed | On-demand equivalent, USD | Slot-hours |
Query A | 1,365 | 0 | 0.03 | 0.16 | 0.1 |
Frequent does not automatically mean expensive. This result alone does not justify a materialization project. The dollar column is a list-price byte equivalent, not allocated reservation spend; check slot consumption separately for jobs running on capacity.
How to read it:
Sort by the total: a cheap query run 2,000 times matters more than an expensive one run once.
Same result every time: materialize it once (scheduled table or materialized view) and point the dashboard at it.
cache_hitsnear zero:CURRENT_TIMESTAMP()orCURRENT_DATE()in the SQL, or a source table written between runs, disables the cache.Large scans on a partitioned table: check whether the filter enables partition pruning and whether the requested date range is necessary.
ALTER TABLE ... SET OPTIONS (require_partition_filter = TRUE)rejects queries without a filter usable for partition elimination, but does not cap how many partitions a valid filter reads. A per-user daily query quota caps what anyone can scan, andmaximum_bytes_billedcaps a single query.Several rows with the same
query_start: inspect the full SQL before combining them. Queries differing only in literals may belong to the same workload.
2. Logical vs physical storage
BigQuery always stores data compressed. The dataset’s billing model only decides which bytes you pay for:
Logical (the default) bills the uncompressed size. Time travel and fail-safe are included.
Physical bills the compressed size at roughly 2.2× the price per GiB. Time travel and fail-safe are billed on top, at the active physical rate.
Without history overhead, the listed rates put the compression break-even at about 2.2×. Repeated values often compress well, but actual compression and retained history matter more than the column types. Compare the forecast costs below. The trap is churn. When a MERGE, UPDATE, DELETE or CREATE OR REPLACE changes or removes data, affected old blocks are retained for the time-travel window (7 days by default, configurable down to 2), then 7 days of fail-safe. Logical billing includes that history; physical billing charges it. The query measures it as churn_ratio: time-travel plus fail-safe bytes divided by active physical bytes.
active_physical_bytes already includes time-travel bytes, so the query subtracts them to avoid counting them twice. Deleted tables contribute no logical charges, but their retained physical history still counts in the physical forecast.
Observed output, anonymized (29 October 2026). Selected rows from the 852 dataset results; monthly forecasts hold current storage usage constant, not measured savings:
Dataset | Logical forecast, USD/month | Physical forecast, USD/month | Compression ratio | History/current-active ratio |
Dataset A | 1,573 | 308 | 32.5 | 1.88 |
Dataset B | 848 | 53 | 45.2 | 0.28 |
Dataset A remains cheaper on physical in the forecast even though retained history is larger than current active physical storage. A churn threshold alone would have given the wrong recommendation. Neither row establishes savings from switching: first check which billing model the dataset already uses. This query covers base tables only.
How to read it:
physical_usd_monthclearly lower: switch withALTER SCHEMA my_dataset SET OPTIONS (storage_billing_model = 'PHYSICAL');High
churn_ratio: rewrite history can outweigh the compression gain. Use the forecast cost columns to decide; churn alone does not tell you which billing model wins.Churny but still worth physical:
ALTER SCHEMA my_dataset SET OPTIONS (max_time_travel_hours = 48);cuts time travel to 2 days (fail-safe stays at 7). Make sure 2 days of recovery is enough.Append-only logs mixed with heavily mutated tables: split the dataset first. The billing model is set per dataset, not per table.
After a switch you must wait 14 days to switch back, and billing takes about 24 hours to reflect it.
3. On-demand vs slots
Decide per workload, not per project.
Slot billing now tracks real work
Earlier slot models meant paying for fixed capacity or, with autoscaling, a one-minute minimum per scale-up. Fluid scaling removes that minimum: capacity still scales in 50-slot steps, but billing follows it per second. Enable it per reservation from the admin project:
If you settled on-demand vs slots two years ago, the answer may have changed.
Break-even: 142 slot-hours per TiB
On-demand bills bytes scanned; capacity pricing bills allocated slot capacity over time. The useful number is slot-hours per TiB. At EU list prices, a workload under about 142 slot-hours per TiB (6.25 / 0.044) is cheaper on Standard edition slots, before autoscaling overhead. On Enterprise ($0.066) the break-even drops to about 95. Slots you already pay for (baseline, commitments) are cheaper still to fill.
For example, on one customer project (savings in EUR, unlike the USD prices elsewhere):
Data integrity checks (counts,
COUNT(DISTINCT), simple aggregations over full tables) scan many TB with little CPU. Moving them to slots saved about €2,800 per month.Client-facing filtering jobs read the full data, but their filters shrink the input almost immediately: about €1,500 per month saved on slots.
Complex joins and heavy aggregations burn a lot of CPU per TiB, came out more expensive on slots, and stayed on-demand.
All three shared projects and often a service account. Moving a whole project to slots would have offset some savings with higher costs for heavy joins.
Measure each workload
This first pass groups jobs by destination table, falling back to the execution identity for anonymous results. If several workloads share an identity, refine the grouping with destination tables, referenced_tables, labels or SQL patterns before deciding where to route them. A source table can also serve several workloads, so validate the groups rather than treating any single clue as definitive.
Observed output, anonymized (29 October 2026). Selected rows from the 7 workload groups above the $10 filter, over the preceding 30 days:
Workload group | Jobs | TiB billed | Slot-hours/TiB | On-demand equivalent, USD | Standard slot estimate, USD |
Workload A | 2,688 | 13.36 | 3 | 83 | 3 |
Workload B | 1,794 | 10.87 | 10 | 68 | 6 |
Workload C | 38 | 23.06 | 662 | 144 | 721 |
A and B are candidates for a reservation trial; C points the other way. These are measured on-demand job statistics priced under two models, not a completed migration or realized savings. Workload names are aliases for the query’s first-pass groups.
How to read it:
Compare the dollar columns:
slot_hours_per_tibmeasures consumed slot time;standard_slots_estimate_usdadds a pessimistic allowance for capacity rounding. A workload just under the break-even ratio can still be cheaper on-demand.Worst-case rounding allowance: assume capacity follows concurrent consumption instantly, rounded up in 50-slot blocks. The query adds a full 50 slots for every second each job runs. Rounding wastes less than 50 slots for the shared reservation at any instant; charging that allowance to every concurrent job deliberately overcounts it.
Validate before moving: this is a worst case for rounding under those assumptions, not a ceiling on the full bill. Baseline capacity, scaling delays and different execution times remain outside the model, as does the free first TiB. Validate shortlisted workloads in a trial reservation, checking billed capacity and latency.
Small workloads hidden:
WHERE tib_billed * 6.25 > 10drops workloads under $10 of on-demand spend over the 30 days.
Why the rounding allowance is pessimistic
Illustration, not production measurements. Job A uses 30 slots from 0–20 seconds; Job B uses 40 from 10–30 seconds. During the overlap, consumption is 70 slots and ideal shared capacity rounds to 100. The query allows 170: 70 consumed plus 50 for each of the two jobs. The dashed line is the query’s allowance, not a measured autoscaler trace.
Route workloads to slots or on-demand
Two execution projects: one assigned to the reservation, one left on-demand. Each query runs in the project that suits it.
Assign only
QUERYjobs to the reservation: load and extract jobs are free on the shared slot pool. They consume reservation slots only if the project also has aPIPELINEassignment.With dbt: our open-source dbt-adapter-bigquery-project-selector picks the execution project per model or tag: integrity tests and filtering models on slots, heavy joins on-demand.
Define workloads without perfect labels
Destination and referenced tables can identify useful workload groups without labels. When integrity checks and BI dashboards touch the same tables under the same service account, combine those clues with SQL patterns or job labels.
Each job carries small clues: user or service account, project, destination table, tables read, statement type and job labels (dbt can add the model name). None is enough alone. Combined, they usually are.
Costory is a FinOps platform for allocating cloud costs to teams and workloads. Its BigQuery jobs connector attaches job clues to billing lines; a virtual dimension applies rules to those clues to assign a workload label:
Clue on the job | Workload |
Reads tables in | Data integrity |
Writes to | Client-facing |
Runs as the Looker service account | BI dashboards |
Carries a dbt | dbt, by model |
Anything else in project | Analytics (default) |
What you get:
Allocation without a labelling project: per workload, team or product, refined as labels appear.
Slot cost per job: GCP bills a reservation as one line, so Costory reallocates it to each job from its slot time (
query_compute_ms).One view for both models: on-demand and slot spend per workload, with slot-hours per TiB as a tracked formula. Projects without job access show as Unlabelled, so you see where coverage is missing.
4. Quick housekeeping: forgotten backup tables
Finish with a simpler housekeeping check: tables such as orders_backup_20240312, customers_old or tmp_fix_revenue, copied “just in case” and left in storage. Their names flag candidates for review, not proof that they can be deleted:
Observed output, anonymized (29 October 2026). The name pattern returned three candidates:
Table | Created | Last written | Logical GiB | Logical-price comparison, USD/month |
Candidate A | 2026-10-28 | 2026-10-28 | 972.7 | 19.45 |
Candidate B | 2025-06-27 | 2025-06-27 | 19.0 | 0.19 |
Candidate C | 2025-06-27 | 2025-06-27 | 7.6 | 0.08 |
The largest candidate was written the day before the audit. A matching name does not prove abandonment. The other two have old write timestamps, but that says nothing about recent reads or retention requirements.
How to read it:
Adjust the pattern to your naming habits.
Date suffixes are left out on purpose: GA4 and other exports shard tables by date.
Logical rates only:
usd_month_logicalis a logical-price comparison, not the actual cost or deletion saving for datasets on physical billing.
Before deleting a copy, confirm its owner and retention requirements. For short-term recovery, BigQuery retains changed or deleted table data for 7 days by default, unless the dataset uses a shorter time-travel window. Copy jobs are free within a region, though the resulting table incurs storage charges. For longer retention, keep a deliberate backup or snapshot policy.
For recovery within the time-travel window, restore into a new table with a time decorator:
For a deliberate longer-lived restore point, use an expiring snapshot. It shares unchanged blocks with its base table, but retained blocks after updates, deletion or reclustering can still incur substantial storage charges. Fail-safe adds 7 days after time travel and requires Google Cloud Customer Care access; it is an emergency recovery path, not a backup policy.
Keep the audit running
These checks are a snapshot, and the results drift. A new dbt model can add nightly full refreshes to a dataset you just moved to physical. A workload that was cheap on slots can gain a heavy join and flip back.
With Costory:
See how to set this up in 5 minutes
A dashboard shows the potential savings from these checks per workload, on the bill itself.
Costory Scout reruns them on a schedule and flags new repeating queries or churning datasets.
The same checks are open source as agent skills in costory-finops-mcp-skills, if you prefer running them with your own agent.

Costory is free for 30 days, then from €250 per month (billed annually) for up to €10M of annual cloud and AI spend.

Put this into practice with your own cost data
30-day free trial. Plans from €250/month, billed annually. We recommend Team (€600/month) once more than one team uses Costory.
First optimization recommendation
The next day
ROI
Pays for itself with the first recommendation you act on
Setup
Under 30 minutes
Data residency
EU
Access
Read-only on your cloud
