How to Audit Your GCP Costs with Native Tools (Billing Reports, Recommender, BigQuery Export)
A proper GCP cost audit needs exactly zero third-party software. Google ships everything required — billing reports in the console, a fleet of Recommenders generating findings continuously, Cloud Monitoring for verification, a BigQuery export that turns your invoice into SQL, and gcloud for raw inventory. What Google doesn't ship is the procedure: which tool answers which question, in what order, and what counts as a finding. That procedure is this article.
Part one of this series covered the seven places GCP spend leaks: orphaned persistent disks and snapshots, idle or oversized Compute Engine instances, committed use discount (CUD) coverage gaps, unused static IPs and idle load balancers, over-provisioned Cloud SQL, egress and inter-zone traffic, and non-production projects running around the clock. Here you'll hunt all seven, grouped by tool rather than by waste source — because in practice you open a tool once and extract everything it knows before moving on.
One thing to do before reading further: if the BigQuery billing export isn't enabled on your billing account, enable it now (Tool 4 below has the path). It only captures data from the moment you switch it on, so every day you wait is a day of history you can never query.
Tool 1: Cloud Billing reports — coverage gaps and the big picture
GCP billing reports live at Billing → Reports in the console (docs). This is where you size the problem and where waste source #3 — CUD coverage — gets answered definitively.
Three passes through the report view:
- Group by: Service, time range last 90 days. Note which services carry the bill and which are trending up. For a typical SaaS footprint, Compute Engine, GKE (billed as Compute Engine SKUs plus a cluster fee), Cloud SQL, and networking dominate.
- Group by: Project, same range. A staging or sandbox project sitting in the top five is an immediate lead for waste source #7 — flag it now and quantify it with BigQuery later.
- Filter to one service, group by SKU whenever a number surprises you. SKU granularity is where vague categories resolve into specifics: "Network Inter Zone Data Transfer Out" and "Network Internet Data Transfer Out" SKUs under Compute Engine are your first sighting of waste source #6.
Also toggle credits on and off in the report settings. The delta between gross and net cost is everything discount-related — sustained use, committed use, promotional — and if that delta is small while Compute Engine is large, you already suspect the answer to the next step.
The CUD analysis report
Open Billing → Committed use discounts, then the CUD analysis report (docs). It plots two lines over time:
- Utilization — how much of the commitment you bought is actually consumed. Anything under 100% is money spent on capacity nobody used, because commitments bill regardless.
- Coverage — how much of your eligible spend sits behind a commitment. For a stable 24/7 baseline, roughly 60–80% coverage is healthy. Materially below that on a mature workload means a large slice of always-on compute pays on-demand rates for no reason. Coverage pushing 100% is its own warning: you've committed to your peaks, and utilization will sag when traffic does.
Check expiry dates while you're here — a commitment that lapses quietly re-prices everything it covered back to on-demand:
gcloud compute commitments list \
--format="table(name, region.basename(), status, plan, endTimestamp)"
Anything with an endTimestamp in the next quarter goes on the calendar today, with an owner.
Tool 2: GCP Recommender — four recommenders that find waste for you
Google's Recommender service analyzes your usage continuously and publishes findings per project and location. Findings surface in the console under Home → Recommendations (and inline on the Compute Engine pages), but the CLI is what scales past one project. Enable the API once per project:
gcloud services enable recommender.googleapis.com
Four recommenders map directly onto waste sources #1, #2, and #4:
| Recommender ID | Finds | Location scope |
|---|---|---|
google.compute.instance.IdleResourceRecommender |
Idle VMs (waste source #2) | zone |
google.compute.instance.MachineTypeRecommender |
Oversized VMs (waste source #2) | zone |
google.compute.disk.IdleResourceRecommender |
Idle persistent disks (waste source #1) | zone or region |
google.compute.address.IdleResourceRecommender |
Unused static IPs (waste source #4) | region |
Pull idle-VM findings for one zone:
gcloud recommender recommendations list \
--project=my-project \
--recommender=google.compute.instance.IdleResourceRecommender \
--location=us-central1-a \
--format="table(name.basename(), priority, primaryImpact.costProjection.cost.units, description)"
Swap the --recommender and --location values for the other three — zones for the instance and disk recommenders (idle VM docs, machine type docs), regions for addresses. Because findings are per-location, wrap the call in a loop over the zones you actually deploy to:
for z in us-central1-a us-central1-b us-central1-c; do
echo "== $z =="
gcloud recommender recommendations list --project=my-project \
--recommender=google.compute.instance.MachineTypeRecommender \
--location=$z --format="value(description)" 2>/dev/null
done
Two fields tell you how seriously to take each row. Priority runs P1 (act now) down to P4; cost recommendations usually land P2–P3. primaryImpact.costProjection carries a negative cost value — the projected savings — over a duration, typically 30 days, so a units value of -140 reads as roughly 140 USD per month recovered if you apply it. Sort your findings document by that column.
Know what feeds these numbers. The idle-VM recommender looks at the previous 14 days of CPU and network; the machine-type recommender uses 8 days by default. Both windows are short enough to miss a monthly billing run or a quarterly load test, which is exactly why Tool 3 exists. And neither recommender ever asks the more valuable question — whether the workload should exist at all. That one stays yours.
Tool 3: Cloud Monitoring — confirming a resource is actually idle
Every resize or shutdown decision from Tool 2 should be checked against a longer metric window before anyone touches production. Monitoring → Metrics Explorer in the console is enough — no dashboards required (Metrics Explorer docs).
For a suspect VM, chart compute.googleapis.com/instance/cpu/utilization, filtered to the instance, over the last 30 days, and view both the mean and max aligners. A defensible action threshold:
- Average CPU under about 10% and maximum under about 40% across 30 days — the instance is a shutdown or downsize candidate, full stop.
- Average low but max spiking past 60–70% — it's bursty, not idle. Consider a smaller machine type in the same family or a shared-core type, not deletion.
The same pass handles waste source #5. Cloud SQL exposes cloudsql.googleapis.com/database/cpu/utilization and cloudsql.googleapis.com/database/memory/utilization (Cloud SQL monitoring docs). Apply the same 30-day lens: CPU averaging single digits with memory utilization comfortably under 80% means the tier is at least one step too large. Watch memory separately from CPU — databases starve on RAM long before they starve on cores, and a CPU-only reading will happily tell you to shrink an instance that's already swapping. If memory is the constraint, move to a machine type with a higher memory-to-vCPU ratio instead of down a size.
For load balancers flagged later in Tool 5, loadbalancing.googleapis.com/https/request_count over 30 days settles the question — a forwarding rule serving near-zero requests for a month has no defenders.
Tool 4: BigQuery billing export — the audit's query engine
Enable the export under Billing → Billing export → BigQuery export, and pick the detailed usage cost table — it includes resource-level records and the full label set (export docs). Data lands in a table named gcp_billing_export_resource_v1_ plus your billing account ID; the schema is documented here, and the fields you'll live in are service.description, sku.description, project.id, usage_start_time, cost, credits, and labels.
Once data is flowing, bigquery billing export analysis replaces an afternoon of console clicking with three queries. Substitute your own table name for the placeholder.
Query 1 — top 10 SKUs by cost, last 30 days. Your audit priority list, one row per line item:
SELECT
service.description AS service,
sku.description AS sku,
ROUND(SUM(cost), 2) AS gross_cost
FROM `project.dataset.gcp_billing_export_resource_v1_XXXXXX`
WHERE usage_start_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY service, sku
ORDER BY gross_cost DESC
LIMIT 10;
If a SKU in this list surprises you, the audit has already paid for itself. Persistent disk SKUs ranking high while your fleet is modest usually means waste source #1; premium networking SKUs point at #6.
Query 2 — network egress and inter-zone cost by project (waste source #6). This attributes traffic cost to the project generating it, which the console never quite does:
SELECT
project.id AS project_id,
sku.description AS sku,
ROUND(SUM(cost), 2) AS cost
FROM `project.dataset.gcp_billing_export_resource_v1_XXXXXX`
WHERE usage_start_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
AND (LOWER(sku.description) LIKE '%egress%'
OR LOWER(sku.description) LIKE '%inter zone%'
OR LOWER(sku.description) LIKE '%inter region%')
GROUP BY project_id, sku
HAVING cost > 0
ORDER BY cost DESC;
Networking above roughly 3–5% of a project's total spend warrants a design conversation. Heavy inter-zone rows usually mean chatty services split across zones for availability they may not need; heavy internet egress through Cloud NAT toward Google APIs is fixable with Private Google Access.
Query 3 — cost by environment label (waste sources #7 and hygiene). This one query exposes both your non-prod spend and how much of the bill nobody labeled at all:
SELECT
IFNULL(
(SELECT l.value FROM UNNEST(labels) l WHERE l.key = 'env'),
'unlabeled') AS env,
ROUND(SUM(cost), 2) AS cost
FROM `project.dataset.gcp_billing_export_resource_v1_XXXXXX`
WHERE usage_start_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY env
ORDER BY cost DESC;
Adjust the key to whatever your labeling convention uses. Read the output two ways. First, any environment that only serves engineers during working hours needs about 36% of a week's compute (60 of 168 hours); the rest of its spend is a scheduling opportunity. Second, a large unlabeled bucket is itself a finding — you can't attribute, budget, or schedule what you can't identify, so labeling enforcement goes in the report next to the dollar figures.
Tool 5: gcloud sweeps — inventory the Recommenders can't see
The last pass is raw inventory via gcloud cost commands — no analysis window, no eligibility rules, just what exists right now. These four catch waste sources #1, #2, and #4 directly:
# Persistent disks attached to nothing (empty users field)
gcloud compute disks list --filter="-users:*" \
--format="table(name, zone.basename(), sizeGb, type.basename(), creationTimestamp)"
# Snapshots that outlived your retention policy (adjust the date)
gcloud compute snapshots list --filter="creationTimestamp<'2026-04-01'" \
--format="table(name, diskSizeGb, sourceDisk.basename(), creationTimestamp)"
# Static addresses reserved but not attached — these bill hourly while unused
gcloud compute addresses list --filter="status=RESERVED" \
--format="table(name, region.basename(), address, addressType)"
# Every instance with its machine type — scan for the n2-highmem-16 nobody remembers
gcloud compute instances list \
--format="table(name, zone.basename(), machineType.basename(), status)"
Add one more for the idle load balancers in waste source #4 — GCP bills load balancing through forwarding rules, so list those and cross-check each against the request-count metric from Tool 3:
gcloud compute forwarding-rules list \
--format="table(name, region.basename(), IPAddress, target.basename())"
None of this is useful run against a single project when you have forty. Loop the whole set:
for p in $(gcloud projects list --format="value(projectId)"); do
echo "== $p =="
gcloud compute disks list --project=$p --filter="-users:*" \
--format="value(name, zone.basename(), sizeGb)" 2>/dev/null
done
Disposition rules, so the output becomes decisions instead of a spreadsheet: unattached disks get snapshotted and deleted after a one-week ownership challenge; reserved-unused addresses get released the same day (there is no data to lose); stale snapshots get a retention policy applied going forward and a bulk cleanup behind it; mystery large machine types get routed back through Tool 3 before anyone resizes.
Done properly, these five passes produce a findings document that touches all seven waste sources with a projected monthly figure against each line — a genuinely useful artifact, whatever you do next.
What this audit cannot do
Worth being clear-eyed about four properties of the exercise you just ran, because they're structural, not fixable with effort.
The audit expires the moment you finish it. Your projects on Tuesday are not your projects the Tuesday after — new deploys create new disks, experiments get abandoned mid-flight, and traffic drift invalidates rightsizing math. Each of the seven waste sources is generated continuously; you sampled them once.
The price is paid in senior attention. The people qualified to judge whether a database can drop a tier are the same people shipping the roadmap. A first full pass takes an afternoon or two; the real cost is that the cadence collapses under competing priorities, and the second audit happens two quarters later than planned.
Recommendations age badly. Recommender findings built on 8- and 14-day windows describe last week. Sit on them for a sprint or three and you're executing decisions against data that no longer describes anything — the CUD expiry you calendared is the only finding with a built-in deadline, and it won't extend itself.
Every finding above is homework, not a fix. No query in this article released an address, resized an instance, or purchased a commitment. Each row converts to a ticket, tickets need owners, and the gap between audited savings and realized savings lives precisely in that conversion.
So treat the audit as the baseline it is — and treat the recurring version of it as an automation problem.
What comes next
Part three closes the loop: running these same checks continuously with an AI agent. CloudThinker's CostOps agent executes this audit daily over read-only GCP access — idle resources, CUD coverage and expiry, orphaned disks, unlabeled spend — and gates every remediation behind your approval instead of a ticket queue.
If you'd rather see your own projects' findings before reading on, the free tier includes 100 premium credits, no card required.
