ByteLoom Consulting
Performance & Cost

Optimizing Databricks Compute Costs and Spark Workloads

Attribute Databricks spend with system tables, choose compute per workload, tune Spark and Delta layout, and hold the savings with policies and reviews.

Written by
ByteLoom Consulting
Published
Reading time
15 min read
On this page 8 sections

Databricks spend rarely comes from one bad decision. It accumulates from defaults nobody revisited: scheduled jobs on all-purpose clusters, a SQL warehouse sized for a Monday-morning peak, tables made of small files, a streaming query polling an empty source around the clock. Each is cheap to fix once you can see it.

This article is a working method for engineers who own a Databricks bill: build a cost baseline from system tables, choose compute per workload, tune the Spark workloads that dominate spend, keep Delta table layout healthy, control streaming cost, and govern it so the savings last. Examples assume Databricks on AWS and use generic names such as main.sales.orders.

Establish a cost baseline before tuning anything

Tuning without attribution optimizes whatever is in front of you rather than whatever costs the most. Start with a dataset that answers, for any day, which team, pipeline and product consumed what, at list price, on which kind of compute.

What the billing system tables give you

system.billing.usage has one row per usage record: quantity and unit (DBUs for compute), sku_name, billing_origin_product, a usage_metadata struct with attribution ids (job_id, job_run_id, warehouse_id, cluster_id), identity_metadata.run_as, custom_tags, and product_features flags such as is_serverless. system.billing.list_prices holds each SKU’s list price over time, so cost is a join on the SKU and the price window that contains the usage.

Two details decide whether the numbers are right. Corrections arrive as extra rows, a RETRACTION with a negative quantity followed by a RESTATEMENT, so sum every record type rather than filtering to ORIGINAL. And this is list price for the Databricks side only. It ignores negotiated discounts, and classic compute also incurs cloud provider charges for instances and storage, which you attribute from AWS cost data using the same tags.

-- List-price cost per team and product over the last 30 days.
-- RETRACTION rows carry negative quantities, so summing all record types
-- yields corrected usage.
SELECT
  COALESCE(u.custom_tags['team'], 'untagged')                      AS team,
  u.billing_origin_product                                         AS product,
  COALESCE(u.product_features.is_serverless, false)                AS serverless,
  ROUND(SUM(u.usage_quantity), 1)                                  AS dbus,
  ROUND(SUM(u.usage_quantity * p.pricing.effective_list.default), 2) AS list_cost
FROM system.billing.usage AS u
JOIN system.billing.list_prices AS p
  ON  p.sku_name = u.sku_name
  AND u.usage_end_time >= p.price_start_time
  AND (p.price_end_time IS NULL OR u.usage_end_time < p.price_end_time)
WHERE u.usage_date >= date_sub(current_date(), 30)
GROUP BY ALL
ORDER BY list_cost DESC;

Attribution with tags, usage policies and job identity

Classic compute carries default tags (ClusterId, ClusterName, Creator, plus JobId and RunName on job compute) and any custom tags set on the cluster, pool or job. Custom tags reach both custom_tags in the usage table and the EC2 and EBS resources. Serverless has no cluster to tag, so attribution comes from serverless usage policies (introduced as serverless budget policies): named sets of tags applied to the serverless usage of the users, jobs, pipelines, notebooks and endpoints assigned to them. Policy tag changes apply only to usage that starts afterwards, so set policies up before moving workloads.

Keep the taxonomy small and enforced: three or four keys such as team, cost_center and env, applied by policy. Untagged spend needs an owner.

Shared SQL warehouses need an allocation model. system.query.history records executed_by, query_source and total_task_duration_ms, so split each warehouse’s cost by each consumer’s share of task time. Agree the model with finance; it is an approximation, not metered truth.

For jobs, join usage to the latest row per job in system.lakeflow.jobs, which keeps a row for every change:

-- Cost per job and per run over the last 30 days, at list price.
WITH job_names 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
),
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 * p.pricing.effective_list.default) AS list_cost
  FROM system.billing.usage AS u
  JOIN system.billing.list_prices AS p
    ON  p.sku_name = u.sku_name
    AND u.usage_end_time >= p.price_start_time
    AND (p.price_end_time IS NULL OR u.usage_end_time < p.price_end_time)
  WHERE u.usage_metadata.job_id IS NOT NULL
    AND u.usage_date >= date_sub(current_date(), 30)
  GROUP BY ALL
)
SELECT
  c.workspace_id,
  c.job_id,
  n.name                                       AS job_name,
  c.runs,
  ROUND(c.list_cost, 2)                        AS list_cost,
  ROUND(c.list_cost / NULLIF(c.runs, 0), 2)    AS list_cost_per_run
