BigHammer.ai Cost Field Guide
Field guide|Databricks system tables|Free

The Databricks Cost Field Guide

13 copy-paste SQL queries against Databricks system tables that show where the money goes, which jobs and owners drive it, and what is waste. Run them before your renewal and walk in with evidence, not estimates.

Read-only queries No install, no signup Every column checked against Databricks docs
13

Copy-paste, read-only queries

9

Databricks system tables, nothing to install

6

Chapters, from see it to prove it

1

Printable Renewal Evidence Sheet

Before you start

How to use this guide

Run these queries in a Databricks SQL editor or notebook with read access to system tables. Every query is read-only: SELECT statements only, nothing is written, changed or deleted. A small SQL warehouse is enough.

1. Get accessA metastore admin grants USE and SELECT on the system.billing, system.lakeflow and system.compute schemas.
2. Run in orderEach chapter builds on the last. Chapter 1 gives the baseline every later number is measured against.
3. Write it downDrop each answer into the Renewal Evidence Sheet at the end and print it to PDF.
Tables used (links go to the official Databricks reference)
MeasuredRead from system tables, with spend priced at list price, before contract discounts: baseline, cost by product, job cost, failed-run cost, traced %, CPU, tags, cluster settings.
EstimatedSavings opportunities derived with a rule, such as moving all-purpose work to jobs compute, right-sizing, or the upper-bound attribution in Q11.
GapSpend you cannot trace to an owner or job. A visibility gap to close, never a saving to claim.

System tables keep a rolling history (most of the tables used here retain around a year; node_timeline keeps 90 days). If a table was enabled recently, your 90-day window is shorter than it looks: check MIN(usage_date) first. All timestamps are UTC.

Chapter 1

See it

You cannot cut what you have not sized. Two queries give you the 90-day baseline: which products and SKUs the money goes to, and which jobs carry the most of it.

Q01

90-day list cost by billing product and SKU

system.billing.usagesystem.billing.list_pricesMeasuredat list price, before contract discounts

This is your baseline. Every later finding is a share of this number. Usage is joined to the list price that was valid at the moment the usage happened, so price changes inside the window are handled correctly.

-- Q01  90-day list cost by billing product and SKU  (read-only)
-- List price, before contract discounts. Uses pricing.effective_list.default,
-- falling back to pricing.default if effective_list is empty.
WITH priced AS (
  SELECT
    u.billing_origin_product,
    u.sku_name,
    u.usage_unit,
    p.currency_code,
    u.usage_quantity,
    u.usage_quantity
      * COALESCE(p.pricing.effective_list.default, p.pricing.default) AS list_cost
  FROM system.billing.usage u
  JOIN system.billing.list_prices p
    ON  u.sku_name   = p.sku_name
    AND u.cloud      = p.cloud
    AND u.usage_unit = p.usage_unit
    AND u.usage_start_time >= p.price_start_time
    AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
  WHERE u.usage_date >= current_date() - INTERVAL 90 DAYS
)
SELECT
  billing_origin_product,
  sku_name,
  usage_unit,
  currency_code,
  ROUND(SUM(usage_quantity), 2)                                AS usage_quantity_90d,
  ROUND(SUM(list_cost), 2)                                     AS list_cost_90d,
  ROUND(100 * SUM(list_cost) / SUM(SUM(list_cost)) OVER (), 1) AS pct_of_total
FROM priced
GROUP BY ALL
HAVING SUM(usage_quantity) <> 0
ORDER BY list_cost_90d DESC;

How to read the result

  • One row per product and SKU. pct_of_total tells you where to look first.
  • Sum list_cost_90d for your 90-day baseline. Multiply by 365 / 90 for an annualised run-rate.
  • Corrections (RETRACTION and RESTATEMENT rows) net out because the query sums quantities.

What good looks like

  • Scheduled production work shows up mostly under JOBS SKUs, not ALL_PURPOSE.
  • No single SKU you cannot explain in one sentence.
  • The total is within a few percent of your invoice once your discount is applied.
Benchmark

If ALL_PURPOSE SKUs are a large share of spend and most of your workload is scheduled, chapter 4 (Q11) is likely your biggest lever. All-purpose compute carries a higher DBU list rate than jobs compute; check both SKUs in list_prices for your cloud.

Q02

Top 10 jobs by cost over 90 days

system.billing.usagesystem.billing.list_pricessystem.lakeflow.jobsMeasuredat list price, before contract discounts

Spend concentrates. In most estates a handful of jobs carry a large share of job cost, and each one has a person who can change it. system.lakeflow.jobs keeps full history (SCD2), so the query takes the latest row per job for its current name and owner.

-- Q02  Top 10 jobs by list cost, last 90 days  (read-only)
WITH job_cost AS (
  SELECT
    u.workspace_id,
    u.usage_metadata.job_id                     AS job_id,
    COUNT(DISTINCT u.usage_metadata.job_run_id) AS runs,
    SUM(u.usage_quantity
        * COALESCE(p.pricing.effective_list.default, p.pricing.default)) AS list_cost
  FROM system.billing.usage u
  JOIN system.billing.list_prices p
    ON  u.sku_name   = p.sku_name
    AND u.cloud      = p.cloud
    AND u.usage_unit = p.usage_unit
    AND u.usage_start_time >= p.price_start_time
    AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
  WHERE u.usage_date >= current_date() - INTERVAL 90 DAYS
    AND u.usage_metadata.job_id IS NOT NULL
  GROUP BY ALL
),
latest_jobs AS (           -- jobs is SCD2: keep the most recent row per job
  SELECT workspace_id, job_id, name, run_as_user_name, creator_user_name, tags, delete_time
  FROM system.lakeflow.jobs
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, job_id ORDER BY change_time DESC) = 1
)
SELECT
  c.workspace_id,
  c.job_id,
  j.name                                    AS job_name,
  j.run_as_user_name,
  j.creator_user_name,
  j.tags['owner']                           AS owner_tag,
  j.delete_time IS NOT NULL                 AS job_deleted,
  c.runs,
  ROUND(c.list_cost, 2)                     AS list_cost_90d,
  ROUND(c.list_cost / NULLIF(c.runs, 0), 2) AS list_cost_per_run
