Performance Tuning & FinOps
Turning Spark knowledge into runtime and dollars - the module that maps directly to production wins.
The methodology: measure before you touch anything
Every credible tuning answer starts the same way: you measure first. Random knob-turning is the number-one anti-pattern interviewers probe for. Your working order:
- Measure. For notebooks and jobs, open the Spark UI: find the longest stage, then check shuffle read/write volumes, spill (memory and disk), and task-duration skew (compare median vs. max task time in the stage summary). For Databricks SQL, use the Query Profile - it shows time per operator, rows produced, files pruned vs. read, and whether the query hit a cache.
- Fix data layout. File sizes, clustering, pruning, statistics. Layout fixes help every downstream query, not just the one you're staring at.
- Fix code. Join strategy, eliminating UDFs, removing unnecessary wide transformations, pushing filters early.
- Fix compute last. Resizing a cluster to outrun a layout problem is renting your way out of an engineering problem - it works until the data doubles.
Memorize one diagnostic sentence per signal: spill means partitions don't fit in task memory; skew means a few tasks do most of the work; high shuffle volume means you're moving data you could have pruned or broadcast. If you can read those three off the Spark UI, you can structure any tuning conversation.
Compute: sizing, autoscaling, spot, Photon, serverless
Cluster sizing and instance families
Match the family to the bottleneck: memory-optimized for wide aggregations, large joins, and anything that spills; compute-optimized for CPU-bound transforms and many small tasks; storage-optimized (instances with local NVMe) when you lean on the disk cache for repeated reads; general-purpose when you don't yet know. Two sizing heuristics worth quoting: prefer fewer, larger nodes for shuffle-heavy work (less network traffic between executors), and size total cluster memory so your largest shuffle stage doesn't spill - spill to disk is often the single biggest silent slowdown.
Autoscaling - and when it hurts
Autoscaling fits ragged, interactive, or multi-job workloads where demand varies. It actively hurts when: (1) the workload is a steady batch job - you pay scale-up latency every run for no benefit, so a fixed right-sized cluster is cheaper and more predictable; (2) the job is shuffle-heavy - removing a node can lose shuffle files and trigger recomputation; (3) it's a Structured Streaming job, where classic autoscaling reacts poorly to micro-batch load. For SQL warehouses, autoscaling adds whole clusters for concurrency rather than resizing one - that one is usually safe to leave on.
Spot instances
Spot/preemptible workers can cut compute cost 60-90 percent. Use them for retry-tolerant batch work: keep the driver (and ideally a small core of workers) on-demand, put the rest on spot with fallback to on-demand. Avoid spot for tight-SLA jobs and long-running streaming, where eviction mid-shuffle costs more in recomputation than it saves.
Photon: what it accelerates and when it pays
Photon is the C++ vectorized engine that replaces the JVM execution path for SQL and DataFrame operations: scans, filters, joins, aggregations, Delta writes and MERGE. It does not accelerate RDD code, Python UDFs, or most Scala UDFs - those fall back to the JVM row-at-a-time path. Photon carries a higher DBU rate, so the test is arithmetic: if it cuts runtime by more than the DBU multiplier increases cost (it often does on scan/join/aggregate-heavy SQL pipelines - 2-3x speedups are common), it is cheaper per job, not just faster. UDF-heavy or RDD-heavy code sees little speedup and pays the full premium. Check the Spark UI or Query Profile for how much of the plan actually ran "photonized" before deciding.
Serverless compute trade-offs
Serverless compute for notebooks, jobs, and pipelines is GA and is the strategic default Databricks is steering toward (serverless jobs and pipelines now default to a "Performance optimized" mode in the UI, with a cheaper standard mode available). What you gain: near-instant startup, no idle clusters burning DBUs, no sizing decisions, Databricks-managed Photon and scaling. What you give up: node-level control (no instance-family choice, no spot strategy, limited Spark conf), and a different cost shape - you pay a premium per DBU in exchange for paying only for execution. Rule of thumb as of mid-2026: serverless wins for spiky, short, or interactive workloads and for teams without tuning bandwidth; a well-tuned classic cluster on spot can still win for long, steady, predictable batch. Run the comparison with your own system-table billing data rather than asserting either way.
At ADM you run the FinOps side of Finance R2R delivery on Azure Databricks: cluster right-sizing, autoscaling policies, and Delta layout work - partitioning, compaction, and Z-ORDER - on the medallion tables feeding P&L and Budget-vs-Actual dashboards for Executive Committee and CFO-level consumers. When you tell this story, structure it in the measure-first order this module teaches: start from observed utilization and job metrics, fix layout before compute, and encode the outcome as policies so improvements persist. The right-sizing, autoscaling, and Delta tuning are genuinely your production work - the sequencing is how you make that work legible to an interviewer.
Data layout: the small-file problem and file-size management
Thousands of small files mean slow listings, bloated transaction-log state, and tasks that spend more time on overhead than on data. Classic causes: streaming or frequent small batch appends, over-partitioning, and high-cardinality partition keys. Target file sizes in the hundreds of MB to ~1 GB. The toolkit:
OPTIMIZE- compacts small files into larger ones; the scheduled, explicit fix.- Optimized writes and auto compaction - write-time features that produce fewer, larger files as data lands, reducing how often you need OPTIMIZE at all.
- Predictive optimization - Databricks runs OPTIMIZE, VACUUM, and ANALYZE automatically on Unity Catalog managed tables based on observed access patterns. It's enabled by default for accounts created on or after November 11, 2024 and has been rolling out broadly - so for UC managed tables, the modern answer is increasingly "let the platform handle maintenance, and verify via system tables that it's happening."
OPTIMIZE finance.gold.gl_actuals; -- compact small files
-- New tables (current guidance): automatic liquid clustering
CREATE TABLE finance.gold.gl_actuals (...)
CLUSTER BY AUTO;
-- Existing table: adopt liquid clustering (drops partition/Z-ORDER strategy)
ALTER TABLE finance.silver.journal_lines CLUSTER BY (company_code, fiscal_period);
Partitioning vs. liquid clustering: the decision framework
As of mid-2026 the official recommendation is liquid clustering for all new Delta tables, preferably automatic via CLUSTER BY AUTO with key selection powered by predictive optimization. Hive-style partitioning plus Z-ORDER is legacy guidance - and the two are mutually exclusive: a table is either partitioned/Z-ORDERed or liquid-clustered, never both.
| Dimension | Partitioning + Z-ORDER (legacy) | Liquid clustering (current default) |
|---|---|---|
| Key cardinality | Low-cardinality partition keys only; high cardinality explodes directories and small files | Handles high-cardinality keys; no directory explosion |
| Changing access patterns | Repartitioning = full table rewrite; Z-ORDER rewrites on every run | ALTER TABLE ... CLUSTER BY changes keys without rewriting existing data; incremental clustering |
| Skewed key distribution | Skewed partitions stay skewed | Clustering balances file sizes regardless of key skew |
| Operational load | You schedule and pay for Z-ORDER runs | With CLUSTER BY AUTO + predictive optimization, largely hands-off |
| When it still makes sense | Existing stable tables already tuned this way; hard physical-isolation needs (e.g., partition-boundary deletes/retention) | All new tables; migrate existing tables opportunistically when access patterns shift |
Pitfall: over-partitioning is still the most common layout mistake in the wild. Partitioning by date and region and category on a mid-sized table creates thousands of tiny partitions, each full of tiny files - the small-file problem with a directory structure on top. Anything below roughly 1 TB rarely needed partitioning even under the old guidance. In an interview, knowing when not to partition signals more seniority than reciting partitioning syntax. And never describe partitioning + Z-ORDER as your recommendation for a new table - say liquid clustering, and mention your production Z-ORDER experience as exactly that: tuning existing tables.
Caching: three layers, three different things
| Layer | What it caches | Scope and invalidation | Use when |
|---|---|---|---|
Spark cache (df.cache() / persist()) | Materialized DataFrame in executor memory/disk | Per-application; manual - goes stale if the underlying table changes; evicted under memory pressure | One DataFrame reused several times within one job (e.g., iterative logic); always unpersist() after |
| Disk cache (a.k.a. Delta cache) | Parquet data files on workers' local NVMe | Per-cluster; automatic and consistency-aware (detects file changes) | Repeated reads of the same tables on the same cluster; enabled by default on storage-optimized instances |
| DBSQL result cache | Final result sets of queries on SQL warehouses | Per-warehouse (plus a remote layer on serverless); invalidated when underlying data changes | Dashboards where many users re-run identical queries - the BI workhorse |
Pitfall: sprinkling .cache() over a pipeline as a performance reflex. Caching a DataFrame used once costs memory (often causing the very spill you're debugging) and adds a materialization step. The disk cache already covers repeated reads transparently - reach for .cache() only when you can name the specific re-use it serves.
Joins and skew: the recap
The deep treatment lives in Module 01 - Apache Spark Internals & Troubleshooting; for this module you need the decision summary. Broadcast the small side of a join to eliminate the shuffle entirely (check the threshold; hint explicitly when statistics mislead the planner). Let AQE do its job - it coalesces post-shuffle partitions, converts sort-merge joins to broadcast at runtime, and splits skewed partitions via skewed-join handling. When AQE isn't enough - one customer or one ledger account dominating a key - salt the hot key manually. Diagnose skew in the Spark UI as a handful of straggler tasks running far longer than the stage median.
Writing patterns that keep Photon and the optimizer in play
- Never
collect()large results to the driver - it's the canonical driver-OOM cause. Write results to a table; usedisplay()/limit()to inspect. - Prefer built-in functions over UDFs. Built-ins are visible to Catalyst and run in Photon; a Python UDF is an opaque black box that forces row-by-row serialization to a Python worker and disables Photon for that operator.
- If you need custom Python, use pandas UDFs - Arrow-based, vectorized batches instead of row-at-a-time, typically an order of magnitude faster than plain Python UDFs. Still slower than built-ins; treat as the fallback, not the default.
# Bad: opaque UDF - no Catalyst, no Photon
@udf("double")
def margin(rev, cost):
return (rev - cost) / rev if rev else None
# Good: built-in expressions - optimizable, Photon-eligible
df = df.withColumn(
"margin",
F.when(F.col("rev") != 0, (F.col("rev") - F.col("cost")) / F.col("rev"))
)
Symptom → likely cause → fix
| Symptom | Likely cause | Fix |
|---|---|---|
| One stage dominated by long shuffle read/write | Wide join/aggregation moving too much data; no pruning; small side not broadcast | Filter and project before the shuffle; broadcast the small side; cluster/layout the table so file pruning works; verify AQE coalescing is on |
| Spill (memory and disk) in stage metrics | Shuffle partitions too large for task memory; under-provisioned memory per core | Let AQE size partitions; raise spark.sql.shuffle.partitions if static; move to memory-optimized instances; reduce data per task before resizing the cluster |
| Driver OOM | collect() / toPandas() on big data; huge task-result accumulation; thousands of tiny tasks overwhelming scheduling state | Remove driver-side collection; write to tables instead; compact small files to cut task counts; bigger driver only as last resort |
| Stragglers - a few tasks run 10-100x longer than the median | Data skew on the join/aggregation key (one hot customer, account, or null bucket) | Confirm via task-duration distribution; enable AQE skew handling; salt the hot key; handle null keys separately |
Slow MERGE | MERGE rewrites every file containing a potential match: no pruning on the target, small files, or an unclustered merge key | Add target-side predicates (e.g., date range) to the ON clause; compact the target; liquid-cluster on the merge key; enable deletion vectors so updates/deletes avoid full file rewrites |
"A nightly job that used to run in 40 minutes now takes 3 hours and costs 4x. Walk me through what you'd do."
Strong outline: (1) What changed? Compare Spark UI / job metrics against a good run - data volume growth, new code, cluster change, more concurrent workloads. (2) Localize: find the dominant stage; classify it as shuffle volume, spill, or skew. (3) Layout first: check small files and pruning - is OPTIMIZE/predictive optimization actually running? Did a key's cardinality drift? (4) Code: any new UDFs, a join that lost its broadcast because the small side grew past the threshold, a MERGE without target pruning. (5) Compute last: right-size or consider Photon/serverless with a cost-per-run comparison from system tables. Close with prevention: alert on runtime and cost regressions so it never gets to 3 hours unnoticed. The order is the answer.
Cost observability: you can't optimize what you can't attribute
On the Data Intelligence Platform, cost data is just more Delta tables - which means you can do FinOps with the same SQL skills you use for finance data:
- System tables for billing:
system.billing.usagerecords DBU consumption per workspace, SKU, and workload, with identity and tag metadata; join tosystem.billing.list_pricesto express it in dollars. Related system schemas cover compute (node types, cluster timelines) and query history for DBSQL. - Cluster policies: the enforcement layer - constrain instance families, max workers, auto-termination, spot usage, and require tags at creation time. A policy is an opinion about cost that users can't forget to follow.
- Tags: custom tags on clusters, jobs, and SQL warehouses propagate into billing data (and to cloud-provider billing for classic compute). No tags, no attribution, no accountability.
- Budgets and alerts: account-level budgets watch spend against tag- or workspace-scoped filters and notify on threshold breaches - your early-warning system, alongside alerts on the usage tables themselves.
-- Last 30 days of cost by team tag and SKU
SELECT u.custom_tags['team'] AS team,
u.sku_name,
SUM(u.usage_quantity * p.pricing.default) AS est_dollars
FROM system.billing.usage u
JOIN system.billing.list_prices p
ON u.sku_name = p.sku_name
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
GROUP BY ALL
ORDER BY est_dollars DESC;
The FinOps operating model: visibility → accountability → optimization
Frame cost work as an operating loop, not a one-off cleanup. Visibility: system tables, enforced tagging, and an AI/BI dashboard showing spend by team, job, and SKU - cost becomes a metric people see weekly. Accountability: attribute every dollar to an owner via tags, set budgets per team, and let cluster policies make the efficient path the default path. Optimization: only now do the technical work - right-sizing, autoscaling and spot policy, Photon and serverless evaluations, layout and compaction - prioritized by the dashboard, and measured against it afterward so wins are provable. Then the loop repeats, because workloads drift. Skipping straight to optimization without visibility is how teams "save" money on clusters nobody was using anyway while the real burn continues untouched.
Your biggest cost number lives at Maersk: re-architecting Fact-Based Reporting's consumption layer - SSAS multidimensional cubes migrated to Azure Analysis Services Tabular, then a Dremio + Power BI semantic-layer lakehouse over Delta on ADLS Gen2, plus moving managed tables to external tables for governance and cost control - delivering a $10M annual infrastructure cost reduction. That figure comes from your resume: verify how it was calculated (and over what baseline) before quoting it externally - in the meantime, "eight figures annually" or "a multi-million-dollar annual reduction" is a safe phrasing. The architectural story needs no hedging: you removed an expensive serving tier by letting the semantic layer query the lakehouse directly - eliminating a copy of the data and the infrastructure that hosted it, which is FinOps at the architecture level rather than the cluster level.
"How would you stand up cost governance for Databricks across an organization?"
Strong outline: (1) Visibility - enable and query billing system tables, build a spend dashboard by workspace/team/job, dollarize with list prices. (2) Accountability - mandatory tag policy enforced through cluster policies, budgets with alerts per team, monthly cost review with owners in the room. (3) Optimization - a prioritized backlog from the dashboard: kill idle/oversized clusters, fix autoscaling and auto-termination defaults, spot for retry-tolerant batch, Photon/serverless where the math works, then data-layout work (compaction, liquid clustering, predictive optimization) for the biggest tables. Anchor it in experience: you ran this loop at ADM - right-sizing, autoscaling policies, and Delta tuning - and you've delivered architecture-level cost reduction at Maersk. Name the loop explicitly: visibility → accountability → optimization, repeated.
One-line summary to carry into interviews: "Measure with the Spark UI and Query Profile, fix layout before code, code before compute, and make cost a first-class metric with system tables, tags, and policies." Every question in this domain is some subset of that sentence.