FROM job_cost AS c
LEFT JOIN job_names AS n
  ON n.workspace_id = c.workspace_id AND n.job_id = c.job_id
ORDER BY list_cost DESC
LIMIT 25;

job_id is not populated for jobs that run on all-purpose clusters. That gap is itself a finding: scheduled work on all-purpose compute appears under ALL_PURPOSE with no job identity, usually run as a service principal. Find it and move it.

Cost attribution flow from system tables to decisions Five Databricks system tables (billing usage, list prices, Lakeflow jobs, compute node timeline and query history) feed a curated cost model with cost by tag, cost per run, utilization and query hot spots. The cost model drives budgets and alerts, compute policies, a tuning backlog ranked by cost and a monthly review. A dashed arrow returns to the system tables: every change is re-measured. system.* tables billing.usage DBUs, SKU, tags, ids billing.list_prices list price per SKU lakeflow.jobs job names and owners compute.node_timeline CPU, memory per node query.history spill, bytes scanned cost model cost by tag team, cost center cost per run jobs and pipelines utilization idle, oversized query hot spots spill, full scans decisions budgets, alerts per team and tag compute policies limits and defaults tuning backlog ranked by cost monthly review owners sign off re-measure after every change
Fig. 01 Attribution starts in the system tables and ends in decisions that are re-measured. Classic compute also has a cloud provider bill, attributed with the same tags.

Choose compute deliberately

Compute choice sets the unit price of everything that runs on it. Decide per workload, write the decision down, and revisit it when the workload changes.

Choosing compute for a Databricks workload A decision path with four questions. Interactive development leads to all-purpose compute or serverless notebooks with auto-termination. Otherwise, a SQL-only workload leads to a serverless SQL warehouse, sized up for spill and scaled out for queues. Otherwise, a need for cluster-level control such as instance types, spot or Spark configuration leads to classic job compute with a compute policy, spot workers and an LTS runtime. Otherwise, if startup latency matters, use serverless in performance optimized mode; if not, serverless in standard mode, which starts more slowly and uses fewer DBUs per run. Interactive development? notebooks, exploration yes All-purpose compute or serverless notebooks; auto-terminate SQL-only workload? BI, dashboards, SQL transformations yes Serverless SQL warehouse size up for spill, scale out for queues Need cluster-level control? instance types, spot, Spark conf yes Classic job compute compute policy, spot workers, LTS runtime Startup latency matters? tight SLAs, short tasks yes Serverless, performance optimized fast start, higher DBU use per run no no no no Serverless, standard mode slower start, fewer DBUs per run
Fig. 02 A first-pass decision path for one workload. The order of questions is a heuristic; confirm each choice with measured cost per run.

Job compute for scheduled work

All-purpose clusters are built for interactive work and accumulate idle time between commands. Databricks’ cost guidance states that non-interactive workloads cost significantly less on job compute, which exists only for the duration of a run. Interactive clusters still need a short auto-termination setting, enforced by policy.

Serverless or classic

Serverless compute removes cluster sizing, start-up waits and idle capacity, and with them some levers: instance choice, spot, pools, init scripts, most Spark configurations, and the Spark UI (the query profile replaces it). Serverless jobs and pipelines run in performance optimized mode or in standard mode, which uses fewer DBUs in exchange for a start-up delay of several minutes and is unavailable for some task types, such as continuous pipelines. product_features.performance_target records the mode, so compare cost per run before and after switching.

SQL warehouses: size for the query, scale for concurrency

Size sets the resources for a single query; the cluster range sets how much concurrency the warehouse absorbs. Follow the documented rule: if queries spill to disk, increase the size; if they queue, raise the maximum number of clusters. Serverless warehouses start in seconds, so a short auto-stop costs little; pro and classic warehouses take minutes, so a very short auto-stop trades idle cost for cold starts. Find the statements worth fixing before resizing:

-- Warehouse statements that spilled to disk in the last 7 days, worst first.
SELECT
  statement_id,
  executed_by,
  compute.warehouse_id                           AS warehouse_id,
  total_duration_ms,
  total_task_duration_ms,
  ROUND(read_bytes / POWER(1024, 3), 1)          AS read_gib,
  ROUND(spilled_local_bytes / POWER(1024, 3), 1) AS spilled_gib,
  read_io_cache_percent