FROM job_cost c
LEFT JOIN latest_jobs j USING (workspace_id, job_id)
ORDER BY c.list_cost DESC
LIMIT 10;

How to read the result

  • Divide the top 10 total by your Q01 baseline: that share is how concentrated your job spend is.
  • list_cost_per_run separates "runs too often" from "each run is too heavy".
  • A blank job_name usually means a one-time submit run with no job definition.

What good looks like

  • Every one of the 10 has a named owner (tag or run-as) who knows why it costs what it costs.
  • No deleted jobs still on the list.
  • Per-run cost is stable week to week, not creeping up.

Only jobs on jobs compute and serverless carry a job_id in billing. Jobs that run on all-purpose clusters are billed to the cluster, not the job, so they are invisible here. Q11 finds them.

Chapter 2

Trace it

A number nobody owns is a number nobody will cut. This chapter measures how much spend you can tie to a job run, which clusters have no owner or cost-centre, and whether any usage is missing a price. Untraced spend is a visibility gap, not a saving.

Q03

Share of spend traceable to a job run

system.billing.usagesystem.billing.list_pricesMeasuredGap

Billing rows with a job_run_id can be traced to one run of one job, with a run-as identity and a schedule. Everything else needs tags or detective work. This query gives the traced share in money and percent, overall and per product.

-- Q03  Share of 90-day list cost traceable to a job run  (read-only)
WITH priced AS (
  SELECT
    u.billing_origin_product,
    u.usage_metadata.job_run_id AS job_run_id,
    u.usage_quantity
      * COALESCE(p.pricing.effective_list.default, p.pricing.default) AS list_cost
  FROM system.billing.usage u
  JOIN system.billing.list_prices p
    ON  u.sku_name   = p.sku_name
    AND u.cloud      = p.cloud
    AND u.usage_unit = p.usage_unit
    AND u.usage_start_time >= p.price_start_time
    AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
  WHERE u.usage_date >= current_date() - INTERVAL 90 DAYS
)
SELECT
  COALESCE(billing_origin_product, 'ALL PRODUCTS (TOTAL)') AS billing_origin_product,
  GROUPING(billing_origin_product)                         AS is_total,
  ROUND(SUM(list_cost), 2)                                 AS list_cost_90d,
  ROUND(SUM(CASE WHEN job_run_id IS NOT NULL THEN list_cost ELSE 0 END), 2) AS traced_to_job_run,
  ROUND(SUM(CASE WHEN job_run_id IS NULL     THEN list_cost ELSE 0 END), 2) AS not_traced,
  ROUND(100 * SUM(CASE WHEN job_run_id IS NOT NULL THEN list_cost ELSE 0 END)
            / NULLIF(SUM(list_cost), 0), 1)                AS traced_pct
FROM priced
GROUP BY ROLLUP (billing_origin_product)
ORDER BY is_total DESC, list_cost_90d DESC;

How to read the result

  • The first row is the total: traced_pct is your headline traceability number.
  • SQL warehouses, interactive notebooks and model serving never carry a job run id. That spend is not "bad", it simply needs tags or warehouse owners to trace.
  • Large not_traced under ALL_PURPOSE often hides scheduled work (see Q11).

What good looks like

  • For JOBS, close to 100% traced.
  • For everything else, the untraced part is covered by owner and cost-centre tags (Q04).
  • The traced share goes up quarter on quarter.
Rule

Report the untraced figure as "spend we cannot attribute yet". Do not count it as savings, and do not assume it is waste. It becomes a finding only once it is traced.

Q04

Clusters missing owner, cost-centre or environment tags

system.compute.clusterssystem.billing.usagesystem.billing.list_pricesMeasured

system.compute.clusters is SCD2, so the query takes the latest row per cluster, then checks its tags map. Tag keys are normalised (case, dashes and underscores ignored), so Cost-Centre, cost_center and CostCentre all count. Edit the key lists to match your tagging policy.

-- Q04  Live clusters missing owner / cost-centre / environment tags  (read-only)
WITH latest_clusters AS (  -- clusters is SCD2: keep the most recent row per cluster
  SELECT *
  FROM system.compute.clusters
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, cluster_id ORDER BY change_time DESC) = 1
),
cluster_cost AS (
  SELECT
    u.workspace_id,
    u.usage_metadata.cluster_id AS cluster_id,
    SUM(u.usage_quantity
        * COALESCE(p.pricing.effective_list.default, p.pricing.default)) AS list_cost
  FROM system.billing.usage u
  JOIN system.billing.list_prices p
    ON  u.sku_name   = p.sku_name
    AND u.cloud      = p.cloud
    AND u.usage_unit = p.usage_unit
    AND u.usage_start_time >= p.price_start_time
    AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
  WHERE u.usage_date >= current_date() - INTERVAL 90 DAYS
    AND u.usage_metadata.cluster_id IS NOT NULL
  GROUP BY ALL
),
tag_check AS (
  SELECT
    c.workspace_id, c.cluster_id, c.cluster_name, c.owned_by, c.cluster_source,
    transform(COALESCE(map_keys(c.tags), array()),
              k -> lower(regexp_replace(k, '[^A-Za-z]', ''))) AS keys_norm
  FROM latest_clusters c
  WHERE c.delete_time IS NULL
    AND c.cluster_source IN ('UI', 'API')   -- remove to include job clusters
)
SELECT
  t.workspace_id,
  t.cluster_id,
  t.cluster_name,
  t.owned_by,
  t.cluster_source,
  NOT array_contains(t.keys_norm, 'owner')                            AS missing_owner,
  NOT arrays_overlap(t.keys_norm, array('costcentre', 'costcenter'))  AS missing_cost_centre,
  NOT arrays_overlap(t.keys_norm, array('environment', 'env'))        AS missing_environment,
  ROUND(COALESCE(cc.list_cost, 0), 2)                                 AS list_cost_90d
