DatabricksZero to Advanced

Find out why a query is slow, then fix the cause. Most speed-ups come from reading less data, shuffling less, and giving the optimizer the right layout.

  • Serverless has no Spark UI and accepts only a few Spark settings. You tune with code, table layout, and the query profile
  • Habit: look at the plan or profile first, change one thing, measure again
  • Setup: a 2-million-row table, a small dimension table and a skewed table (about 70% of rows share one key)
Python
import time
from pyspark.sql import functions as F

catalog = spark.sql("SELECT current_catalog()").first()[0]
spark.sql(f"USE SCHEMA {catalog}.dbx_course")

def timed(label, fn):
    t = time.time(); out = fn(); print(f"{label}: {time.time() - t:.1f}s"); return out

(spark.range(0, 2_000_000).select(
    F.col("id").alias("order_id"),
    (F.pmod(F.hash("id", F.lit(1)), F.lit(1000)) + 1).alias("customer_id"),
    (F.pmod(F.hash("id", F.lit(2)), F.lit(30)) + 1).alias("product_id"),
    (F.pmod(F.hash("id", F.lit(3)), F.lit(5)) + 1).alias("quantity"),
    F.timestamp_seconds(F.lit(1735689600) + F.col("id") * 15).alias("order_ts"))
 .write.mode("overwrite").saveAsTable("perf_orders"))

(spark.range(0, 1000).select(
    (F.col("id") + 1).alias("customer_id"),
    F.concat(F.lit("Customer "), F.col("id") + 1).alias("name"),
    F.element_at(F.array(F.lit("NL"), F.lit("DE"), F.lit("FR"), F.lit("US")),
                 (F.pmod(F.col("id"), F.lit(4)) + 1)).alias("country"))
 .write.mode("overwrite").saveAsTable("perf_customers"))

(spark.range(0, 2_000_000).select(
    F.col("id").alias("order_id"),
    F.when(F.pmod(F.hash("id", F.lit(5)), F.lit(10)) < 7, F.lit(1))
     .otherwise(F.pmod(F.hash("id", F.lit(6)), F.lit(1000)) + 1).alias("customer_id"),
    F.lit(1).alias("quantity"))
 .write.mode("overwrite").saveAsTable("perf_skewed"))
12.01Concept

Spark Query Execution

By the end: trace how Spark runs a query from code to tasks.

CodeDataFrame or SQLPlanoptimizedJobs and stagesTasksone per partition
Nothing runs until an action. Then Spark plans, splits at shuffles, and runs tasks in parallel.
  • Transformations (filter, select, join) are lazy: they build a plan
  • Actions (count, write, display, collect) trigger work
  • A shuffle (join, groupBy, repartition) splits a job into stages
  • A stage = many tasks, one per partition, run in parallel
  • Slow queries are usually: too much data read, a big shuffle, one slow (skewed) task, or too many tiny files

Remember

Performance work = read less, shuffle less, balance the work.

12.02Hands-on on Free Edition

Logical and Physical Plans

By the end: read a plan with explain.

Python
q = (spark.table("perf_orders")
        .filter("customer_id = 42")
        .groupBy("product_id").agg(F.sum("quantity").alias("qty")))
q.explain("formatted")
Parsed logical planwhat you wroteOptimized logical planafter rulesPhysical planhow it will run
  • Read the physical plan bottom to top: scan first, result last
  • Words to look for:
In the plan Means
Scan ... PushedFilters The filter is applied at the source
Exchange A shuffle
BroadcastHashJoin / SortMergeJoin Join strategy
HashAggregate Aggregation (partial and final)
AdaptiveSparkPlan AQE is on
Photon... Runs in Photon
  • explain() shows the physical plan. "extended" and "formatted" show more
  • SQL EXPLAIN doesn't run the query. After running, AQE may have changed the real plan
12.03Concept

Catalyst Optimizer

By the end: know what the Catalyst optimizer does for you.