FROM system.query.history
WHERE start_time >= current_timestamp() - INTERVAL 7 DAYS
  AND compute.type = 'WAREHOUSE'
  AND spilled_local_bytes > 0
ORDER BY spilled_local_bytes DESC
LIMIT 20;

Pools, spot and fleet instances on AWS

Databricks charges no DBUs for idle pool instances, but AWS bills for them, so a pool’s idle minimum is a standing cost. For spot capacity, set aws_attributes.availability to SPOT_WITH_FALLBACK and aws_attributes.first_on_demand to 1 so the driver stays on demand. Work on a reclaimed worker can be lost and recomputed, so spot suits idempotent batch with short stages, not long stages or streaming. Fleet instance types and flexible node types reduce launch failures by falling back across instance types when capacity is short.

Photon: when it pays for itself

Photon is the default on SQL warehouses and serverless, and on by default for classic compute, where it is a real cost decision because Photon instance types consume DBUs at a different rate. It pays for itself when the runtime reduction outweighs the rate difference, most likely for long-running scans, joins and aggregations over large data. Queries that finish in a couple of seconds gain little, and Photon does not accelerate Python UDFs, RDD or Dataset APIs, or stateful streaming; those parts fall back to the standard engine.

Autoscaling behavior

Optimized autoscaling (Premium plan and above) can scale down a busy cluster by looking at shuffle file state, and job compute scales down sooner than all-purpose compute. The traps are a high minimum, paid for with or without work, and a generous maximum that lets an inefficient query buy its way out of a problem. Check system.compute.node_timeline first: workers at low CPU for weeks mean oversized compute. For streaming, autoscaling on classic compute has scale-down limitations; Databricks recommends Lakeflow pipelines (Spark Declarative Pipelines, formerly Delta Live Tables) with enhanced autoscaling.

Tune the workloads that dominate the bill

Work down the cost-per-run list, reading the query profile (serverless and SQL warehouses) or the Spark UI (classic) before changing anything: which operators take the time, how much is read versus pruned or shuffled, what spills, and which tasks run long after the rest.

Shuffle, skew and adaptive query execution

Adaptive query execution (AQE) is on by default. It coalesces small shuffle partitions, switches sort-merge joins to broadcast joins at runtime (on Databricks, below spark.databricks.adaptive.autoBroadcastJoinThreshold, 30 MB by default) and splits skewed partitions in sort-merge and shuffle hash joins. On classic compute, setting spark.sql.shuffle.partitions to auto enables auto-optimized shuffle, usually better than a fixed constant. AQE’s skew handling targets joins, so a hot key in an aggregation or window function still lands on one task; a salted two-stage aggregation spreads it, as in the example below. On serverless most Spark configurations cannot be set, which leaves the query and the data layout as the levers.

Joins, partitions and statistics

The optimizer chooses broadcast joins from table statistics, so stale statistics often explain a large table shuffled to join a small one. Predictive optimization runs ANALYZE on managed tables; elsewhere, compute statistics after large loads. Prefer a verified broadcast() hint to a higher job-wide threshold: broadcast data is collected on the driver and copied to every executor, and a safe threshold today becomes an out-of-memory error once the dimension grows.

Partition settings rarely need tuning beyond the defaults (128 MB read partitions and AQE coalescing). The usual culprits are inherited repartition() calls, coalesce(1) on large outputs and tiny input files. Fix small files at the table, not in every reader.

Python UDFs and caching

A row-at-a-time Python UDF serializes each row out of the JVM and back, hides its logic from the optimizer and cannot run in Photon. Most such UDFs reimplement string, date or JSON functions that Spark already has. Where Python is genuinely needed, a pandas UDF exchanges data in Arrow batches at much lower cost.

from pyspark.sql import functions as F

orders = spark.read.table("main.sales.orders")
stores = spark.read.table("main.sales.stores")  # a few thousand rows

# 1. Replace a row-at-a-time Python UDF with built-in functions.
#    Before:
#      @F.udf("string")
#      def email_domain(email):
#          return email.split("@")[-1].lower() if email else None
orders = orders.withColumn(
    "email_domain", F.lower(F.substring_index("customer_email", "@", -1))
)

# 2. Aggregate before joining, and broadcast the small side explicitly
#    when its statistics are missing or stale.
daily_revenue = (
    orders.groupBy("store_id", F.to_date("order_ts").alias("order_date"))
    .agg(F.sum("amount").alias("revenue"))
    .join(F.broadcast(stores.select("store_id", "region")), "store_id")
)