FROM tag_check t
LEFT JOIN cluster_cost cc USING (workspace_id, cluster_id)
WHERE NOT array_contains(t.keys_norm, 'owner')
   OR NOT arrays_overlap(t.keys_norm, array('costcentre', 'costcenter'))
   OR NOT arrays_overlap(t.keys_norm, array('environment', 'env'))
ORDER BY list_cost_90d DESC;

How to read the result

  • Sorted by 90-day cost, so the top rows are the untagged spend that matters.
  • owned_by is the creator. It tells you who to ask, even when the owner tag is missing.
  • For job clusters and serverless, check custom_tags on system.billing.usage instead: job tags flow through there.

What good looks like

  • Zero rows, enforced by a cluster policy that requires the tags.
  • If not zero, the untagged clusters are a small share of the Q01 baseline and each has a named follow-up.
Q05

Usage rows with no matching list price

system.billing.usagesystem.billing.list_pricesMeasured

A quiet check that protects every other number. If usage has no price row for its SKU, cloud, unit and time, the joins in Q01 to Q04 drop it and your baseline is understated. This query finds what would fall through.

-- Q05  Usage with no matching list price, last 90 days  (read-only)
SELECT
  u.billing_origin_product,
  u.sku_name,
  u.cloud,
  u.usage_unit,
  COUNT(*)                        AS usage_rows,
  ROUND(SUM(u.usage_quantity), 2) AS usage_quantity_90d,
  MIN(u.usage_date)               AS first_seen,
  MAX(u.usage_date)               AS last_seen
FROM system.billing.usage u
LEFT JOIN system.billing.list_prices p
  ON  u.sku_name   = p.sku_name
  AND u.cloud      = p.cloud
  AND u.usage_unit = p.usage_unit
  AND u.usage_start_time >= p.price_start_time
  AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
WHERE u.usage_date >= current_date() - INTERVAL 90 DAYS
  AND p.sku_name IS NULL
GROUP BY ALL
ORDER BY usage_rows DESC;

How to read the result

  • No rows: every unit of usage was priced. Your baseline is complete.
  • Rows with tiny quantities: usually new or preview SKUs. Note them and move on.
  • Rows with large quantities: your baseline is missing real spend. Fix this before quoting any number.

What good looks like

Zero rows, or only rows whose quantity is immaterial against the Q01 baseline.

Chapter 3

Measure it

Failed runs bill exactly like successful ones. So do repairs, and so does a job that hangs for six hours because nobody set a timeout. These three queries put a list-price figure on the reliability tax.

Q06

Cost of failed, errored and timed-out runs

system.billing.usagesystem.billing.list_pricessystem.lakeflow.job_run_timelinesystem.lakeflow.jobsMeasuredat list price, before contract discounts

Run cost is summed from billing by job_run_id, then labelled with the run's final result_state from job_run_timeline (values: SUCCEEDED, FAILED, SKIPPED, CANCELLED, TIMED_OUT, ERROR, BLOCKED). Part A is the summary; part B ranks the jobs.

-- Q06 part A  Job run cost by final outcome, last 90 days  (read-only)
WITH run_cost AS (
  SELECT
    u.workspace_id,
    u.usage_metadata.job_id     AS job_id,
    u.usage_metadata.job_run_id AS run_id,
    SUM(u.usage_quantity
        * COALESCE(p.pricing.effective_list.default, p.pricing.default)) AS list_cost
  FROM system.billing.usage u
  JOIN system.billing.list_prices p
    ON  u.sku_name   = p.sku_name
    AND u.cloud      = p.cloud
    AND u.usage_unit = p.usage_unit
    AND u.usage_start_time >= p.price_start_time
    AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
  WHERE u.usage_date >= current_date() - INTERVAL 90 DAYS
    AND u.usage_metadata.job_run_id IS NOT NULL
  GROUP BY ALL
),
run_outcome AS (           -- result_state is only set on the row that ends a run
  SELECT workspace_id, job_id, run_id, result_state, termination_code
  FROM system.lakeflow.job_run_timeline
  WHERE result_state IS NOT NULL
    AND period_end_time >= current_date() - INTERVAL 97 DAYS
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, job_id, run_id
                             ORDER BY period_end_time DESC) = 1
)
SELECT
  o.result_state,
  COUNT(*)                                                       AS runs,
  ROUND(SUM(c.list_cost), 2)                                     AS list_cost_90d,
  ROUND(100 * SUM(c.list_cost) / SUM(SUM(c.list_cost)) OVER (), 1) AS pct_of_job_run_cost
