100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Programming

Spark Best Practices

Practical guidelines for writing efficient, reliable, and maintainable Apache Spark jobs in production.

PracticeIntermediate9 min readJul 10, 2026
Analogies

Writing Efficient Spark Jobs

Writing efficient Spark jobs starts with choosing the DataFrame or Dataset API over raw RDDs whenever possible, since the Catalyst optimizer can rewrite DataFrame queries into more efficient physical plans, while RDD transformations execute exactly as written with no such optimization. Minimizing wide transformations that trigger shuffles - such as groupByKey, repartition, or joins on unfiltered data - keeps network I/O low and jobs fast.

🏏

Cricket analogy: A captain who lets the DRS system (Catalyst) suggest the optimal field placement outperforms one who sets fields purely from instinct, just as letting Spark's optimizer rewrite a DataFrame query beats manually coded RDD logic in a low-scoring T20 chase.

Caching and Persistence Strategy

Caching should be applied deliberately: call .cache() or .persist() only on DataFrames that will be reused across multiple actions, such as a filtered dataset queried by three downstream aggregations, and always call .unpersist() once the data is no longer needed to free executor memory. Choosing the right storage level - MEMORY_ONLY for datasets that comfortably fit in RAM versus MEMORY_AND_DISK_SER for larger ones - prevents costly recomputation without exhausting cluster memory.

🏏

Cricket analogy: A team keeps a set batsman like Virat Kohli at the crease (caching him in the lineup) only as long as he's actively scoring across multiple overs, then rotates him out for a declaration, just as data is unpersisted once no longer reused.

python
from pyspark.sql import SparkSession
from pyspark import StorageLevel

spark = SparkSession.builder.appName("BestPractices").getOrCreate()

orders = spark.read.parquet("s3://data/orders/")
filtered = orders.filter(orders.status == "COMPLETED")

# Reused across 3 downstream aggregations -> worth caching
filtered.persist(StorageLevel.MEMORY_AND_DISK_SER)

by_region = filtered.groupBy("region").sum("amount")
by_product = filtered.groupBy("product_id").count()
by_month = filtered.groupBy("order_month").avg("amount")

by_region.write.mode("overwrite").parquet("s3://out/by_region/")
by_product.write.mode("overwrite").parquet("s3://out/by_product/")
by_month.write.mode("overwrite").parquet("s3://out/by_month/")

filtered.unpersist()  # free executor memory once done

Avoiding Skew and Shuffle Overhead

Data skew - where one partition holds far more rows than others, often from a popular join key like a single retailer ID - causes a handful of tasks to run far longer than the rest and stalls the whole stage. Techniques like salting the skewed key with a random suffix, broadcasting small dimension tables under spark.sql.autoBroadcastJoinThreshold, and enabling Adaptive Query Execution (AQE) to dynamically split skewed partitions at runtime all reduce this straggler effect.

🏏

Cricket analogy: When one bowler like Jasprit Bumrah is overused for 10 overs while others bowl only 2, the attack becomes lopsided and tired; skewed Spark partitions similarly overload one task while others idle, hurting overall throughput.

Set spark.sql.adaptive.enabled=true and spark.sql.adaptive.skewJoin.enabled=true (default on in Spark 3.x+) so AQE automatically splits detected skewed partitions at runtime instead of requiring manual salting for every job.

Monitoring and Resource Configuration

Right-sizing executors matters as much as tuning code: too few cores per executor underutilizes parallelism, while too many can cause excessive garbage collection pauses and contention for shared memory, so a common starting point is 4-5 cores and 16-32GB per executor, adjusted after inspecting the Spark UI's stage and task timelines. Enabling dynamic allocation lets the cluster scale executors up during a heavy shuffle stage and scale back down during light stages, avoiding both resource starvation and idle billing.

🏏

Cricket analogy: A T20 franchise that fields too many all-rounders and too few specialist death bowlers struggles in crunch overs, just as an executor with too many cores but too little memory struggles under garbage-collection pressure at peak load.

Calling .collect() on a large DataFrame pulls the entire result set into the driver's JVM heap; on anything beyond a few hundred MB this reliably causes a driver OutOfMemoryError. Use .take(n), .write(), or aggregate first.

  • Prefer the DataFrame/Dataset API over RDDs so Catalyst can optimize the physical plan.
  • Cache only DataFrames reused across multiple actions, and always unpersist when done.
  • Choose a storage level (MEMORY_ONLY vs MEMORY_AND_DISK_SER) based on whether the dataset fits comfortably in RAM.
  • Handle data skew with salting, broadcast joins, and Adaptive Query Execution.
  • Size executors around 4-5 cores and 16-32GB, verified against the Spark UI's task timelines.
  • Enable dynamic allocation to scale executors with workload instead of running a fixed cluster size.
  • Never call .collect() on large DataFrames; prefer .take(), aggregation, or writing to a sink.

Practice what you learned

Was this page helpful?

Topics covered

#Programming#ApacheSparkStudyNotes#SparkBestPractices#Spark#Writing#Efficient#Jobs#StudyNotes#SkillVeris#ExamPrep