# 3. Spread a hot key across tasks with a two-stage (salted) aggregation.
#    Valid for decomposable aggregates such as sum and count.
SALT_BUCKETS = 16
lifetime_value = (
    orders.withColumn("salt", (F.rand() * SALT_BUCKETS).cast("int"))
    .groupBy("customer_id", "salt")
    .agg(F.sum("amount").alias("partial"))
    .groupBy("customer_id")
    .agg(F.sum("partial").alias("lifetime_value"))
)

df.cache() is often a cost rather than an optimization: caching a DataFrame used once adds a materialization step and memory pressure. On classic compute the disk cache already keeps local copies of Parquet and Delta files on supported instance types, and Databricks recommends it over the Spark cache for repeated reads. Spark-cached data is lost when autoscaling removes a worker, and serverless does not support CACHE TABLE. Cache only what is expensive to compute and reused within the job, then unpersist() it.

Keep table layout healthy

Layout is where storage and compute cost meet: a well-laid-out table is read with fewer files and better data skipping, by every consumer, every time.

File sizing

Small files mean more tasks, more metadata and poorer compression. Optimized writes and auto compaction (delta.autoOptimize.optimizeWrite and delta.autoOptimize.autoCompact) reduce them at write time and are always on for MERGE, UPDATE and DELETE. Unity Catalog managed tables tune their target file size automatically; for external tables, delta.targetFileSize sets an explicit target.

Liquid clustering versus partitioning and Z-ordering

Databricks recommends liquid clustering for all new tables. Up to four clustering keys, chosen from the most-filtered columns, can be changed without rewriting the table, and cannot be combined with partitioning or ZORDER. On managed tables with predictive optimization, CLUSTER BY AUTO lets the platform choose keys from observed queries. Recent runtimes can also convert a partitioned table in place with ALTER TABLE ... REPLACE PARTITIONED BY WITH CLUSTER BY; check the runtime requirement first.

-- New table: clustering keys instead of partitions.
CREATE TABLE main.sales.order_events (
  event_id  STRING,
  order_id  STRING,
  store_id  INT,
  event_ts  TIMESTAMP,
  payload   STRING
)
CLUSTER BY (store_id, event_ts);

-- Changing keys is a metadata change. Existing data is reclustered only when
-- forced, and on a large table that can take hours: schedule it.
ALTER TABLE main.sales.order_events CLUSTER BY (order_id, event_ts);
OPTIMIZE main.sales.order_events FULL;

-- Managed table with predictive optimization: let the platform choose keys.
ALTER TABLE main.sales.orders CLUSTER BY AUTO;

Predictive optimization and VACUUM

Predictive optimization runs OPTIMIZE, VACUUM and ANALYZE on Unity Catalog managed tables and is enabled by default for eligible workspaces (check the current documentation for your account). It is billed under a serverless jobs SKU, appears as PREDICTIVE_OPTIMIZATION in the usage table and logs each operation in system.storage.predictive_optimization_operations_history. It replaces maintenance you would otherwise schedule; external tables still need that scheduled maintenance.

VACUUM deletes unreferenced files older than delta.deletedFileRetentionDuration, seven days by default. A shorter retention saves storage but shortens time travel, and can delete files that a long-running write still needs. Keep the safety check that blocks short retention switched on.

Control streaming cost

A streaming query with no trigger runs as a processing-time trigger with a zero interval: it polls continuously, keeps compute up around the clock, and makes cloud storage API calls even when nothing arrives. Match the trigger to the freshness consumers need:

  • processingTime with an interval bounds how often micro-batches run on compute that stays up.
  • availableNow processes what is available and stops, turning a stream into a schedulable incremental batch job. It is the trigger serverless notebooks and jobs support.
  • Continuous processing is experimental in open source Spark and not supported by Databricks. For sub-second latency, check the availability of Databricks’ real-time mode.

State is the other streaming cost. Aggregations, dropDuplicates and stream-stream joins keep state, and with it memory, checkpoint storage and batch duration, that grows without bound unless a watermark limits it. RocksDB is the default state store from Databricks Runtime 17.3 LTS, with changelog checkpointing on by default; enable both on older runtimes. The number of shuffle partitions for a stateful query is fixed when its checkpoint is created, so set it before the first run.

# Hourly incremental deduplication instead of an always-on stream.
# Schedule this as a job; each run processes the new data and stops.
CHECKPOINT = "/Volumes/main/sales/checkpoints/order_events_dedup"

(
    spark.readStream.table("main.sales.order_events")
    .withWatermark("event_ts", "2 hours")
    .dropDuplicatesWithinWatermark(["event_id"])
    .writeStream
    .option("checkpointLocation", CHECKPOINT)
    .trigger(availableNow=True)
    .toTable("main.sales.order_events_dedup")
)