FROM run_cost c
JOIN run_outcome o USING (workspace_id, job_id, run_id)
GROUP BY ALL
ORDER BY list_cost_90d DESC;
-- Q06 part B  Jobs ranked by failed / errored / timed-out run cost  (read-only)
WITH run_cost AS (
  SELECT
    u.workspace_id,
    u.usage_metadata.job_id     AS job_id,
    u.usage_metadata.job_run_id AS run_id,
    SUM(u.usage_quantity
        * COALESCE(p.pricing.effective_list.default, p.pricing.default)) AS list_cost
  FROM system.billing.usage u
  JOIN system.billing.list_prices p
    ON  u.sku_name   = p.sku_name
    AND u.cloud      = p.cloud
    AND u.usage_unit = p.usage_unit
    AND u.usage_start_time >= p.price_start_time
    AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
  WHERE u.usage_date >= current_date() - INTERVAL 90 DAYS
    AND u.usage_metadata.job_run_id IS NOT NULL
  GROUP BY ALL
),
run_outcome AS (
  SELECT workspace_id, job_id, run_id, result_state, termination_code
  FROM system.lakeflow.job_run_timeline
  WHERE result_state IS NOT NULL
    AND period_end_time >= current_date() - INTERVAL 97 DAYS
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, job_id, run_id
                             ORDER BY period_end_time DESC) = 1
),
latest_jobs AS (
  SELECT workspace_id, job_id, name, run_as_user_name
  FROM system.lakeflow.jobs
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, job_id ORDER BY change_time DESC) = 1
)
SELECT
  c.workspace_id,
  c.job_id,
  j.name                                         AS job_name,
  j.run_as_user_name,
  COUNT(*)                                       AS failed_runs,
  ROUND(SUM(c.list_cost), 2)                     AS failed_run_list_cost_90d,
  concat_ws(', ', collect_set(o.termination_code)) AS termination_codes
FROM run_cost c
JOIN run_outcome o USING (workspace_id, job_id, run_id)
LEFT JOIN latest_jobs j USING (workspace_id, job_id)
WHERE o.result_state IN ('FAILED', 'ERROR', 'TIMED_OUT')
GROUP BY ALL
ORDER BY failed_run_list_cost_90d DESC
LIMIT 20;

How to read the result

  • Add FAILED, ERROR and TIMED_OUT from part A: that is your failed-run cost for the Evidence Sheet.
  • In part B, termination_codes point to the fix: CLUSTER_ERROR and CLOUD_FAILURE are platform issues, RUN_EXECUTION_ERROR and DRIVER_ERROR are code or sizing issues.
  • A run that failed and was later repaired to success counts as SUCCEEDED here. Q07 catches those.

What good looks like

  • Failed, errored and timed-out runs together are a low single-digit share of job run cost.
  • No job appears in part B two quarters in a row.
Threshold

Treat any job whose failed-run cost exceeds 10% of its own 90-day cost as a reliability ticket, not a cost ticket. Fixing the failure usually removes the cost.

Q07

Jobs with the most repairs and task retries

system.lakeflow.job_run_timelinesystem.lakeflow.job_task_run_timelinesystem.lakeflow.jobsMeasured

When a run is repaired, job_run_timeline records another row with a result state for the same run_id, so terminal rows minus one equals repair attempts (the same logic Databricks uses in its own repair query). Part B counts task attempts beyond one per task per run, which captures automatic task retries.

-- Q07 part A  Jobs with the most repaired runs, last 90 days  (read-only)
WITH run_attempts AS (
  SELECT
    workspace_id, job_id, run_id,
    COUNT(*)                              AS terminal_rows,
    MAX_BY(result_state, period_end_time) AS final_state
  FROM system.lakeflow.job_run_timeline
  WHERE result_state IS NOT NULL
    AND period_end_time >= current_date() - INTERVAL 90 DAYS
  GROUP BY ALL
),
latest_jobs AS (
  SELECT workspace_id, job_id, name, run_as_user_name
  FROM system.lakeflow.jobs
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, job_id ORDER BY change_time DESC) = 1
)
SELECT
  r.workspace_id,
  r.job_id,
  j.name                                                    AS job_name,
  j.run_as_user_name,
  COUNT(*)                                                  AS runs,
  SUM(CASE WHEN r.terminal_rows > 1 THEN 1 ELSE 0 END)      AS repaired_runs,
  SUM(r.terminal_rows - 1)                                  AS repair_attempts,
  ROUND(100 * SUM(CASE WHEN r.terminal_rows > 1 THEN 1 ELSE 0 END) / COUNT(*), 1)
                                                            AS repaired_pct,
  SUM(CASE WHEN r.final_state <> 'SUCCEEDED' THEN 1 ELSE 0 END) AS runs_not_succeeded
FROM run_attempts r
LEFT JOIN latest_jobs j USING (workspace_id, job_id)
GROUP BY ALL
HAVING SUM(r.terminal_rows - 1) > 0
ORDER BY repair_attempts DESC
LIMIT 20;
-- Q07 part B  Tasks that needed more than one attempt per run, last 90 days  (read-only)
SELECT
  workspace_id,
  job_id,
  task_key,
  COUNT(DISTINCT job_run_id)                          AS job_runs,
  COUNT(DISTINCT run_id)                              AS task_attempts,
  COUNT(DISTINCT run_id) - COUNT(DISTINCT job_run_id) AS extra_attempts
FROM system.lakeflow.job_task_run_timeline
WHERE result_state IS NOT NULL
  AND period_end_time >= current_date() - INTERVAL 90 DAYS
GROUP BY ALL
HAVING COUNT(DISTINCT run_id) - COUNT(DISTINCT job_run_id) > 0
ORDER BY extra_attempts DESC
LIMIT 25;

