/

/

How to label dbt jobs for BigQuery cost visibility

How to label dbt jobs for BigQuery cost visibility

Guide

How to label dbt jobs for BigQuery cost visibility

The GCP invoice tells you what BigQuery cost, at project level. It does not tell you which dbt model, package, or team ran the query. The missing piece is labels: dbt can stamp them on every job and every table it creates.

·

Costory team

·

5 min read

Originally published 28 October 2024. Refreshed 12 November 2026.

The GCP invoice tells you what BigQuery cost, at project level. It does not tell you which dbt model, package, or team ran the query. The missing piece is labels: dbt can stamp them on every job and every table it creates. You then read INFORMATION_SCHEMA and, if you want, join those labels to the bill.

This is a how-to you run in your own dbt project. Costory can use the labels once they exist. It does not apply them for you, and it does not change your GCP account.

What BigQuery actually charges for

Two lines matter: storage and processing. Current rates live on Google Cloud's price list; they change, so this page does not quote them.

Storage is billed on the data you keep. Data modified in the last 90 days is active storage; data untouched for 90 days moves to long-term storage at a lower rate. You also choose whether to bill logical bytes (the uncompressed size you query) or physical bytes (what BigQuery stores after compression). The right choice depends on how compressible the data is and how often you query it.

Processing is billed under one of two models:

  • On-demand: you pay for the bytes each query processes.

  • Capacity (BigQuery Editions / slots): you reserve (and can autoscale) slot capacity at a fixed rate.

Until jobs and tables carry labels, both lines stay stuck at project or dataset grain. The rest of this guide is how to get to model grain.

Label the jobs dbt runs

A SQL comment in the query text is not enough. BigQuery job labels are a separate key/value map, and they are what INFORMATION_SCHEMA.JOBS_BY_PROJECT and the Job explorer actually filter on.

In dbt_project.yml, set query-comment so the comment is JSON, and turn on job-label:

query-comment:
  comment: "{{ bq_job_labels(node) }}"
  job-label: true
query-comment:
  comment: "{{ bq_job_labels(node) }}"
  job-label: true
query-comment:
  comment: "{{ bq_job_labels(node) }}"
  job-label: true

Then add a macro that returns a JSON object. Keys and values must satisfy BigQuery's label rules: lowercase, no more than 63 characters, and only letters, numbers, underscores, and hyphens. Values must start with a letter or underscore. Do not put file paths, model unique IDs, or mixed-case names in a label; sanitize them first, or BigQuery will drop the label silently.

{% macro bq_job_labels(node) %}
  {%- set labels = {
    'app': 'dbt',
    'target_name': (target.name | lower)[:63],
  } -%}
  {%- if node is not none -%}
    {%- do labels.update({
      'node_name': (node.name | lower | replace('.', '_') | replace(' ', '_'))[:63],
      'resource_type': (node.resource_type | lower),
      'package_name': (node.package_name | lower | replace('.', '_'))[:63],
    }) -%}
    {%- set model_labels = node.config.get('labels', {}) or {} -%}
    {%- for k, v in model_labels.items() -%}
      {%- do labels.update({
        (k | lower)[:63]: (v | string | lower)[:63]
      }) -%}
    {%- endfor -%}
  {%- endif -%}
  {{ return(tojson(labels)) }}
{% endmacro %}
{% macro bq_job_labels(node) %}
  {%- set labels = {
    'app': 'dbt',
    'target_name': (target.name | lower)[:63],
  } -%}
  {%- if node is not none -%}
    {%- do labels.update({
      'node_name': (node.name | lower | replace('.', '_') | replace(' ', '_'))[:63],
      'resource_type': (node.resource_type | lower),
      'package_name': (node.package_name | lower | replace('.', '_'))[:63],
    }) -%}
    {%- set model_labels = node.config.get('labels', {}) or {} -%}
    {%- for k, v in model_labels.items() -%}
      {%- do labels.update({
        (k | lower)[:63]: (v | string | lower)[:63]
      }) -%}
    {%- endfor -%}
  {%- endif -%}
  {{ return(tojson(labels)) }}
{% endmacro %}
{% macro bq_job_labels(node) %}
  {%- set labels = {
    'app': 'dbt',
    'target_name': (target.name | lower)[:63],
  } -%}
  {%- if node is not none -%}
    {%- do labels.update({
      'node_name': (node.name | lower | replace('.', '_') | replace(' ', '_'))[:63],
      'resource_type': (node.resource_type | lower),
      'package_name': (node.package_name | lower | replace('.', '_'))[:63],
    }) -%}
    {%- set model_labels = node.config.get('labels', {}) or {} -%}
    {%- for k, v in model_labels.items() -%}
      {%- do labels.update({
        (k | lower)[:63]: (v | string | lower)[:63]
      }) -%}
    {%- endfor -%}
  {%- endif -%}
  {{ return(tojson(labels)) }}
{% endmacro %}

This follows the current dbt BigQuery pattern: query-comment.job-label: true plus a macro that returns JSON. Official reference: query-comment and BigQuery configurations.

Once this is in place, every dbt run, test, and snapshot should show labels on the job in the BigQuery console. If they do not appear, check the charset and length first; that is the usual failure.

Read job cost by model

Replace PROJECT_ID and the region with yours. The query only reads metadata.