Govern cost so the savings stay

Compute policies as guardrails

Compute policies (formerly cluster policies) constrain classic compute with fixed values, ranges, allowlists and patterns, and with the virtual dbus_per_hour attribute that caps a cluster’s hourly DBU rate. Manage them in Terraform so changes are reviewed like code:

variable "team" {
  type        = string
  description = "Owning team, written as a tag on every cluster created under the policy"
}

resource "databricks_cluster_policy" "job_compute" {
  name        = "job-compute-${var.team}"
  description = "Job compute only: LTS runtime, spot workers, bounded size, tagged"

  definition = jsonencode({
    "cluster_type"  = { type = "fixed", value = "job" }
    "spark_version" = { type = "unlimited", defaultValue = "auto:latest-lts" }
    "node_type_id" = {
      type         = "allowlist"
      values       = ["m6gd.xlarge", "m6gd.2xlarge", "r6gd.2xlarge"]
      defaultValue = "m6gd.xlarge"
    }
    "autoscale.min_workers"          = { type = "range", minValue = 1, maxValue = 2 }
    "autoscale.max_workers"          = { type = "range", maxValue = 8, defaultValue = 4 }
    "dbus_per_hour"                  = { type = "range", maxValue = 40 }
    "aws_attributes.availability"    = { type = "fixed", value = "SPOT_WITH_FALLBACK" }
    "aws_attributes.first_on_demand" = { type = "fixed", value = 1 }
    "custom_tags.team"               = { type = "fixed", value = var.team }
    "custom_tags.cost_center"        = { type = "regex", pattern = "^cc-[0-9]{4}$" }
  })
}

resource "databricks_permissions" "job_compute_use" {
  cluster_policy_id = databricks_cluster_policy.job_compute.id

  access_control {
    group_name       = "${var.team}-engineers"
    permission_level = "CAN_USE"
  }
}

The policy allows job compute only, defaults to an LTS runtime, bounds node types, workers and hourly DBUs, keeps the driver on demand, fixes the team tag and validates cost_center. The values are illustrative; set yours from measured utilization. Policies govern classic compute only; serverless relies on usage policies, budgets and review.

Budgets and alerts

Account budgets track spend by workspace and tag and email owners at thresholds. They are useful, and they are not a control.

A review cadence

  • Weekly: new untagged spend, the largest day-over-day movers, and retried runs, which pay for compute again.
  • Monthly: top workloads by cost per run, warehouse queueing, policy exceptions and the tuning backlog, each with a named owner.
  • Quarterly: re-baseline after runtime upgrades, revisit serverless and classic choices, and retire unused jobs, warehouses and dashboards.

When migrating legacy ETL, make cost per run an acceptance criterion; the ETL migration article covers validation. The ByteLoom Lakehouse Health Check, an accelerator currently in development, is being designed as a structured assessment that covers compute efficiency alongside configuration, governance and reliability. For hands-on help, see Databricks and lakehouse engineering.

Decision summary

  • One priced usage view, summing all record types, behind every cost report.
  • A small, enforced tag taxonomy: compute policies for classic, usage policies for serverless.
  • No scheduled work on all-purpose compute.
  • Serverless or classic chosen per workload on measured cost per run, including the cloud bill.
  • Warehouses sized for spill, scaled for queueing, with auto-stop set deliberately.
  • Spot workers with an on-demand driver for idempotent batch; Photon tested per job.
  • Query profile first; then skew, stale statistics, Python UDFs and needless caching.
  • Liquid clustering for new tables, predictive optimization for managed ones.
  • Streaming triggers matched to freshness, state bounded by watermarks.
  • Budgets as a backstop behind a regular review with named owners.

References

Tags

  • databricks
  • spark
  • cost-optimization
  • finops
  • delta-lake
  • system-tables

Share

About the author

Founder & Principal Data Architect

ByteLoom Consulting

Senior data architect focused on Databricks lakehouse platforms, enterprise ETL modernization, cloud architecture on AWS, and production AI engineering.

About ByteLoom and the founder

Related articles

  • AI Engineering 15 min read

    Building an Enterprise AI Observability Framework

    A reference framework for observing LLM and RAG applications: signals, a Delta telemetry model, cost attribution, evaluation and governance on Databricks.

Start a conversation

Let's Build What's Next.

Tell us about the platform you are modernizing, the migration you are planning, or the AI workload you need in production. You will hear back from an engineer, not a sales team.