
Per-Team Cost Allocation on GCP: Labels, Folders & Billing Queries | 2026
Per-team cost allocation on GCP is built in three layers — enforce a label taxonomy (`team`, `env`, `cost-center`) via Terraform `default_labels` and organization policy, group projects into team folders, and query the BigQuery Detailed billing export by label and project ancestry to produce a showback report with near-complete attribution.
By Mateusz Chmielewski · Aug 17, 2026 · 14 min read
What Is Per-Team Cost Allocation on GCP?
Per-team cost allocation (showback/chargeback) is the practice of attributing every dollar of Google Cloud spend to the team that caused it. GCP gives you three native signals: resource labels (key-value pairs attached to VMs, buckets, clusters, etc.), the resource hierarchy (folders and projects), and the Cloud Billing BigQuery export, which joins usage rows with both signals so you can aggregate cost by any dimension in SQL.
Think of it like a shared office building with one electricity meter. Labels are the sub-meters on each team's floor, folders are the floor plans that group rooms by department, and the BigQuery export is the itemized utility bill that lets finance split the total fairly instead of dividing by headcount.
| Concept | Explanation | When to use |
|---|---|---|
| Resource Labels | Key-value metadata (`team=payments`) attached to resources and propagated into the billing export. | For fine-grained attribution inside shared projects (per service, per environment, per cost center). |
| Folders & Projects | Resource hierarchy nodes that group projects under a team or business unit. | For coarse-grained attribution, IAM/budget scoping, and allocating resources that do not support labels. |
| BigQuery Detailed Usage Cost Export | Automatic daily export of resource-level usage rows including labels, project ancestry, credits, and SKU detail. | As the single source of truth for all showback, chargeback, and anomaly analysis queries. |
| Terraform default_labels | Provider-level labels automatically applied to every label-capable resource Terraform manages. | To guarantee 100% label coverage on Terraform-managed infrastructure without editing each resource. |
Why Not Just Split the Bill by Project Count?
Default billing views group cost by project or SKU, which breaks down fast in the real world — shared platform projects host many teams, a single GKE cluster runs dozens of namespaces, and unmanaged resources (support plans, network egress between projects) belong to nobody. Without enforced labels and hierarchy, 15–30% of spend typically lands in an unattributed bucket and finance falls back to splitting evenly, which kills any incentive to optimize.
Enforce a three-key label taxonomy at the Terraform provider level, map projects to team folders, and let the BigQuery Detailed export carry both dimensions into SQL. Unlabeled rows become visible and shrinkable instead of silently averaged. For reducing the bill once you can see it, combine this with [Spot VMs and Preemptible Capacity for GKE](/tutorial/spot-vms-preemptible-gke-cost-optimization) and guardrails from [GCP Organization Policies to Enable by Default](/tutorial/gcp-organization-policies-defaults-terraform-module).
| Feature | thisService | altA | altB |
|---|---|---|---|
| Granularity | Label + folder + SKU + resource level | Invoice by project (console reports) | Third-party FinOps SaaS |
| Shared-service allocation | SQL-driven split rules (labels, custom mapping tables) | Not supported | Supported, vendor-specific rules engine |
| Data freshness | Daily (detailed export) | Monthly invoice | Daily to weekly depending on connector |
| Cost | Free export + BigQuery storage/scan ($) | Free | Typically 1–3% of managed cloud spend |
| Ownership of data | Your dataset, full SQL access | Console only, limited exports | Vendor platform, export varies by plan |
Prerequisites
- GCP Organization with a Cloud Billing account and `roles/billing.admin` (or `roles/billing.costsManager` + BigQuery admin) permissions
- `roles/resourcemanager.folderAdmin` and `roles/resourcemanager.organizationViewer` on the organization
- gcloud CLI v450.0+ installed and authenticated
- Terraform v1.5.0+ with the `google` provider v5.x+
- A dedicated project to host the billing BigQuery dataset (e.g. `billing-export-prod`)
Step-by-Step Guide
Step 1: Enable APIs and Create the Billing Dataset Project
Cost allocation data lives in BigQuery. Create (or reuse) a dedicated project to host the export dataset and enable the required APIs so billing, BigQuery, and budget tooling can work together. Keeping the billing dataset in its own project isolates access (only FinOps gets query rights), prevents accidental deletion, and keeps query-scan costs attributable to the platform team instead of a random workload project.
export BILLING_PROJECT_ID=billing-export-prod
export BILLING_ACCOUNT_ID=$(gcloud billing accounts list \
--format='value(name)' --filter=open=true | head -1)
gcloud services enable bigquery.googleapis.com \
bigquerydatatransfer.googleapis.com \
billingbudgets.googleapis.com \
cloudresourcemanager.googleapis.com \
--project=$BILLING_PROJECT_ID
Step 2: Create Team Folders and Move Projects Under Them
Build a folder hierarchy that mirrors how you want to report spend — one folder per team (or business unit), with projects moved underneath. Folders give you attribution for resources that do not support labels and a clean scope for budgets and IAM. Many SKUs (support fees, some network egress, commitment discounts at billing-account scope) carry no labels. Folder-level ancestry in the billing export is the only signal that lets you allocate them without manual spreadsheets.
resource "google_folder" "team_payments" {
display_name = "team-payments"
parent = "organizations/${var.org_id}"
}
resource "google_folder" "team_platform" {
display_name = "team-platform"
parent = "organizations/${var.org_id}"
}
resource "google_project" "payments_api_prod" {
name = "payments-api-prod"
project_id = "payments-api-prod-${var.env_suffix}"
folder_id = google_folder.team_payments.name
billing_account = var.billing_account_id
labels = {
team = "payments"
env = "prod"
cost_center = "cc-4100"
}
}
Step 3: Enforce the Label Taxonomy with Terraform default_labels
Define the canonical taxonomy (`team`, `env`, `cost_center`) once and apply it through the provider's `default_labels` block so every label-capable resource Terraform creates is tagged automatically. Per-resource label discipline fails at scale — one engineer forgetting one label on one VM corrupts a month of showback. Provider-level enforcement makes the correct behavior the default and turns missing labels into a code-review issue instead of a finance issue.
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.40"
}
}
}
provider "google" {
project = var.project_id
region = var.region
default_labels = {
team = "payments"
env = "prod"
cost_center = "cc-4100"
}
}
# No labels needed here — the provider applies them.
resource "google_compute_instance" "api" {
name = "payments-api-01"
machine_type = "e2-standard-2"
zone = "${var.region}-b"
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
network_interface {
network = "default"
}
}
Step 4: Enable the BigQuery Detailed Usage Cost Export
Point the Cloud Billing account at a BigQuery dataset and enable the Detailed usage cost export (not just Standard). Detailed adds resource-level rows and full label/ancestry fidelity needed for per-team allocation. The Standard export aggregates to project+SKU level; the Detailed export includes `resource.name`, all labels, and project ancestry, which is the difference between "team used this project" and "team used these 47 VMs inside a shared project".
resource "google_bigquery_dataset" "billing_export" {
dataset_id = "billing_export"
project = var.billing_project_id
location = "EU"
description = "Cloud Billing Detailed usage cost export"
labels = {
team = "platform"
env = "prod"
}
}
# Export itself is configured once via Console or the Billing API:
# Billing → Billing export → BigQuery export →
# enable "Detailed usage cost" → dataset billing_export
#
# Verify rows are landing:
# bq query --use_legacy_sql=false '
# SELECT MAX(_PARTITIONTIME) AS latest
# FROM `billing-export-prod.billing_export.gcp_billing_export_resource_v1_*`'
Step 5: Query Cost per Team by Label
Write the core showback query — sum cost grouped by the `team` label over the current month, excluding credits, using the Detailed export table. This is the query finance and engineering leads actually consume. Grouping by the label (not project) is what makes shared projects and shared clusters allocatable.
-- cost_per_team.sql
SELECT
team_label.value AS team,
service.description AS service,
ROUND(SUM(cost), 2) AS cost_usd,
ROUND(SUM(cost) * 100.0 / SUM(SUM(cost)) OVER (), 1) AS pct_of_total
FROM
`billing-export-prod.billing_export.gcp_billing_export_resource_v1_*`,
UNNEST(labels) AS team_label
WHERE
_TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_TRUNC(CURRENT_DATE(), MONTH))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE())
AND team_label.key = 'team'
AND cost_type = 'regular' -- exclude credits and adjustments
GROUP BY team, service
ORDER BY cost_usd DESC;
Step 6: Query Cost per Folder Using Project Ancestry
Use the `project.ancestors` field in the Detailed export to roll spend up to the team folder — this captures resources with no labels and validates your label coverage. Folder-level totals are your reconciliation anchor: SUM(folder totals) must equal the invoice. Comparing folder totals against label totals exposes exactly how much spend is escaping your label taxonomy.
-- cost_per_folder.sql
SELECT
ancestor.display_name AS folder_name,
project.id AS project_id,
ROUND(SUM(cost), 2) AS cost_usd
FROM
`billing-export-prod.billing_export.gcp_billing_export_resource_v1_*`,
UNNEST(project.ancestors) AS ancestor
WHERE
_PARTITIONTIME >= TIMESTAMP(DATE_TRUNC(CURRENT_DATE(), MONTH))
AND ancestor.resource_name LIKE 'folders/%'
AND cost_type = 'regular'
GROUP BY folder_name, project_id
ORDER BY cost_usd DESC;
Step 7: Hunt Unlabeled Spend
Close the loop by querying usage rows that carry no `team` label at all — the attribution leak that silently corrupts every showback report. An unlabeled-spend report trending toward zero is the only honest measure of allocation coverage. Without it, finance cannot tell the difference between "platform team spent $4K" and "somebody forgot a label".
-- unlabeled_spend.sql
SELECT
project.id AS project_id,
service.description AS service,
ROUND(SUM(cost), 2) AS unlabeled_cost_usd
FROM
`billing-export-prod.billing_export.gcp_billing_export_resource_v1_*`
WHERE
_PARTITIONTIME >= TIMESTAMP(DATE_TRUNC(CURRENT_DATE(), MONTH))
AND NOT EXISTS (
SELECT 1 FROM UNNEST(labels) AS l WHERE l.key = 'team'
)
AND cost_type = 'regular'
GROUP BY project_id, service
HAVING unlabeled_cost_usd > 1
ORDER BY unlabeled_cost_usd DESC;
Step 8: Create Per-Team Budget Alerts Filtered by Label
Give every team its own Cloud Billing budget scoped to its label (and projects), so an 80%-of-budget alert pages the team that owns the spend instead of a central platform inbox. Label-scoped budgets turn cost ownership into an automated, near-real-time signal. Without them, overspend is discovered weeks later on the invoice, when nothing can be done about it.
resource "google_billing_budget" "team_payments" {
billing_account = var.billing_account_id
display_name = "team-payments-monthly"
budget_filter {
projects = ["projects/${data.google_project.payments_api_prod.number}"]
labels = {
team = ["payments"]
}
}
amount {
specified_amount {
currency_code = "USD"
units = "15000"
}
}
threshold_rules {
threshold_percent = 0.8
}
threshold_rules {
threshold_percent = 1.0
}
}
Step 9: Schedule the Showback Report
Persist the per-team query as a BigQuery scheduled query that writes a reporting table on the 2nd of each month (after the export settles), so dashboards and finance exports read stable, cheap data. Re-running raw export scans for every dashboard view is slow and burns query-scan budget; a materialized monthly table costs cents and gives every consumer the same numbers.
resource "google_bigquery_data_transfer_config" "monthly_showback" {
display_name = "cost-per-team-monthly"
project = var.billing_project_id
location = "EU"
data_source_id = "scheduled_query"
schedule = "every month 2 06:00"
destination_dataset_id = google_bigquery_dataset.finops.dataset_id
params = {
query = file("${path.module}/sql/cost_per_team_monthly.sql")
destination_table_name_template = "cost_per_team_monthly"
write_disposition = "WRITE_TRUNCATE"
}
}
Verification & Health Check
Best Practices
- Enforce Labels at the Provider, Not per Resource
- Keep Label Values Lowercase and Stable
- Use Folders for Unlabelable Spend
- Reconcile Labels Against Folder Totals Monthly
Common Mistakes
- {"errorCode":"LABELS_MISSING_FROM_EXPORT","symptoms":"Billing export rows have empty `labels` arrays even though resources are labeled.","rootCause":"Labels were applied after the usage occurred — billing labels are not retroactive — or the Standard export is being used instead of Detailed for resource-level rows.","fixCommand":"gcloud compute instances add-labels payments-api-01 --labels=team=payments,env=prod --zone=europe-west1-b\n","code":"provider \"google\" {\n default_labels = {\n team = \"payments\"\n env = \"prod\"\n }\n}\n","language":"hcl","filename":"fix-labels.tf","prevention":"Enforce default_labels in every Terraform root module and lint console-created resources via the unlabeled-spend scheduled query."}
- {"errorCode":"BILLING_EXPORT_TABLE_NOT_FOUND","symptoms":"bq query fails with `Not found: Table ...gcp_billing_export_resource_v1_...`","rootCause":"The Detailed usage cost export was never enabled (only Standard), the dataset is in a different project, or less than 24h has passed since enabling the export.","fixCommand":"bq ls --project_id=billing-export-prod billing_export\n","code":"resource \"google_bigquery_dataset\" \"billing_export\" {\n dataset_id = \"billing_export\"\n project = var.billing_project_id\n location = \"EU\"\n}\n","language":"hcl","filename":"fix-export.tf","prevention":"Create the dataset in Terraform and document the one-time Console export enablement in the same runbook; verify first rows within 24h."}
- {"errorCode":"QUERY_SCANNED_ENTIRE_EXPORT","symptoms":"BigQuery on-demand bill spikes; a simple monthly report scans hundreds of GB.","rootCause":"Queries filter on `usage_start_time` or a bare date range without `_PARTITIONTIME`/`_TABLE_SUFFIX` pruning, so BigQuery scans every partition of the export.","fixCommand":"bq query --use_legacy_sql=false --dry_run \"$(cat cost_per_team.sql)\"\n","code":"WHERE _PARTITIONTIME >= TIMESTAMP(DATE_TRUNC(CURRENT_DATE(), MONTH))\n AND cost_type = 'regular'\n","language":"sql","filename":"fix-partition.sql","prevention":"Always partition-prune and set maximum bytes billed on FinOps query jobs; materialize monthly aggregates into small reporting tables."}
- {"errorCode":"BUDGET_LABEL_FILTER_NOT_MATCHING","symptoms":"Per-team budget shows 0% spend despite the team clearly consuming resources.","rootCause":"The budget's label filter uses a value with wrong casing (`Payments` vs `payments`), or usage happened before the label existed on the resource.","fixCommand":"gcloud billing budgets list --billing-account=$BILLING_ACCOUNT_ID --format='table(displayName,budgetFilter.labels)'\n","code":"budget_filter {\n labels = {\n team = [\"payments\"] # must match the label value exactly, lowercase\n }\n}\n","language":"hcl","filename":"fix-budget.tf","prevention":"Define label values as Terraform variables shared between the provider default_labels and the budget module so the two can never drift apart."}
Cost Analysis
| Feature | metric | cost1k | cost10k | cost100k | cost1m |
|---|---|---|---|---|---|
| Billing export itself | $0.00 | $0.00 | $0.00 | $0.00 | |
| BigQuery storage (export data) | $0.00 | $0.00 | $0.02 | $0.20 | |
| BigQuery on-demand scans (partition-pruned) | $0.00 | $0.01 | $0.10 | $1.00 | |
| Budget alerts (per budget per month) | $0.00 | $0.00 | $0.00 | $0.00 |