How to read the result

  • repaired_pct is the share of runs that needed a human or a rerun to finish.
  • High extra_attempts on one task key means the retry policy is masking a flaky step. Each attempt bills compute.
  • Pair with Q06 part B: a job high on both lists is your first reliability fix.

What good looks like

  • Repairs are rare and explainable (an upstream outage, a known data issue).
  • No task retries routinely on every run.
Threshold

A job with more than 5% of runs repaired, or a task with extra attempts on most of its runs, deserves a root-cause fix rather than another retry.

Q08

Jobs and tasks with no timeout configured

system.lakeflow.jobssystem.lakeflow.job_taskssystem.lakeflow.job_run_timelineMeasured

Both jobs and job_tasks carry a timeout_seconds column, where 0 or empty means no limit. A hung job with no timeout bills until someone notices. The query lists live jobs where the job, or any of its tasks, has no timeout, with recent run count and longest run.

-- Q08  Live jobs with no job-level or task-level timeout  (read-only)
WITH latest_jobs AS (
  SELECT *
  FROM system.lakeflow.jobs
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, job_id ORDER BY change_time DESC) = 1
),
latest_tasks AS (  -- job_tasks is SCD2 too
  SELECT *
  FROM system.lakeflow.job_tasks
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, job_id, task_key ORDER BY change_time DESC) = 1
),
task_timeouts AS (
  SELECT
    workspace_id, job_id,
    COUNT(*)                                                     AS tasks,
    SUM(CASE WHEN COALESCE(timeout_seconds, 0) = 0 THEN 1 ELSE 0 END) AS tasks_without_timeout
  FROM latest_tasks
  WHERE delete_time IS NULL
  GROUP BY ALL
),
recent_runs AS (
  SELECT
    workspace_id, job_id,
    COUNT(DISTINCT run_id)                                         AS runs_90d,
    MAX(CASE WHEN result_state IS NOT NULL THEN run_duration_seconds END) AS longest_run_seconds
  FROM system.lakeflow.job_run_timeline
  WHERE period_end_time >= current_date() - INTERVAL 90 DAYS
  GROUP BY ALL
)
SELECT
  j.workspace_id,
  j.job_id,
  j.name                                      AS job_name,
  j.run_as_user_name,
  j.paused,
  COALESCE(j.timeout_seconds, 0)              AS job_timeout_seconds,
  t.tasks,
  t.tasks_without_timeout,
  COALESCE(r.runs_90d, 0)                     AS runs_90d,
  ROUND(r.longest_run_seconds / 3600.0, 2)    AS longest_run_hours
FROM latest_jobs j
LEFT JOIN task_timeouts t USING (workspace_id, job_id)
LEFT JOIN recent_runs  r USING (workspace_id, job_id)
WHERE j.delete_time IS NULL
  AND (COALESCE(j.timeout_seconds, 0) = 0 OR t.tasks_without_timeout > 0)
ORDER BY runs_90d DESC, longest_run_hours DESC;

How to read the result

  • job_timeout_seconds = 0 and tasks_without_timeout = tasks: nothing stops a hung run.
  • A job-level timeout alone still protects the run, so treat task-only gaps as lower priority.
  • longest_run_hours gives a sane starting value for the timeout: a margin above the longest healthy run.

What good looks like

Every scheduled job has a job-level timeout, typically set at 1.5x to 2x its longest healthy run. Ideally enforced by a review check on job definitions.

Chapter 4

Right-size it

Most waste is configuration, not code: clusters that idle at low CPU, autoscaling that cannot scale, interactive clusters that never sleep, and scheduled jobs running on the most expensive compute type. These are the cheapest fixes you will find.

Q09

CPU busy % per cluster: average and p95

system.compute.node_timelinesystem.compute.clusterssystem.billing.usagesystem.billing.list_pricesMeasured

node_timeline records one row per node per minute. CPU busy is cpu_user_percent + cpu_system_percent. Drivers are excluded unless the cluster is single-node, because an idle driver would drag every average down. The window is 30 days to keep the scan light; widen it to 90 if you like.

-- Q09  CPU busy % per cluster (avg and p95), last 30 days  (read-only)
WITH nodes AS (
  SELECT
    workspace_id, cluster_id, driver, start_time, mem_used_percent,
    cpu_user_percent + cpu_system_percent AS cpu_busy_pct,
    MAX(CASE WHEN driver THEN 0 ELSE 1 END)
      OVER (PARTITION BY workspace_id, cluster_id) AS has_workers
  FROM system.compute.node_timeline
  WHERE start_time >= current_date() - INTERVAL 30 DAYS
),
per_cluster AS (
  SELECT
    workspace_id, cluster_id,
    COUNT(DISTINCT date_trunc('MINUTE', start_time)) AS active_minutes,
    AVG(cpu_busy_pct)                                AS avg_cpu_busy_pct,
    PERCENTILE_APPROX(cpu_busy_pct, 0.95)            AS p95_cpu_busy_pct,
    AVG(mem_used_percent)                            AS avg_mem_used_pct
  FROM nodes
  WHERE driver = false OR has_workers = 0   -- workers only, unless single-node
  GROUP BY ALL
),
latest_clusters AS (
  SELECT workspace_id, cluster_id, cluster_name, cluster_source, worker_node_type, owned_by
  FROM system.compute.clusters
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, cluster_id ORDER BY change_time DESC) = 1
),
cluster_cost AS (
  SELECT
    u.workspace_id,
    u.usage_metadata.cluster_id AS cluster_id,
    SUM(u.usage_quantity
        * COALESCE(p.pricing.effective_list.default, p.pricing.default)) AS list_cost
  FROM system.billing.usage u
  JOIN system.billing.list_prices p
    ON  u.sku_name   = p.sku_name
    AND u.cloud      = p.cloud
    AND u.usage_unit = p.usage_unit
    AND u.usage_start_time >= p.price_start_time
    AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
  WHERE u.usage_date >= current_date() - INTERVAL 30 DAYS
    AND u.usage_metadata.cluster_id IS NOT NULL
  GROUP BY ALL
)
SELECT
  pc.workspace_id,
  pc.cluster_id,
  c.cluster_name,
  c.cluster_source,
  c.worker_node_type,
  c.owned_by,
  ROUND(pc.active_minutes / 60.0, 1)  AS active_hours,
  ROUND(pc.avg_cpu_busy_pct, 1)       AS avg_cpu_busy_pct,
  ROUND(pc.p95_cpu_busy_pct, 1)       AS p95_cpu_busy_pct,
  ROUND(pc.avg_mem_used_pct, 1)       AS avg_mem_used_pct,
  ROUND(COALESCE(cc.list_cost, 0), 2) AS list_cost_30d,
  (pc.active_minutes >= 30
   AND pc.avg_cpu_busy_pct < 20
   AND pc.p95_cpu_busy_pct < 40)      AS under_utilised
