Home / Security

Per-Team Cost Allocation on GCP: Labels, Folders & Billing Queries | 2026

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.

ConceptExplanationWhen to use
Resource LabelsKey-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 & ProjectsResource 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 ExportAutomatic 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_labelsProvider-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).

FeaturethisServicealtAaltB
GranularityLabel + folder + SKU + resource levelInvoice by project (console reports)Third-party FinOps SaaS
Shared-service allocationSQL-driven split rules (labels, custom mapping tables)Not supportedSupported, vendor-specific rules engine
Data freshnessDaily (detailed export)Monthly invoiceDaily to weekly depending on connector
CostFree export + BigQuery storage/scan ($)FreeTypically 1–3% of managed cloud spend
Ownership of dataYour dataset, full SQL accessConsole only, limited exportsVendor 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

Featuremetriccost1kcost10kcost100kcost1m
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

References

Browse all tutorials