Unresolved planAnalysisnames and typesLogical rulesrewritePhysical choicescosts
Catalyst applies rules before any data is read.
Rule Effect
Predicate pushdown Filter moves next to the scan
Column pruning Only needed columns are read
Constant folding 1 + 2 becomes 3 once
Join reordering, strategy Cheaper join first, broadcast if small
Combining projects/filters Fewer passes
  • You get these for free with built-in functions and DataFrames
  • A Python UDF is a black box: no pushdown through it, extra serialization (Module 3)
  • Good statistics (Delta stats, ANALYZE) make better choices
  • Write the clear query. Check the plan only if it's slow
12.04Hands-on on Free Edition

Adaptive Query Execution (AQE)

By the end: see Adaptive Query Execution at work.

Python
j = (spark.table("perf_orders").join(spark.table("perf_customers"), "customer_id")
          .groupBy("country").agg(F.count("*").alias("orders")))
j.collect()
j.explain()          # after running: look at isFinalPlan=true
AQE does at runtime Sign in the plan
Switches to a broadcast join if one side turns out small Join node differs from the initial plan
Coalesces many small shuffle partitions AQEShuffleRead / CustomShuffleReader with Coalesced
Splits skewed partitions in joins SortMergeJoin with isSkew=true
Skips work on empty inputs Empty LocalTableScan
  • On by default
  • Defaults: broadcast threshold 30 MB, advisory partition size 64 MB, skew factor 5 and 256 MB
  • On serverless spark.sql.shuffle.partitions is auto
  • You can't tune most spark.sql.adaptive.* settings on serverless: it's managed for you

Trust AQE first

Don't hand-tune partitions before checking what AQE already did.

12.05Concept

Photon Engine

By the end: understand what Photon accelerates and how to confirm it ran.

  • Photon = Databricks' native, vectorized engine, on by default for serverless compute and SQL warehouses
  • Speeds up scans, filters, joins, aggregations, sorts, window functions and writes to Delta/Parquet
  • No code change. Unsupported operations fall back to Spark. Results are the same
Not accelerated
Python UDFs, RDD and Dataset APIs Run in Spark
Stateful streaming Runs in Spark
Very short queries (< 2 s) Overhead dominates
  • Check it: in the query profile, Photon operators are purple and others grey. Execution details shows the share of time in Photon
  • Fewer Python UDFs = more of the plan in Photon

Built-ins over UDFs

It's the one change that helps Catalyst, Photon and the profile at the same time.

12.06Hands-on on Free Edition

Predicate Pushdown and Partition Pruning

By the end: prune data with filter pushdown and partition pruning.

Python
(spark.table("perf_orders").withColumn("order_date", F.to_date("order_ts"))
      .write.mode("overwrite").partitionBy("order_date").saveAsTable("perf_orders_by_date"))

f = spark.table("perf_orders_by_date").filter("order_date = '2025-03-01'")
f.explain("formatted")         # look for PartitionFilters / PushedFilters in the scan
print(f.count())
Technique How it prunes
Predicate pushdown The scan applies the filter using file statistics
Partition pruning Whole folders are skipped (PartitionFilters)
Column pruning Unused columns aren't read
Dynamic pruning A join with a filtered small table prunes the big table at runtime
  • Filter on the raw column. WHERE to_date(order_ts) = ... can block pruning; WHERE order_ts >= ... AND order_ts < ... is safe
  • select only needed columns before heavy steps
  • Hive-style partitioning suits very large tables with a low-cardinality column. For new tables prefer liquid clustering (topic 14)

Over-partitioning

Partitioning by a high-cardinality column (user id) creates millions of tiny files. Partition columns need few values and lots of data each.

12.07Hands-on on Free Edition

Data Skipping

By the end: let Delta statistics skip files, and make them useful.

Python
(spark.table("perf_orders").orderBy("order_ts")
      .write.mode("overwrite").saveAsTable("perf_orders_sorted"))

