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 queriesNo install, no signupEvery column checked against Databricks docs
Q01_cost_by_product.sql
SELECT billing_origin_product,
ROUND(SUM(list_cost), 2) AS list_cost_90d
FROM priced
GROUP BY ALL
ORDER BY list_cost_90d DESC;
Result shapeIllustrative
ALL_PURPOSE
JOBS
SQL
DLT
MODEL_SERVING
90days of evidence, straight from system tables
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.
01
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.
02
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.
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.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.
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.
03
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.
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.
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.
04
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.
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.
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.
05
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.
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.
06
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.
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