SELECT
  (SELECT value FROM UNNEST(labels) WHERE key = 'node_name') AS model,
  (SELECT value FROM UNNEST(labels) WHERE key = 'package_name') AS package,
  (SELECT value FROM UNNEST(labels) WHERE key = 'target_name') AS target,
  COUNT(*) AS jobs,
  SUM(total_slot_ms) AS slot_ms,
  SUM(total_bytes_billed) AS bytes_billed
FROM `PROJECT_ID`.`region-eu`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
  AND (SELECT value FROM UNNEST(labels) WHERE key = 'app') = 'dbt'
GROUP BY 1, 2, 3
ORDER BY bytes_billed DESC
SELECT
  (SELECT value FROM UNNEST(labels) WHERE key = 'node_name') AS model,
  (SELECT value FROM UNNEST(labels) WHERE key = 'package_name') AS package,
  (SELECT value FROM UNNEST(labels) WHERE key = 'target_name') AS target,
  COUNT(*) AS jobs,
  SUM(total_slot_ms) AS slot_ms,
  SUM(total_bytes_billed) AS bytes_billed
FROM `PROJECT_ID`.`region-eu`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
  AND (SELECT value FROM UNNEST(labels) WHERE key = 'app') = 'dbt'
GROUP BY 1, 2, 3
ORDER BY bytes_billed DESC
SELECT
  (SELECT value FROM UNNEST(labels) WHERE key = 'node_name') AS model,
  (SELECT value FROM UNNEST(labels) WHERE key = 'package_name') AS package,
  (SELECT value FROM UNNEST(labels) WHERE key = 'target_name') AS target,
  COUNT(*) AS jobs,
  SUM(total_slot_ms) AS slot_ms,
  SUM(total_bytes_billed) AS bytes_billed
FROM `PROJECT_ID`.`region-eu`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
  AND (SELECT value FROM UNNEST(labels) WHERE key = 'app') = 'dbt'
GROUP BY 1, 2, 3
ORDER BY bytes_billed DESC

On capacity pricing, total_slot_ms is the useful column (which models hold slots, including during autoscale). On on-demand pricing, total_bytes_billed is the useful one. Unlabeled jobs will land in a NULL model row; that is the gap to close, not a query bug.

The same labels also make the Job explorer usable: filter on app=dbt and a model name when a run looks expensive.

For four read-only INFORMATION_SCHEMA queries that find repeated queries, logical vs physical storage, on-demand vs slots, and forgotten tables, see BigQuery cost audit. That article does not replace this one: it does not cover dbt labels.

Label the tables dbt creates

Job labels explain processing. Storage is on the table. In a model, set labels in config():

{{
  config(
    materialized='table',
    labels={
      'maintained_by': 'analytics',
      'package_name': 'core'
    }
  )
}}
{{
  config(
    materialized='table',
    labels={
      'maintained_by': 'analytics',
      'package_name': 'core'
    }
  )
}}
{{
  config(
    materialized='table',
    labels={
      'maintained_by': 'analytics',
      'package_name': 'core'
    }
  )
}}

You can also set default labels in dbt_project.yml. A label key set on the model wins over the same key in dbt_project.yml. dbt does not merge the two maps. (The original 2024 version of this article had that the wrong way around.)

Table labels show up in INFORMATION_SCHEMA.TABLE_OPTIONS. One way to unpack them:

SELECT
  table_schema,
  table_name,
  option_value AS labels_raw
FROM `PROJECT_ID`.`region-eu`.INFORMATION_SCHEMA.TABLE_OPTIONS
WHERE option_name = 'labels'
SELECT
  table_schema,
  table_name,
  option_value AS labels_raw
FROM `PROJECT_ID`.`region-eu`.INFORMATION_SCHEMA.TABLE_OPTIONS
WHERE option_name = 'labels'
SELECT
  table_schema,
  table_name,
  option_value AS labels_raw
FROM `PROJECT_ID`.`region-eu`.INFORMATION_SCHEMA.TABLE_OPTIONS
WHERE option_name = 'labels'

option_value is a SQL-literal array of structs. Parse it in SQL or downstream, then join to INFORMATION_SCHEMA.TABLE_STORAGE if you want current physical and logical bytes per labeled table. Storage in the invoice is still often at dataset grain; table labels are how you allocate that line yourself.

What to do with the labels

Keep the labeling in git, next to the models. That is the source of truth. Once jobs and tables are labeled, you can:

  • build a scheduled query or a dashboard on the two INFORMATION_SCHEMA views above

  • join the same keys to your BigQuery billing export

  • hand the bill, plus those labels, to a tool that already reads both

Costory is one such tool. It recommends how to allocate the labeled cost; it does not edit your dbt project or your cloud resources. If you want that path after the labels exist, see the BigQuery cost by dbt model skill and the BigQuery integration.

Takeaway

  • Stamp dbt metadata onto BigQuery jobs with query-comment and job-label: true.

  • Respect BigQuery's label rules, or the labels never appear.

  • Query INFORMATION_SCHEMA.JOBS_BY_PROJECT for processing cost by model.

  • Set labels on models for storage; the model config overrides dbt_project.yml.

  • You implement the labels. Anything that reads them, including Costory, comes after.

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

PRICE

From 250€/month, never a % of your bill

Setup

Under 30 minutes

Data residency

EU

Access

Read-only on your cloud

From 250€/month, never a % of your bill. 30 days free, no credit card.