The BigQuery billing export is the only place on Google Cloud where you can see model spend at SKU granularity. The schema is straightforward and there are exactly two traps in it, both of which produce a query that runs, returns numbers, and is wrong.
The right table
Cloud Billing writes more than one export, and they are not interchangeable. The detailed usage cost export is named gcp_billing_export_resource_v1_<BILLING_ACCOUNT_ID> with the hyphens in the account id replaced by underscores; the standard export drops the resource segment and, with it, the resource-level rows. Google documents the full schema for both. The detailed export schema is here.
The columns you will use are service.id and service.description, sku.id and sku.description, usage_start_time and usage_end_time, cost, currency, the usage struct with amount, unit, amount_in_pricing_units and pricing_unit, the repeated credits field, project, labels, cost_at_list, invoice.month, and — only in the detailed export — resource with name and global_name.
If per-endpoint attribution matters, you need the detailed export. Turn it on before you need it: exports are not retroactive, so the first data arrives after you enable it and nothing earlier is recoverable.
Filtering to Vertex AI
Filter on service.description. Filtering on sku.description with a LIKE '%Vertex%' pattern is the common shortcut and it under-counts, because plenty of Vertex SKU descriptions do not carry the product name.
SELECT
sku.description AS sku,
SUM(cost) AS cost,
SUM(usage.amount_in_pricing_units) AS usage_units,
ANY_VALUE(usage.pricing_unit) AS unit,
currency
FROM `PROJECT.DATASET.gcp_billing_export_resource_v1_ACCOUNT_ID`
WHERE service.description = 'Vertex AI'
AND usage_start_time >= TIMESTAMP('2026-07-01')
AND usage_start_time < TIMESTAMP('2026-08-01')
GROUP BY sku, currency
ORDER BY cost DESC
Run that once and read the SKU list before writing anything on top of it. The descriptions carry the model name and, for generative models, distinguish input from output tokens or characters — but the exact wording is a Google-side string that changes when products are renamed or repackaged. Never hard-code a SKU description in a dashboard; group by it and let new SKUs appear as new rows rather than silently falling outside a filter.
Credits are a nested array
Here is the first trap, and it is the one that produces a number too large. The cost column is gross. Committed use discounts, promotional credits, free-tier grants and sustained use discounts all live in credits, which is a repeated field of structs. A query that sums cost alone reports what you would have paid without any of them.
SELECT
sku.description AS sku,
SUM(cost) AS gross_cost,
SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS credits,
SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0))
AS net_cost
FROM `PROJECT.DATASET.gcp_billing_export_resource_v1_ACCOUNT_ID`
WHERE service.description = 'Vertex AI'
AND usage_start_time >= TIMESTAMP('2026-07-01')
AND usage_start_time < TIMESTAMP('2026-08-01')
GROUP BY sku
ORDER BY net_cost DESC
Note the sign: credit amounts are negative, so net cost is the sum, not the difference. Using a correlated subquery over UNNEST(credits) rather than a join keeps the row count intact — a naive CROSS JOIN UNNEST(credits) duplicates each usage row once per credit and multiplies the cost figure, which is the second way this query goes wrong.
cost_at_list is a separate concept and worth knowing about: it is the cost before negotiated contract pricing, so the gap between it and cost is the value of your agreement, and the gap between cost and net is the value of credits. Three numbers, three different meanings.
Getting to per-model
The SKU description is the per-model dimension for generative model usage, because Google prices each model’s input and output separately. That gets you cost per model across the whole billing account, which is usually the question.
Cost per model per team needs a second dimension, and there are two candidates. Labels applied to the resource propagate into the labels field, which works well for endpoints and training jobs and not at all for a stateless generative API call — the same structural problem described in the AWS tagging page. The other is project.id: giving each team its own project is blunt, and it is the attribution mechanism Google Cloud is actually designed around.
SELECT
project.id AS project,
sku.description AS sku,
SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0))
AS net_cost
FROM `PROJECT.DATASET.gcp_billing_export_resource_v1_ACCOUNT_ID`
WHERE service.description = 'Vertex AI'
AND DATE(_PARTITIONTIME) BETWEEN '2026-07-01' AND '2026-08-01'
GROUP BY project, sku
HAVING net_cost > 0
ORDER BY net_cost DESC
Model spend vs the infrastructure around it
The reason to go to SKU granularity rather than stopping at the service total is that service.description = 'Vertex AI' is a bucket holding several unrelated businesses. Generative model calls billed per million tokens or characters, batch prediction, custom training node-hours, the node-hours of an endpoint you deployed a fine-tuned model to, pipeline runs and feature store storage all land under the same service name. A single monthly Vertex figure therefore mixes a variable cost that tracks traffic with a fixed cost that tracks nothing at all, and the two respond to completely different levers — prompt engineering moves one, deleting an endpoint nobody calls moves the other. Only the SKU column separates them, and it is the lowest granularity Google exposes: there is no finer dimension to fall back to.
The usage.pricing_unit column is the quickest way to see the split, because it is different for each kind of line. Token SKUs price in millions of tokens or characters; served models price in node-hours or GPU-hours; storage prices in gibibyte-months. That also means summing usage.amount_in_pricing_units across SKUs is meaningless — hours and tokens do not add. Cost is the only column that aggregates across the whole service, which is exactly why a per-SKU breakdown with the unit carried alongside it is the useful shape.
The inverse mistake is treating the Vertex total as the cost of the model workload. It is not. The training data and the model artifacts sit in Cloud Storage, request and response logging goes to Cloud Logging, anything crossing a region boundary bills as network egress, and the billing export itself accrues BigQuery storage and query charges. None of those carry the Vertex AI service name. If the question is what an AI product costs rather than what Vertex costs, run the same query grouped by service.description over the projects that host the workload and read the whole list — the supporting services are frequently a larger share than the first look suggests, and they are invisible to any filter pinned to one service.
How late the data is
The export is a reporting mechanism, not a monitoring one, and the gap matters more than people expect. Google states plainly that there are no delivery or latency guarantees for the export to BigQuery, that individual services report usage to Cloud Billing at intervals that vary by service, and that after you first enable the export it can be a few hours before data begins arriving at all. If you enable retroactive backfill for the current and previous month, Google documents that it may take up to five days for the exported data to catch up to current usage. The export setup documentation covers the latency caveats.
Two practical consequences. First, a query over the last twenty-four hours is systematically low, and how low depends on which services the workload touched — so a dashboard that shows a sharp drop at the right-hand edge is showing you the reporting lag, not a fall in spending. Either exclude the trailing couple of days from trend lines or label them as provisional. Second, the export cannot catch a runaway job. By the time a misconfigured batch prediction appears in BigQuery it has been running for hours. That job is caught by a budget alert or by a ceiling enforced at the call site, not here; this table is where you find out what it cost afterwards and which SKU it hit.
Gotchas worth knowing first
- Bound the scan. The export accumulates every line for every service indefinitely, and an unfiltered scan of a large account is a meaningful BigQuery bill in its own right — a cost-analysis query that costs money is an unusually annoying way to learn about partitioning. Filter on the partition column as well as on
usage_start_time. - Rows are appended, and can be restated. The export is not a snapshot; corrections and late-arriving usage appear after the fact, and a row’s
export_timediffers from itsusage_start_time. A month is not final on the first of the next one. - Use
invoice.monthto reconcile. If the goal is to match the invoice rather than to analyse usage, group byinvoice.monthrather than by usage timestamp. The two do not agree at month boundaries, and that disagreement is the source of most “my query does not match the bill” reports. - Currency. Group by
currencyor filter to one. Summing across currencies produces a number with no meaning, and the column is there precisely because it happens.
Top comments (0)