FROM per_cluster pc
LEFT JOIN latest_clusters c  USING (workspace_id, cluster_id)
LEFT JOIN cluster_cost    cc USING (workspace_id, cluster_id)
ORDER BY under_utilised DESC, list_cost_30d DESC;

How to read the result

  • under_utilised = true rows come first, sorted by cost: these are your right-sizing candidates.
  • Low CPU with high avg_mem_used_pct means the cluster is memory-bound. Move to a memory-optimised node type rather than shrinking it.
  • Low average but high p95 is a bursty workload: autoscaling fits better than a smaller fixed size.

What good looks like

  • Batch job clusters averaging well above 40% busy while running.
  • Few or no under-utilised clusters with material cost.
Threshold

Flag a cluster as under-utilised when it ran for at least 30 minutes, with average CPU busy below 20% and p95 below 40%. The p95 condition stops you shrinking a cluster that genuinely peaks.

Q10

Autoscale min = max, and interactive clusters that never sleep

system.compute.clustersMeasured

Two configuration smells in one pass over the latest cluster definitions. When min_autoscale_workers equals max_autoscale_workers, autoscaling is switched off in practice. When auto_termination_minutes is 0, an interactive cluster runs until someone stops it.

-- Q10  Live interactive clusters: autoscale min = max, or no / long auto-termination  (read-only)
WITH latest_clusters AS (
  SELECT *
  FROM system.compute.clusters
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, cluster_id ORDER BY change_time DESC) = 1
)
SELECT
  workspace_id,
  cluster_id,
  cluster_name,
  cluster_source,
  owned_by,
  worker_node_type,
  worker_count,
  min_autoscale_workers,
  max_autoscale_workers,
  auto_termination_minutes,
  concat_ws(' | ',
    CASE WHEN min_autoscale_workers IS NOT NULL
          AND min_autoscale_workers = max_autoscale_workers
         THEN 'Autoscale min = max' END,
    CASE WHEN COALESCE(auto_termination_minutes, 0) = 0
         THEN 'No auto-termination' END,
    CASE WHEN auto_termination_minutes > 60
         THEN 'Idle window over 60 min' END
  ) AS findings
FROM latest_clusters
WHERE delete_time IS NULL
  AND cluster_source IN ('UI', 'API')
  AND (   (min_autoscale_workers IS NOT NULL AND min_autoscale_workers = max_autoscale_workers)
       OR COALESCE(auto_termination_minutes, 0) = 0
       OR auto_termination_minutes > 60)
ORDER BY findings, cluster_name;

How to read the result

  • "No auto-termination" is the urgent one: cross-check the cluster in Q09 to see how much of its time is idle.
  • "Autoscale min = max" is a fixed-size cluster with an autoscaling label. Lower the minimum or make the size explicit.
  • Clusters created by jobs (JOB source) are excluded: they end with the run.

What good looks like

  • Every interactive cluster auto-terminates, typically after 30 to 60 idle minutes, enforced by a cluster policy.
  • Autoscaling minimums at 1 or 2 workers unless there is a written reason.
Benchmark

Databricks sets a 120-minute auto-termination default for clusters created in the UI. Many teams tighten interactive clusters to 30 to 60 minutes through policy. Never 0 for shared interactive compute.

Q11

Scheduled work running on all-purpose compute

system.lakeflow.job_task_run_timelinesystem.compute.clusterssystem.billing.usagesystem.billing.list_pricessystem.lakeflow.jobsEstimated

Databricks bills jobs that run on all-purpose clusters to the cluster, not the job: usage_metadata.job_id is not populated for them. So filtering billing on ALL_PURPOSE SKUs with a job id returns nothing. Instead, this query reads compute_ids on each task run, keeps the ones that point at interactive clusters (source UI or API), then prices the cluster-hours those tasks touched.