timed("selective filter", lambda: spark.table("perf_orders_sorted")
      .filter("order_ts BETWEEN '2025-03-01' AND '2025-03-02'").count())
  • Delta stores min / max / null count per file for columns
  • A filter on a column whose ranges are sorted or clustered skips most files
  • Data order matters: ordered by time, a time filter skips well. Random order, no skipping
SQL
%sql
ALTER TABLE perf_orders_sorted SET TBLPROPERTIES ('delta.dataSkippingStatsColumns' = 'order_ts,customer_id')
Option Use
delta.dataSkippingNumIndexedCols Stats on the first N columns (default 32)
delta.dataSkippingStatsColumns Stats on named columns (overrides the number)
ANALYZE TABLE ... COMPUTE DELTA STATISTICS Recompute stats after a change
Predictive optimization Managed tables pick the columns for you
  • Long strings are truncated in stats: keep rarely filtered long text columns out
  • Check files pruned in the query profile (topic 15)
  • Liquid clustering is the reliable way to keep data clustered (topic 14)
12.08Hands-on on Free Edition

Broadcast vs Shuffle Joins

By the end: choose between broadcast and shuffle joins.

Broadcast hash joinsmall side copied to every taskno shuffle of the big sideSort-merge joinboth sides shuffled and sortedfor two large tables
Python
from pyspark.sql.functions import broadcast

big, small = spark.table("perf_orders"), spark.table("perf_customers")

b = big.join(broadcast(small), "customer_id")       # hint: broadcast the small side
s = big.join(small.hint("merge"), "customer_id")    # force sort-merge, to compare
b.explain(); s.explain()
Hint Strategy
broadcast Broadcast hash join
merge Sort-merge join
shuffle_hash Shuffled hash join
shuffle_replicate_nl Nested loop (cartesian-like)
  • Spark broadcasts automatically when a side is under the threshold (AQE: 30 MB)
  • Broadcast only genuinely small tables. Too big = out of memory
  • In the plan: BroadcastHashJoin with BroadcastExchange vs SortMergeJoin with two Exchange
  • Join on the same type and null-safe keys. Dedupe the small side first (Module 3)
12.09Hands-on on Free Edition

Shuffle Optimization

By the end: reduce the cost of shuffles.

  • Every Exchange writes data to disk and moves it across the network
  • Shuffle triggers: join, groupBy, distinct, orderBy, repartition, window functions
Python
# less shuffled data: filter and aggregate before the join
daily = (spark.table("perf_orders").filter("quantity > 1")
              .groupBy("customer_id").agg(F.sum("quantity").alias("qty")))
result = daily.join(spark.table("perf_customers"), "customer_id")
Do Why
Filter and select first Fewer rows and columns to move
Aggregate before joining Smaller side to shuffle
Join on keys already partitioned the same Fewer exchanges (clustered tables help)
Avoid repeated distinct and orderBy Each one shuffles
approx_count_distinct when exact isn't needed Cheaper than exact
Leave shuffle.partitions on auto (serverless) AQE sizes the partitions
  • Too many shuffle partitions = tiny tasks; too few = huge tasks and spill. auto handles this
  • orderBy only at the end, only when output order matters
12.10Hands-on on Free Edition

Data Skew and Salting

By the end: spot data skew and fix it with salting.

Python
sk = spark.table("perf_skewed")
sk.groupBy("customer_id").count().orderBy(F.desc("count")).show(3)    # key 1 has ~70% of rows
Skewed keyone task gets 70% of the rowsthe others finish early, the stage waitsSalted keysplit the hot key into N partswork spread across tasks

Salting a skewed aggregation: two steps.

Python
N = 8
salted = (sk.withColumn("salt", F.pmod(F.hash("order_id"), F.lit(N)))
            .groupBy("customer_id", "salt").agg(F.count("*").alias("n"))     # partial, spread out
            .groupBy("customer_id").agg(F.sum("n").alias("n")))               # final, small

