E-NO
Apache Spark performance 11 Min Read

Apache Spark performance tuning: practical guide with examples

calendar_today Published: 2026-08-07
update Last Updated: 2026-08-07
analytics SEO Efficiency: 97%
Technical guide illustration for Apache Spark performance tuning: practical guide with examples.

Intro

Apache Spark processes large datasets quickly when you guide it to use cluster resources well. This guide shows how to identify bottlenecks, size executors and partitions, tune joins and shuffles, and verify improvements with concrete checks in the Spark UI and logs. You will get a safe, incremental workflow with practical examples so you can tune latency-sensitive and throughput-oriented jobs without risky rewrites.

What you will learn:

  • How to inventory versions and environment topology so your tuning applies to your reality.
  • A safe, scoped path to adjust partitioning, join strategies, shuffles, memory, serialization, and resource sizing.
  • How to verify improvements using Spark UI, SQL EXPLAIN, event logs, and simple latency/throughput metrics.
  • Common failure modes (OOM, skew, spills, long tails) and how to roll back quickly.
  • A practical checklist to keep jobs healthy week-over-week.

Note on examples: all numbers in examples are constructed to illustrate approach, not to claim specific benchmarks.

Version and Environment Inventory

Before changing anything, fix the context. This prevents long detours caused by mismatched versions or hidden resource limits.

Inventory checklist:

  • Spark version and build: distribution (Apache, vendor), Scala/Python version.
  • JVM: version and GC mode (e.g., G1).
  • Cluster manager: standalone, YARN, or Mesos; dynamic allocation status.
  • Nodes and disks: core count, RAM, local disk type and bandwidth; network bandwidth.
  • Storage: HDFS, object store (e.g., S3-compatible), or local; default file formats and compression.
  • Observability: Spark History Server URL, event log directory, log level, metrics sink.

Quick commands:

spark-submit --version
pyspark --version
python -V
java -version
hdfs version

Within a Spark shell or notebook:

sc.version
spark.version
spark.sparkContext.getConf().getAll()
spark.sparkContext.master

Prerequisites:

  • Access to Spark UI and History Server (or event logs) for the job you will tune.
  • Authority to change per-job configuration (spark-submit --conf, SparkSession builder, or job config files).
  • A representative dataset slice you can run repeatedly (constructed example: 5% sample of a 1 TB table stored in Parquet).

Safe Configuration Path

Make one change at a time, measure, then decide. The following steps proceed from low-risk and high-signal to deeper changes.

Step 1: Establish a baseline

  • Choose a single representative job (constructed example: nightly ETL join of 200 GB Parquet + 5 GB dimension tables, write to HDFS).
  • Record:
  • Wall-clock duration and critical stage durations.
  • Peak and median task times; skew indicators (few long tasks).
  • Shuffle read/write sizes and spill events.
  • Executor peak memory and GC time.
  • Save initial config: collect spark-submit flags and SparkSession configs.

Tip: enable event logs if not already on.

--conf spark.eventLog.enabled=true \
--conf spark.eventLog.dir=hdfs:///spark-event-logs

Step 2: Right-size parallelism and partitions

Symptoms: long tails, many tasks with tiny input, or few giant partitions.

  • Set default shuffle partitions for SQL/DataFrame workloads:
--conf spark.sql.shuffle.partitions=384

Constructed example: 3x total executor cores (128 cores * 3 = 384).

  • For RDD or non-SQL workloads, set:
--conf spark.default.parallelism=256

Constructed example: 2x total cores.

  • Adjust input partitioning explicitly when sources have poor splits:
big = spark.read.parquet("data/fact
epartition(512)
dim = spark.read.parquet("data/dim
joined = big.join(dim, "key
  • Coalesce only when shrinking after wide operations to avoid excessive tiny files:
joined.coalesce(64).write.mode("overwrite
epartition("out/path
epartition

Expected results: more even task times across executors, reduced long tail on stages, and fewer output files if coalescing.

Step 3: Optimize joins (broadcast, AQE, and hints)

  • Enable Adaptive Query Execution (AQE) to coalesce shuffle partitions and pick better join strategies at runtime:
--conf spark.sql.adaptive.enabled=true \
--conf spark.sql.adaptive.coalescePartitions.enabled=true
  • Allow broadcast joins of small tables, but cap size to protect memory:
--conf spark.sql.autoBroadcastJoinThreshold=50MB

Constructed example threshold.

  • When you know a table is small and frequently reused, use a broadcast hint:
from pyspark.sql.functions import broadcast
joined = fact.join(broadcast(dim_small), "key
epartition
  • If skew exists (few keys dominate), consider salting on the large side (constructed example):
from pyspark.sql.functions import rand, floor, col
salt_buckets = 8
fact_salted = fact.withColumn("salt", floor(rand() * salt_buckets))
dim_salted = dim_small.withColumn("salt", col("key
epartition) % salt_buckets)
joined = fact_salted.join(dim_salted, ["key", "salt
epartition).drop("salt
epartition

Expected results: reduced shuffle data for small-dimension joins, fewer spilled tasks, more balanced stages under skew.

Step 4: Serialization and compression

  • Prefer Kryo for JVM object serialization in RDD-heavy jobs:
--conf spark.serializer=org.apache.spark.serializer.KryoSerializer
  • Data formats: use columnar formats and splittable compression for analytics:
  • Parquet or ORC with Snappy for balanced speed and size.
  • Consider ZSTD for higher compression with acceptable CPU on modern clusters.
  • Shuffle compression:
--conf spark.shuffle.compress=true \
--conf spark.shuffle.spill.compress=true \
--conf spark.io.compression.codec=lz4

Expected results: lower I/O and faster shuffles on I/O-bound jobs; watch CPU if switching to heavier codecs.

Step 5: Cache only what you reuse

Cache DataFrames used multiple times downstream; otherwise avoid caching.

important = compute_expensive(df).persist()
# ... multiple actions on `important`
important.unpersist()

Use MEMORY_AND_DISK to avoid OOM if the dataset does not fit fully in memory.

Step 6: Manage shuffle and disk I/O

  • Ensure external shuffle service is enabled when using dynamic allocation (cluster dependent).
  • Increase network and buffer sizes cautiously if seeing high shuffle wait:
--conf spark.reducer.maxReqsInFlight=64 \
--conf spark.shuffle.file.buffer=64k \
--conf spark.shuffle.io.maxRetries=8 \
--conf spark.shuffle.io.retryWait=5s
  • If spills dominate, confirm executors have sufficient memory and that partitions are not oversized.

Step 7: JVM memory, GC, and executor sizing

  • Aim for 2-5 cores per executor to reduce GC pauses and improve parallelism across nodes (constructed guideline).
  • Balance memory:
--executor-cores 4 \
--executor-memory 8G \
--conf spark.executor.memoryOverhead=1536
  • Prefer G1GC for large heaps (JDK 11+). Set via JVM options if needed:
--conf spark.executor.extraJavaOptions="-XX:+UseG1GC"
  • Enable dynamic allocation for mixed workloads with a cap to prevent noisy neighbors:
--conf spark.dynamicAllocation.enabled=true \
--conf spark.dynamicAllocation.minExecutors=8 \
--conf spark.dynamicAllocation.maxExecutors=128

Expected results: fewer long GC pauses, more stable task times, and right-sized parallelism under load.

Step 8: Output tuning and small files

  • Consolidate output files to match downstream read parallelism; avoid thousands of tiny files:
result.coalesce(128).write.mode("overwrite
epartition("out
epartition
  • For partitioned tables, ensure partition columns are low to medium cardinality and sized for typical queries.

Summary table: common Spark tuning levers

LeverPurposeWhen to useRisk if misused
spark.sql.shuffle.partitionsControl shuffle parallelismLong tails or tiny tasksToo large -> overhead; too small -> skew
AQE (spark.sql.adaptive.enabled)Runtime partition coalesce and join choiceMixed or uncertain data sizesRare regressions on edge plans
Auto broadcast thresholdFast small-table joinsDim table < thresholdOOM if table unexpectedly grows
Kryo serializerFaster object serializationRDD-heavy jobsRequires class registration in some cases
dynamic allocationElastic executorsShared clustersThrash if min/max poorly set
coalesce/repartitionMerge or spread partitionsFix small files or skewWrong setting harms parallelism

Verification and Diagnostics

Tuning without measurement is guessing. Validate each change with consistent signals.

Use Spark UI and History Server

  • Jobs and stages: target the top 2-3 longest stages by duration and shuffle.
  • Tasks: look at the distribution of task durations. A few stragglers indicate skew or remote I/O bottlenecks.
  • Storage tab: confirm cached datasets and memory usage.
  • Environment tab: confirm that your new configs are active.

Latency and throughput checks

  • Latency: wall-clock duration of the critical path (driver start to last stage completion), plus the p95 stage duration.
  • Throughput: records per second or MB/s at key stages; I/O read/write throughput.
  • Stability: GC time as a percentage of executor time and retry counts.

Constructed example (before vs after AQE + partitioning):

  • Before: job 62 min, Stage 4 at 23 min with 3 stragglers running 6 min.
  • After: job 41 min, Stage 4 at 11 min, task durations within 10% of median, zero spills.

SQL EXPLAIN and metrics

Run detailed plans to spot broadcasts and exchanges:

df.explain(True)
# or in SQL
spark.sql("EXPLAIN FORMATTED SELECT ...
epartition).show(truncate=False)

Expected: fewer Exchange nodes after AQE; BroadcastHashJoin for small tables; reduced number of shuffle partitions.

Event logs and GC

  • Confirm spill events decrease run-over-run when increasing partitions or memory.
  • Look for long GC pauses; aim for GC time < 5% of task time in steady state (constructed guideline).
  • If you change compression, monitor CPU time vs I/O wait in executor metrics (CPU-bound vs I/O-bound shift).

Verification table: signals and meaning

SignalLikely bottleneckNext action
Few tasks much slower than othersSkew, hotspot keysSalt keys, increase partitions, broadcast dim
High shuffle spill to diskMemory pressure or large partitionsAdd memory, reduce partition size, enable AQE
High GC timeLarge heaps or too many objectsFewer cores per executor, G1GC, Kryo
Many tiny output filesOver-partitioned writescoalesce() before write
Low CPU, high I/O waitStorage or network boundSwitch codec, increase partitions, cache hot dims

Failure Modes and Recovery

Out of memory (executor or driver)

  • Symptoms: ExecutorLostFailure, OutOfMemoryError, or task killed during broadcast.
  • Quick fixes:
  • Reduce auto-broadcast threshold or remove broadcast hints.
  • Increase spark.executor.memoryOverhead for heavy shuffles.
  • Switch cache to MEMORY_AND_DISK or unpersist.
  • Recovery: revert to prior config snapshot; rerun baseline to confirm stability.

Skew and long tails

  • Symptoms: few tasks run much longer; shuffle reads heavily imbalanced.
  • Fixes:
  • Increase shuffle partitions moderately (e.g., +25%).
  • Salt keys on large side; pre-aggregate on skewed side.
  • Broadcast small side to avoid shuffling it.
  • Recovery: if performance degrades, remove salting and restore prior partitions.

Shuffle file not found / excessive retries

  • Symptoms: fetch failures, repeated task retries.
  • Fixes:
  • Raise spark.shuffle.io.maxRetries and retryWait slightly.
  • Ensure external shuffle service health; check local disk pressure.
  • Reduce partition size to limit per-task shuffle volume.
  • Recovery: roll back retry settings and partition changes to last-known-good.

GC thrashing

  • Symptoms: GC time spikes, low task throughput, frequent full GCs.
  • Fixes:
  • Fewer cores per executor; smaller heaps with more executors.
  • G1GC and tuning pause targets if needed.
  • Kryo serializer for object-heavy code; avoid large in-memory maps.
  • Recovery: restore previous executor sizing and serializer settings.

Driver-side bottlenecks

  • Symptoms: long job start, slow collect/show, broadcast build pauses.
  • Fixes:
  • Avoid driver collect on large datasets; write to storage instead.
  • Increase driver memory if unavoidable.
  • Recovery: revert driver memory and remove driver-side actions.

Operations Checklist

Daily or per-change run:

  • Capture current config from Spark UI Environment tab; store a dated snapshot.
  • Run the representative job on a fixed dataset slice; record duration, p95 stage time, shuffle read/write, spill counts.
  • Inspect task time distribution; confirm lack of stragglers.
  • Confirm AQE status and number of effective shuffle partitions in the plan.
  • Check executor GC time percent and memory peak.

Weekly hygiene:

  • Review output file counts per partitioned table and coalesce targets.
  • Reassess auto broadcast thresholds vs current dim table sizes.
  • Validate that dynamic allocation min/max bounds still match cluster capacity.
  • Prune stale caches in long-running Spark applications.

Change management:

  • Make one change per run; annotate run notes with the single change.
  • If worse, roll back immediately using the saved config snapshot.
  • Keep a small library of known-good profiles (e.g., high-throughput ETL profile vs low-latency ad-hoc profile).

Quick-start example profiles (constructed)

Low-latency, moderate data volume:

--conf spark.sql.adaptive.enabled=true \
--conf spark.sql.shuffle.partitions=200 \
--conf spark.sql.autoBroadcastJoinThreshold=80MB \
--executor-cores 3 --executor-memory 6G \
--conf spark.executor.memoryOverhead=1024 \
--conf spark.serializer=org.apache.spark.serializer.KryoSerializer

High-throughput, large shuffles:

--conf spark.sql.adaptive.enabled=true \
--conf spark.sql.shuffle.partitions=800 \
--conf spark.shuffle.compress=true \
--conf spark.io.compression.codec=lz4 \
--executor-cores 4 --executor-memory 10G \
--conf spark.executor.memoryOverhead=2048 \
--conf spark.reducer.maxReqsInFlight=64

Conclusion

You now have a safe, incremental workflow for tuning Spark:

  1. Establish a baseline with a single representative job.
  2. Right-size partitions.
  3. Use AQE and selective broadcasts to optimize joins.
  4. Tune serialization and compression for your I/O profile.
  5. Size executors to control GC and parallelism.
  6. Verify each change with Spark UI, EXPLAIN, and event logs.
  7. Keep rollbacks simple by changing one lever at a time.

Next steps:

  • Package a base profile for your most critical job and verify on a fixed dataset slice.
  • Expand to adjacent jobs with similar characteristics, adjusting only where measurements justify it.
  • Automate the capture of Spark UI metrics and key counters so you can detect regressions before users do.

With disciplined measurement and small, reversible steps, you can reduce latency, increase throughput, and keep your Spark jobs predictable as data and traffic grow.

Related Research

Article Quality Score

Reader usefulness 97%
  • check_circle Reader-ready guide
  • check_circle Practical examples included
  • check_circle Clean SEO article URL