-- Q11  Jobs whose tasks ran on all-purpose clusters, last 90 days  (read-only)
WITH latest_clusters AS (
  SELECT workspace_id, cluster_id, cluster_name, cluster_source, owned_by
  FROM system.compute.clusters
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, cluster_id ORDER BY change_time DESC) = 1
),
task_clusters AS (
  SELECT t.workspace_id, t.job_id, t.period_start_time,
         explode(t.compute_ids) AS cluster_id
  FROM system.lakeflow.job_task_run_timeline t
  WHERE t.period_start_time >= current_date() - INTERVAL 90 DAYS
),
task_hours AS (            -- each hour in which a scheduled task touched an interactive cluster
  SELECT DISTINCT
    tc.workspace_id, tc.job_id, tc.cluster_id, c.cluster_name, c.owned_by,
    date_trunc('HOUR', tc.period_start_time) AS usage_hour
  FROM task_clusters tc
  JOIN latest_clusters c USING (workspace_id, cluster_id)
  WHERE c.cluster_source IN ('UI', 'API')
),
cluster_hour_cost AS (
  SELECT
    u.workspace_id,
    u.usage_metadata.cluster_id               AS cluster_id,
    date_trunc('HOUR', u.usage_start_time)    AS usage_hour,
    SUM(u.usage_quantity
        * COALESCE(p.pricing.effective_list.default, p.pricing.default)) AS list_cost
  FROM system.billing.usage u
  JOIN system.billing.list_prices p
    ON  u.sku_name   = p.sku_name
    AND u.cloud      = p.cloud
    AND u.usage_unit = p.usage_unit
    AND u.usage_start_time >= p.price_start_time
    AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
  WHERE u.usage_date >= current_date() - INTERVAL 90 DAYS
    AND u.sku_name LIKE '%ALL_PURPOSE%'
  GROUP BY ALL
),
latest_jobs AS (
  SELECT workspace_id, job_id, name
  FROM system.lakeflow.jobs
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, job_id ORDER BY change_time DESC) = 1
)
SELECT
  th.workspace_id,
  th.job_id,
  j.name                         AS job_name,
  th.cluster_id,
  th.cluster_name,
  th.owned_by                    AS cluster_owner,
  COUNT(*)                       AS cluster_hours_touched,
  ROUND(SUM(chc.list_cost), 2)   AS est_all_purpose_list_cost_90d
FROM task_hours th
LEFT JOIN cluster_hour_cost chc USING (workspace_id, cluster_id, usage_hour)
LEFT JOIN latest_jobs j         USING (workspace_id, job_id)
GROUP BY ALL
ORDER BY est_all_purpose_list_cost_90d DESC NULLS LAST;

How to read the result

  • Every row is a scheduled job that depends on an interactive cluster. That is a cost and a reliability risk (someone can restart or resize it mid-run).
  • The cost is an upper bound: it is the whole cluster's cost in each hour a task touched it, including any interactive use in the same hour. Two jobs sharing an hour both count it.

What good looks like

Zero rows. Scheduled production work runs on jobs compute or serverless jobs, where it is cheaper per DBU at list price and every run is traceable in billing.

Sizing it

To estimate the move, compare the current list price of your ALL_PURPOSE SKU with the matching JOBS SKU in system.billing.list_prices and apply the ratio to this cost. Label the result as estimated until a job has actually run on jobs compute.

Chapter 5

Classify it

Not every job should move, and not every job should stay. Sort each workload by how complex it is and how deeply it depends on Databricks-only features. The query fills in what the system tables know; you fill in the rest.

Q12

Job complexity and dependency worksheet

system.lakeflow.jobssystem.lakeflow.job_taskssystem.billing.usagesystem.billing.list_pricessystem.compute.node_typesMeasured

For each live job: task count and dependency edges from job_tasks (depends_on_keys), a structural complexity band, and three dependency signals billing can see for you: serverless, Photon and GPU usage. The others (dbutils, Databricks SDK, Lakeflow Declarative Pipelines, ML, streaming) need a look at the code.

-- Q12  Job complexity and dependency worksheet, last 90 days  (read-only)
WITH latest_jobs AS (
  SELECT *
  FROM system.lakeflow.jobs
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, job_id ORDER BY change_time DESC) = 1
),
latest_tasks AS (
  SELECT *
  FROM system.lakeflow.job_tasks
  QUALIFY ROW_NUMBER() OVER (PARTITION BY workspace_id, job_id, task_key ORDER BY change_time DESC) = 1
),
task_shape AS (
  SELECT
    workspace_id, job_id,
    COUNT(*) AS task_count,
    SUM(CASE WHEN depends_on_keys IS NULL THEN 0 ELSE size(depends_on_keys) END) AS dependency_edges,
    MAX(CASE WHEN depends_on_keys IS NULL THEN 0 ELSE size(depends_on_keys) END) AS max_fan_in
  FROM latest_tasks
  WHERE delete_time IS NULL
  GROUP BY ALL
),
gpu_nodes AS (
  SELECT DISTINCT node_type FROM system.compute.node_types WHERE gpu_count > 0
),
job_usage AS (
  SELECT
    u.workspace_id,
    u.usage_metadata.job_id AS job_id,
    SUM(u.usage_quantity
        * COALESCE(p.pricing.effective_list.default, p.pricing.default)) AS list_cost,
    bool_or(u.product_features.is_serverless)  AS uses_serverless,
    bool_or(u.product_features.is_photon)      AS uses_photon,
    bool_or(g.node_type IS NOT NULL)           AS uses_gpu
  FROM system.billing.usage u
  JOIN system.billing.list_prices p
    ON  u.sku_name   = p.sku_name
    AND u.cloud      = p.cloud
    AND u.usage_unit = p.usage_unit
    AND u.usage_start_time >= p.price_start_time
    AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
  LEFT JOIN gpu_nodes g ON g.node_type = u.usage_metadata.node_type
  WHERE u.usage_date >= current_date() - INTERVAL 90 DAYS
    AND u.usage_metadata.job_id IS NOT NULL
  GROUP BY ALL
)
SELECT
  j.workspace_id,
  j.job_id,
  j.name                                   AS job_name,
  j.run_as_user_name,
  j.trigger_type,
  s.task_count,
  s.dependency_edges,
  s.max_fan_in,
  CASE WHEN s.task_count >= 10 OR s.dependency_edges >= 12 THEN 'High'
       WHEN s.task_count >= 4  OR s.dependency_edges >= 4  THEN 'Medium'
       ELSE 'Low' END                      AS structural_complexity,
  COALESCE(ju.uses_serverless, false)      AS uses_serverless,
  COALESCE(ju.uses_photon, false)          AS uses_photon,
  COALESCE(ju.uses_gpu, false)             AS uses_gpu,
  ROUND(COALESCE(ju.list_cost, 0), 2)      AS list_cost_90d