Salting a join (when AQE doesn't fix it): replicate the small side across salt values.

Python
small = spark.table("perf_customers")
small_salted = small.crossJoin(spark.range(N).select(F.col("id").cast("int").alias("salt")))
big_salted = sk.withColumn("salt", F.pmod(F.hash("order_id"), F.lit(N)))
joined = big_salted.join(small_salted, ["customer_id", "salt"])
  • Signs of skew: one task far slower than the median, one long bar in the profile
  • Try first: AQE skew handling (on), a filter that removes the hot key, a broadcast join
  • Salting costs complexity: use it when the others fail

Measure the key distribution

groupBy(key).count() before joining on it. Nulls count as a key too.

12.11Hands-on on Free Edition

Repartition vs Coalesce

By the end: change partition counts with repartition and coalesce.

Python
df = spark.table("perf_orders")
wide = df.repartition(16, "customer_id")      # full shuffle, even sizes, hash by key
narrow = df.coalesce(2)                       # merge partitions, no shuffle
repartition(n) coalesce(n)
Shuffle Yes No
Can increase partitions Yes No
Result sizes Even Can be uneven
Use Spread work, partition by a key before writing Reduce output files cheaply
  • repartition("key") groups equal keys: useful before a write that is read by key
  • Don't coalesce(1) big data: all work moves to one task
  • Don't repartition "just in case". Measure first
  • Serverless manages partitions: getNumPartitions() and the RDD API aren't available

Serverless

df.rdd is not supported on serverless. repartition and coalesce (DataFrame) work.

12.12Hands-on on Free Edition

Caching and Persistence

By the end: replace caching with something that works on serverless.

  • df.cache() / persist() raise an error on serverless
  • Delta tables get automatic disk caching of data on classic compute and SQL warehouses
  • SQL warehouses also keep a query result cache (an identical query returns instantly)
Need Use on serverless
Reuse an expensive intermediate Write it to a Delta table, read it back
Reuse inside one query A CTE or a temp view (recomputed per use, but optimized together)
Reuse across steps in a job A Delta table per stage
Python
stage = (spark.table("perf_orders").join(spark.table("perf_customers"), "customer_id")
              .groupBy("country", "product_id").agg(F.sum("quantity").alias("qty")))
stage.write.mode("overwrite").saveAsTable("perf_stage")        # the 'cache'

by_country = spark.table("perf_stage").groupBy("country").agg(F.sum("qty").alias("qty"))
by_product = spark.table("perf_stage").groupBy("product_id").agg(F.sum("qty").alias("qty"))
  • A written table is also inspectable, restartable and shareable
  • On classic compute, cache only data used several times, and unpersist() when done

Porting old code

Remove .cache(), .persist(), spark.catalog.cacheTable and df.rdd from notebooks you move to serverless.

12.13Hands-on on Free Edition

Small File Problem

By the end: find and fix the small file problem.

Python
(spark.table("perf_orders").repartition(100)
      .write.mode("overwrite").saveAsTable("perf_small_files"))

display(spark.sql("DESCRIBE DETAIL perf_small_files").select("numFiles", "sizeInBytes"))
SQL
%sql
OPTIMIZE perf_small_files
Python
display(spark.sql("DESCRIBE DETAIL perf_small_files").select("numFiles", "sizeInBytes"))   # far fewer files
  • Many tiny files = slow listing and many small tasks
  • Causes: too many partitions, frequent small appends, streaming micro-batches, over-partitioned tables
  • Fixes, in order of preference:
Fix Notes
Predictive optimization Managed tables are compacted for you
OPTIMIZE Manual compaction (Module 5.12)
Auto compaction, optimized writes Table properties that reduce small files on write
Fewer partitions / liquid clustering Fix the layout, not just the symptom
  • Never leave coalesce(1) or hundreds of partitions on regular writes
12.14Hands-on on Free Edition

Liquid Clustering vs Partitioning

By the end: cluster a table with liquid clustering instead of partitioning.

SQL
%sql
CREATE OR REPLACE TABLE perf_orders_lc CLUSTER BY (customer_id, order_ts) AS
SELECT * FROM perf_orders
SQL
%sql
SELECT count(*) FROM perf_orders_lc WHERE customer_id = 42     -- compare files pruned in the profile
Partitioning Liquid clustering
Layout Folder per value Clustered by keys, flexible
Change keys Rewrite the table ALTER TABLE ... CLUSTER BY
High-cardinality column Bad Fine
Skew in key Skewed folders Handled
Recommended Rare cases All new tables
  • Up to 4 clustering keys: choose columns used in filters
  • Data is clustered on OPTIMIZE. Predictive optimization does it for managed tables
  • CLUSTER BY AUTO lets Databricks choose keys (needs predictive optimization)
  • Not combined with partitioning or ZORDER

Start with liquid clustering

Only fall back to partitioning for a specific, measured reason (Module 5.13).

12.15Hands-on on Free Edition

Spark UI and Query Profiling

By the end: use the query profile to find the slow operator.

Open it: under a cell, See performance โ†’ pick a statement โ†’ See query profile. Or Query History โ†’ the query.

Run queryOpen profileTop operatorswhere time goesFix and rerun
Find the biggest cost first.
Look at To learn
Top operators Which step takes the time
Time spent, Rows, Memory peak Per-operator cost and size
Filter icon on scans % of files and data pruned
IO section Bytes read and written
Wall-clock breakdown Scheduling vs planning and pruning vs execution
Photon share How much ran in Photon
Verbose mode Hidden operators
  • Red flags: scan with little pruning, a join producing far more rows than its inputs, a shuffle with one huge partition, long scheduling time
  • Your own timing with timed() is a quick check; the profile says why
  • Queries served from the result cache have no profile

No Spark UI

Serverless doesn't have the Spark UI. The query profile replaces it. Classic compute still has Stages, SQL and Streaming tabs.

12.16Concept

Memory and Executor Tuning

By the end: know the memory and executor settings, and what serverless takes off your hands.

Classic computechoose node type, workers, autoscalingset Spark configsyou size itServerlessplatform sizes and scalesfew Spark settingsyou fix the query
  • Classic: executor memory splits into execution (shuffles, joins) and storage (cache)
  • Spill (data written to disk during a shuffle or sort) means memory is short: more memory per core, fewer partitions per task, or less data
  • Out-of-memory causes: collecting to the driver, a huge broadcast, a skewed partition, very wide rows
  • Serverless has few settable configs: spark.sql.shuffle.partitions, spark.sql.session.timeZone, spark.sql.ansi.enabled, spark.sql.files.maxPartitionBytes, timeouts
  • If a serverless query runs out of memory: reduce the data, fix skew, avoid collect(), or use a larger/high-memory option where offered

Fix the query before the cluster

Adding memory hides skew and bad joins. Cheaper to fix the cause.

12.17Concept

Serverless Performance Optimization

By the end: apply the serverless performance checklist.

Area Do
Performance mode Performance-optimized for interactive. Standard for scheduled jobs and pipelines that can wait a few minutes to start (lower cost)
Environments Pin an environment version and library versions. Package caching speeds restarts
Streaming availableNow with maxFilesPerTrigger/maxBytesPerTrigger so batches fit in memory
Layout Liquid clustering, predictive optimization, no tiny files
Code Built-ins, filter early, broadcast small tables, no collect() of big data
Caching Write intermediates to Delta tables
Debugging Query profile from See performance and Query History
  • Many serverless limits exist (Module 2): no RDDs, no caching, few Spark configs
  • Free Edition has a daily compute quota: a wasteful query uses it up. Use LIMIT while exploring

Remember

On serverless you don't tune the machine. You tune the data layout and the query.

Recap

What you can do now

  • Plans, Catalyst, AQE and Photon
  • Pruning: pushdown, partition pruning, data skipping, clustering
  • Joins, shuffles, skew, repartition and coalesce
  • Caching alternatives, small files, the query profile
  • What serverless changes about tuning