Quickstart
Add DiffCost to a repository with plain SQL files and get a per-run cost report on your next pull request. No DiffCost account needed.
You need:
- a Google Cloud project where DiffCost can run dry-run jobs, with the BigQuery and IAM APIs enabled;
- a service account that can dry-run the queries in your pull requests (PRs);
- a workflow step that runs DiffCost on
pull_request.
To run the commands below you need, in the project: roles/serviceusage.serviceUsageAdmin (enable APIs), roles/iam.serviceAccountAdmin, roles/resourcemanager.projectIamAdmin, roles/bigquery.jobUser (to run the GRANT statements) and, for the key option, roles/iam.serviceAccountKeyAdmin; plus roles/bigquery.dataOwner on each dataset you grant access to. A project Owner has all of these. BigQuery access and keyless auth lists the roles and the APIs to enable.
Without BigQuery credentials DiffCost still runs, but it posts a structural-only report: risk flags, no dollar amounts (see Without BigQuery credentials). If your repository is a dbt project, do steps 1 and 2 here, then follow dbt projects for the workflow.
What DiffCost can price#
DiffCost dry-runs each changed .sql file exactly as written, as GoogleSQL (not legacy SQL), in the gcp_project project, with no default dataset. A file can be priced when:
- it runs as written;
- every table name is qualified:
dataset.tableorproject.dataset.table; - it has no templating (such as Jinja
{{ }}) and no query parameters (such as@run_date).
Other files fail the dry run and show as an unknown cost, which blocks the PR when a threshold applies. List them in ignored_paths in .diffcost.yml (see Configuration reference). dbt models are different: DiffCost prices their compiled SQL (see dbt projects).
1. Create a service account for dry runs#
BigQuery checks the same permissions for a dry run as for a real query. The service account therefore needs:
bigquery.jobs.createon the project that runs the jobs (it has no effect on a dataset);bigquery.tables.getandbigquery.tables.getDataon every table and view the queries read.
A dry run returns a byte estimate and no rows, and DiffCost only submits dry runs. getData still allows reading table data, so grant it only on the datasets your SQL uses.
PROJECT_ID="my-project"
SA="diffcost-ci@${PROJECT_ID}.iam.gserviceaccount.com"
# 0. Enable the APIs (once per project)
gcloud services enable iam.googleapis.com bigquery.googleapis.com --project="${PROJECT_ID}"
# 1. Create the service account
gcloud iam service-accounts create diffcost-ci \
--project="${PROJECT_ID}" --display-name="DiffCost CI dry runs"
# 2. Let it run (dry-run) jobs in the project
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
--member="serviceAccount:${SA}" \
--role="roles/bigquery.jobUser"
# 3. Let it read each dataset your SQL references (repeat per dataset)
bq query --nouse_legacy_sql --project_id="${PROJECT_ID}" \
"GRANT \`roles/bigquery.dataViewer\` ON SCHEMA \`${PROJECT_ID}.analytics\` TO 'serviceAccount:${SA}'"
If you'd rather not grant per dataset, grant roles/bigquery.dataViewer on the whole project with gcloud projects add-iam-policy-binding instead of step 3. Don't put bigquery.jobs.create in a role you grant on a dataset: it has no effect there, and every dry run fails with "Access Denied".
If your SQL reads tables in other projects (common for dbt sources), grant roles/bigquery.dataViewer on those datasets too. Otherwise those queries show "Access Denied" instead of a cost.
2. Choose how the workflow authenticates#
-
Keyless (recommended): Workload Identity Federation. Follow BigQuery access and keyless auth. It reuses the
diffcost-ciservice account from step 1. -
Service account key: create a key, then store the whole JSON file contents as a repository secret named
GCP_SA_KEY(in the repository's GitHub settings: Settings → Secrets and variables → Actions):gcloud iam service-accounts keys create key.json --iam-account="${SA}" # paste the contents of key.json into the GCP_SA_KEY secret, then delete key.json
3. Add DiffCost to a workflow#
# .github/workflows/diffcost.yml
name: DiffCost
on: pull_request # every PR, no `paths:` filter (a required check needs a status on every PR)
permissions:
contents: read # git diff against the base branch
pull-requests: write # post and update the PR comment
id-token: write # only for Workload Identity Federation
jobs:
diffcost:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required (SQL files and dbt): a shallow clone has no base history
# Option A: keyless (Workload Identity Federation)
- uses: google-github-actions/auth@v2
if: github.event.pull_request.head.repo.full_name == github.repository # fork PRs get no OIDC token
with:
workload_identity_provider: 'projects/123456789012/locations/global/workloadIdentityPools/github-pool/providers/github-provider'
service_account: 'diffcost-ci@my-project.iam.gserviceaccount.com'
- uses: diffcost/action@v1
with:
sql_paths: "queries/**/*.sql"
gcp_project: "my-project"
# Option B: service account key instead of the auth step above
# gcp_credentials: ${{ secrets.GCP_SA_KEY }}
DiffCost posts its comment with the workflow's own token (the github_token input defaults to ${{ github.token }}), so you don't pass a token yourself. The job needs pull-requests: write.
Set sql_paths to a glob that matches your SQL files, or several globs separated by commas ("queries/**/*.sql, reports/*.sql"). In these globs * also matches /, so reports/*.sql matches nested files too. Without it, DiffCost analyzes the dbt project if the base branch has one, and otherwise every changed .sql file (**/*.sql).
4. Open a pull request#
Change a .sql file that matches sql_paths and open a PR. When the workflow finishes, DiffCost posts a comment on the PR with each changed query's cost per run before and after the change. When you push more commits, DiffCost updates the same comment instead of adding a new one. The report is also written to the workflow run's job summary. Reading the PR comment explains every part of it.
A PR that changes no SQL gets no comment, with two exceptions:
- if an earlier push changed SQL, DiffCost replaces its old report with "No SQL changes in the latest push";
- with an API key, DiffCost comments when no
diffcost/cost-checkstatus could be posted or confirmed, to say why.
Set comment_on_clean: true to get a comment on every PR.
If DiffCost can't run, it posts "DiffCost could not analyze this PR" with the reason and what to fix, instead of reporting no changes. The step still passes unless you set fail_on_error: true. See Troubleshooting.
Workflow rules#
- Run DiffCost once per PR. Running it more than once on the same PR (two steps, two jobs or two workflows, for example one per dbt project) isn't supported: the runs share one PR comment and one
diffcost/cost-checkstatus, so a later run overwrites an earlier run's verdict. - PRs from forks get no secrets, no OpenID Connect (OIDC) token and a read-only token. DiffCost still runs on them and posts a structural-only report (for plain SQL; in a dbt repository a fork PR has no compiled manifest, so it reports "could not analyze", see dbt projects). It can't comment on a fork PR, so read the report in the job summary of the workflow run. Keep the
if:on thegoogle-github-actions/authstep: without it, the auth step fails the job on a fork PR before DiffCost runs. - Merge queue? Add
merge_grouptoon:and change the auth step'sif:, as in Merge queues. - Use
on: pull_request. Apull_request_targetworkflow checks out the base branch by default, and DiffCost refuses that checkout instead of reporting a clean PR. If you must usepull_request_target, check out${{ github.event.pull_request.head.sha }}, and only for trusted PRs: it runs the PR's code with your secrets.
Next steps#
- Switch to keyless authentication if you used a service account key.
- Set up dbt if the repository is a dbt project.
- Turn on merge blocking: sign in to the dashboard, create an API key, install the GitHub App and make
diffcost/cost-checka required check. - Add a
.diffcost.ymlto set thresholds, ignore paths or change the runs-per-month projection.
Something missing or wrong? Email support@diffcost.dev.