FROM latest_jobs j
JOIN task_shape s USING (workspace_id, job_id)
LEFT JOIN job_usage ju USING (workspace_id, job_id)
WHERE j.delete_time IS NULL
ORDER BY list_cost_90d DESC;

How to read the result

  • Start with the top 20 by list_cost_90d. Classifying the long tail rarely pays back.
  • structural_complexity is a starting band. Raise it if tasks hide large notebooks or custom libraries.
  • Carry the serverless, Photon and GPU flags into the scorer below, then add what you find in the code.

What good looks like

Every job above your cost cut-off has one of the five outcomes, an owner, and a reason a reviewer could challenge.

Worksheet

Keep or move: score each workload

Pick a complexity band and tick the Databricks dependencies you found. Lakeflow Declarative Pipelines (formerly DLT), ML and streaming weigh 2 points each because they are the hardest to replicate elsewhere; the rest weigh 1. Low dependency is 0 to 1 points, Medium 2 to 3, High 4 or more. Scores stay in this browser.

Dependency
Low complexity
Medium
High
High
Keep on Databricks
Keep on Databricks
Keep on Databricks
Medium
Evaluate
Optimise then evaluate
Defer
Low
Migrate
Evaluate
Optimise then evaluate

A starting heuristic, not a verdict. Defer anything whose 90-day cost would not pay for the effort of moving it, whatever the matrix says.

Chapter 6

Prove it

A move only counts when the new output matches the old one. Run this parity checklist for every migrated job before you switch the old one off, and keep the results with the change record.

Q13

Parity checklist for any migrated job

your source tableyour target tablesystem.information_schema.columnsMeasured

Generic SQL templates. Fill in the fields and every template below rewrites itself. Use three-part names. If the target lives outside Databricks, land its output in a comparison schema first, or run the same SQL on the target engine and compare results side by side.

Runs entirely in your browser. Nothing you type here is sent anywhere.

Row and column counts

Same number of rows, same number of columns, no duplicate business keys on either side.

Pass: exact match, zero duplicates

Schema match

Every column present on both sides with the same data type.

Pass: every row says Match

Null rates per column

A column that is suddenly emptier, or fuller, is the most common silent break.

Pass: identical null % per column

Numeric aggregates

Sums, minimums, maximums and averages for the columns finance cares about.

Pass: equal to the agreed precision

Row-level comparison

Part A uses EXCEPT ALL for an exact set comparison. Part B hashes each row by business key and counts missing, extra and changed rows. Part C lists the first differing keys to inspect.

Pass: zero rows in A, zero missing, extra and changed in B

Across engines, cast floating-point columns to a fixed DECIMAL and normalise timestamps to UTC before hashing, or identical data will hash differently. Record any accepted tolerance in the change record.

One page for the renewal meeting

Renewal Evidence Sheet

Fill it in as you run the queries, then print it or save it as a PDF. Every field says which query feeds it and whether the number is measured or estimated.

No data leaves your browser. It is saved only in this browser's local storage.

Databricks Renewal Evidence Sheet

Built from Databricks system tables using the BigHammer.ai Cost Field Guide. Costs at list price, before contract discounts.

BigHammer.ai
1

90-day baseline

Measuredat list price, before contract discountsQ01
Annualised (projection) 
2

Traceability

MeasuredGapQ03, Q04, Q05
Traced % 
Not yet attributable (gap) 
3

Top 10 jobs and owners

Measuredat list price, before contract discountsQ02
JobOwner90-day list costNext action
Top 10 total 
Share of baseline 
4

Reliability cost

Measuredat list price, before contract discountsQ06, Q07, Q08
Share of baseline 
5

Right-sizing flags

MeasuredEstimated: Q11Q09, Q10, Q11
6

Keep vs move

Q12 and scorer
WorkloadOutcomeReason / evidence
7

Our position for the renewal

Costs are list price, before contract discounts, from system.billing.usage and system.billing.list_prices. Untraced spend is a visibility gap, not a saving.

Review it with us

Saved in this browser

What the full assessment adds

The guide shows where. The assessment puts it in one report.

These queries answer one question at a time. The offline assessment answers them together: seven published, read-only SQL queries you run in your own Databricks SQL warehouse, and one defensible report back. No credentials leave your environment.

This field guide

  • 13 read-only queries you run yourself
  • List-price view of spend, failures and idle compute
  • A heuristic keep-or-move score per workload
  • Parity templates you adapt by hand

The offline assessment

  • 7 published, read-only SQL queries, run in one Databricks SQL warehouse session
  • Results exported to CSV, zipped and sent to BigHammer. No credentials leave your environment
  • Total 90-day cost, cost by product, traced versus untraced spend and failed-job cost in one report
  • Per-job and per-run cost tables, with measured and estimated figures kept separate

From the team behind the webinar "Reduce your Databricks Costs up to 75%", hosted by Srinath Reddy, Founder and CEO, BigHammer.ai.