Mastering PySpark Memory Management for Optimal Performance

Out-of-memory errors, excessive disk spills, slow jobs, and garbage-collection pauses — these are the most common performance killers in PySpark applications, and they all trace back to one root cause: misconfigured or misunderstood memory management.

Apache Spark uses a sophisticated, unified memory model that divides JVM heap space into purpose-built regions for execution, storage, and user data. PySpark adds another layer of complexity by maintaining a separate Python worker process that communicates with the JVM through serialisation. Getting the best out of this system requires understanding both tiers.

This blog provides a comprehensive guide to PySpark memory management — from the foundational architecture through configuration parameters, caching strategies, spill management, garbage collection tuning, and production monitoring — with practical code examples throughout.

Since Spark 1.6, the default memory manager is the Unified Memory Manager. It divides each executor’s JVM heap into three logical regions and allows the boundary between execution and storage memory to shift dynamically based on demand.

Diagram illustrating the PySpark Memory Architecture, detailing components of the Driver Node and Executor Node, including JVM Heap, User Memory, Spark Memory Pool, and Off-Heap Memory.

RegionFraction of HeapPurpose
Reserved Memory~300 MB fixedInternal Spark system usage; not configurable
User Memory(1 – spark.memory.fraction) of (heap – reserved)User data structures, UDFs, metadata — fully managed by application code
Spark Memory Poolspark.memory.fraction (default 0.6) of (heap – reserved)Shared pool split between Execution and Storage memory

Within the Spark Memory Pool, execution and storage memory compete dynamically. Neither region has a fixed allocation — each can borrow from the other up to configured limits.

Memory TypeUsed ForEviction Policy
Execution MemoryShuffle buffers, sort operations, join hash tables, aggregationCannot be evicted — spills to disk instead
Storage MemoryCached RDDs, DataFrames, broadcast variablesCan be evicted (LRU) to free space for execution
# Total executor memory breakdown (approximate)
heap_size = spark.executor.memory # e.g. 8g
reserved = 300 MB # fixed system reserve
usable_heap = heap_size - reserved
spark_memory_pool = usable_heap * spark.memory.fraction # default 0.6
user_memory = usable_heap * (1 - spark.memory.fraction) # default 0.4
storage_memory = spark_memory_pool * spark.memory.storageFraction # default 0.5
execution_memory = spark_memory_pool - storage_memory
# Example: 8GB executor
# usable = 8192 - 300 = 7892 MB
# spark pool = 7892 * 0.6 = 4735 MB
# user mem = 7892 * 0.4 = 3157 MB
# storage = 4735 * 0.5 = 2367 MB (initial — can grow)
# execution = 4735 * 0.5 = 2367 MB (initial — can grow)

Spark also supports off-heap memory — native memory managed outside the JVM garbage collector. It is used for Project Tungsten’s unsafe operations and can dramatically reduce GC pressure on large heaps.

# Enable off-heap memory
spark.conf.set('spark.memory.offHeap.enabled', 'true')
spark.conf.set('spark.memory.offHeap.size', '4g')
# Off-heap is primarily used by:
# - Tungsten's UnsafeRow binary format
# - Sorted external shuffle
# - Some Dataset/DataFrame operations

PySpark introduces an additional memory consumer that pure Scala/Java Spark jobs do not have: the Python worker process. Every executor spawns a Python subprocess to run user-defined Python code (UDFs, RDD maps, etc.). This process has its own memory footprint entirely separate from the JVM heap.

ComponentLocationWhat It Holds
JVM ExecutorJVM HeapSpark internals, shuffle, storage, execution
Python WorkerNative OS MemoryPython UDF execution, pandas operations, Arrow batches
Off-Heap (Tungsten)Native OS MemoryUnsafe binary row format, sorted shuffle
Overhead MemoryNative OS MemoryJVM internals, NIO buffers, Python worker processes

memoryOverhead is non-heap native memory allocated per executor for OS, JVM internals, and Python worker processes. It defaults to max(executor_memory * 0.1, 384 MB). For PySpark workloads with heavy Python usage, this often needs to be increased significantly.

from pyspark.sql import SparkSession
spark = SparkSession.builder \
.appName('MemoryTuning') \
.config('spark.executor.memory', '8g') \
.config('spark.executor.memoryOverhead', '2g') \
.config('spark.driver.memory', '4g') \
.config('spark.driver.memoryOverhead', '1g') \
.getOrCreate()

When using Pandas UDFs (vectorised UDFs) with Apache Arrow, each Python worker processes Arrow batches. The batch size is controlled by spark.sql.execution.arrow.maxRecordsPerBatch and directly affects how much memory each Python worker consumes.

import pandas as pd
from pyspark.sql.functions import pandas_udf
from pyspark.sql.types import DoubleType
# Control Arrow batch size to limit per-worker memory
spark.conf.set('spark.sql.execution.arrow.pyspark.enabled', 'true')
spark.conf.set('spark.sql.execution.arrow.maxRecordsPerBatch', '50000')
@pandas_udf(DoubleType())
def apply_discount(prices: pd.Series) -> pd.Series:
return prices * 0.90
df = df.withColumn('discounted_price', apply_discount(df['price']))

ParameterDefaultDescription
spark.executor.memory1gJVM heap per executor
spark.executor.memoryOverhead10% or 384MBNative overhead per executor (OS, Python workers)
spark.driver.memory1gJVM heap for the driver process
spark.driver.memoryOverhead10% or 384MBNative overhead for the driver
spark.memory.fraction0.6Fraction of heap for Spark memory pool (execution + storage)
spark.memory.storageFraction0.5Initial fraction of Spark pool for storage (dynamic)
spark.memory.offHeap.enabledfalseEnable off-heap Tungsten memory
spark.memory.offHeap.size0Off-heap allocation size
spark.sql.shuffle.partitions200Partitions for shuffle operations (affects per-task memory)
spark.default.parallelismcores * 2Default parallelism for RDD operations
spark.executor.cores1Cores per executor (affects task concurrency and memory sharing)
spark.sql.execution.arrow.maxRecordsPerBatch10000Arrow batch size for pandas UDFs
spark.rdd.compressfalseCompress serialised RDD partitions
spark.serializerJavaUse KryoSerializer for better performance

Read this also –

One of the most impactful decisions in Spark tuning is choosing between fewer large executors (fat) or many small executors (thin). Each approach has distinct memory implications.

AspectFat Executors (few, large)Thin Executors (many, small)
Memory per executorLarge — more in-memory data per nodeSmall — spills more likely
GC pressureHigh — large heaps have long GC pausesLower — smaller heaps GC faster
Broadcast efficiencyBetter — one large broadcast per executorWorse — more copies across cluster
Task parallelismHigh — many cores share data in RAMLimited by small heap per task
HDFS throughputBetter — fewer executors, larger HDFS buffersModerate
Fault toleranceLower — losing one executor hurts moreHigher — losing small executor is cheap
Recommended forShuffle-heavy, broadcast-heavy jobsMany independent small tasks

Recommended Executor Sizing Formula

# Rule of thumb for YARN / Kubernetes clusters:
# Leave 1 core per node for OS/YARN daemons
# Leave 1 GB per node for OS
# Max 5 cores per executor (HDFS throughput sweet spot)
# Example: Node with 16 cores, 64 GB RAM
usable_cores = 16 - 1 = 15
executors_per_node = 15 // 5 = 3
usable_memory = 64 - 1 = 63 GB
memory_per_executor = 63 // 3 = 21 GB
executor_memory = 21 * 0.9 = 18 GB # leave 10% as overhead
memory_overhead = 21 - 18 = 3 GB
# Spark config:
# spark.executor.cores = 5
# spark.executor.memory = 18g
# spark.executor.memoryOverhead = 3g

Caching stores intermediate DataFrame or RDD results in the Storage Memory region so they can be reused without recomputation. Choosing the right storage level is essential to avoid wasting memory or causing unnecessary disk I/O.

Storage LevelMemoryDiskSerialisedReplicatedBest For
MEMORY_ONLYYesNoNoNoSmall datasets; fastest access
MEMORY_ONLY_SERYesNoYesNoSaving memory at cost of CPU
MEMORY_AND_DISKYesYesNoNoMedium datasets; spill allowed
MEMORY_AND_DISK_SERYesYesYesNoLarge datasets with limited RAM
DISK_ONLYNoYesYesNoVery large datasets
OFF_HEAPOff-heapNoYesNoReduce GC; needs offHeap enabled
MEMORY_ONLY_2YesNoNoYesCritical data needing fault tolerance

from pyspark import StorageLevel
# cache() — shortcut for MEMORY_AND_DISK (DataFrame) or MEMORY_ONLY (RDD)
df_cached = df.cache()
# persist() — choose storage level explicitly
df.persist(StorageLevel.MEMORY_AND_DISK_SER)
df.persist(StorageLevel.MEMORY_ONLY)
df.persist(StorageLevel.DISK_ONLY)
df.persist(StorageLevel.OFF_HEAP)
# IMPORTANT: cache/persist is lazy — data is stored only on first action
df_cached.count() # triggers caching
# Always unpersist when done to release storage memory
df_cached.unpersist()
df.unpersist(blocking=True) # wait for eviction to complete

  • Cache when the same DataFrame is used in two or more downstream actions.
  • Cache when recomputation is expensive (many transformations, complex joins, aggregations).
  • Do NOT cache if the DataFrame is used only once — it wastes storage memory.
  • Do NOT cache very large DataFrames that barely fit in memory — they cause eviction of other cached data.
  • Prefer MEMORY_AND_DISK over MEMORY_ONLY for production jobs where OOM is a risk.

# View cached DataFrames in Spark UI or programmatically
spark.catalog.isCached('my_table') # True / False
# List all cached tables
for table in spark.catalog.listTables():
if table.isTemporary:
print(f'{table.name}: cached={spark.catalog.isCached(table.name)}')
# Check storage memory usage
print(spark.sparkContext.statusTracker().getExecutorInfos())

Shuffle is the most memory-intensive operation in Spark. It occurs during operations like groupBy, join, distinct, repartition, and orderBy. During a shuffle, data is written to disk (shuffle write) and then read back (shuffle read) across the network. Tuning shuffle memory is critical for avoiding spills and OOM errors.

Each task in the map stage uses execution memory to build an in-memory sort buffer (ExternalSorter). When this buffer fills up, data spills to disk. Frequent spills dramatically slow down jobs and can cause disk OOM on small nodes.

# Default: 200 shuffle partitions — often too many for small/medium data
# or too few for very large data
# For small to medium workloads
spark.conf.set('spark.sql.shuffle.partitions', '50')
# For large workloads (10s of GB of shuffle data)
spark.conf.set('spark.sql.shuffle.partitions', '2000')
# Adaptive Query Execution (AQE) — automatically coalesces shuffle partitions
spark.conf.set('spark.sql.adaptive.enabled', 'true')
spark.conf.set('spark.sql.adaptive.coalescePartitions.enabled', 'true')
spark.conf.set('spark.sql.adaptive.coalescePartitions.minPartitionNum', '1')
spark.conf.set('spark.sql.adaptive.advisoryPartitionSizeInBytes', '128MB')

# SortShuffleManager (default) — uses ExternalSorter with spill support
# Better for large data; handles spill gracefully
# For small data with few reducers, Spark may use BypassMergeSort
# which writes directly to per-reducer files (no sorting overhead)
# Tune spill threshold
spark.conf.set('spark.shuffle.spill.compress', 'true')
spark.conf.set('spark.shuffle.compress', 'true')
spark.conf.set('spark.io.compression.codec', 'lz4') # faster than snappy for CPU
# External shuffle service (recommended on YARN)
spark.conf.set('spark.shuffle.service.enabled', 'true')

The most effective way to reduce shuffle memory pressure is to eliminate the shuffle entirely using a broadcast join. When one side of a join is small enough to fit in memory, broadcast it to all executors so each executor can perform a local hash join.

from pyspark.sql.functions import broadcast
# Auto-broadcast threshold (default 10 MB)
spark.conf.set('spark.sql.autoBroadcastJoinThreshold', '50MB')
# Large table
orders = spark.read.parquet('s3://bucket/orders/')
# Small lookup table — broadcast it explicitly
products = spark.read.parquet('s3://bucket/products/')
# Broadcast join — no shuffle, no sort, O(1) memory on map side
result = orders.join(broadcast(products), on='product_id', how='left')
# Disable auto-broadcast for very large small tables
spark.conf.set('spark.sql.autoBroadcastJoinThreshold', '-1') # force sort-merge join

When execution memory is exhausted, Spark spills data to disk — writing intermediate results to a temporary directory and reading them back when needed. Spill is a safety valve that prevents OOM errors but comes at a significant performance cost: disk I/O, serialisation overhead, and increased task duration.

Spill is visible in the Spark UI under the Stages tab. Look for the Spill (Memory) and Spill (Disk) columns. Consistent spill in the same stage indicates the task is under-resourced.

# Monitor spill programmatically via accumulators (Spark 3.x)
# In Spark UI: Stages -> look for 'Spill (Memory)' and 'Spill (Disk)' columns
# Common causes of spill:
# 1. Too many tasks per executor (too many concurrent tasks fighting for execution memory)
# 2. Large shuffle operations with insufficient shuffle partitions
# 3. GroupBy/join on high-cardinality columns producing large per-task data
# 4. Window functions over large partitions

  1. Increase executor memory or reduce executor cores to give each task more memory.
  2. Increase spark.sql.shuffle.partitions to make each partition (and thus each task) smaller.
  3. Enable Adaptive Query Execution (AQE) to dynamically right-size shuffle partitions.
  4. Use broadcast joins to eliminate shuffle for small-large table joins.
  5. Repartition data before groupBy to distribute load more evenly.
  6. Pre-filter data aggressively before heavy operations to reduce the data volume each task sees.
# Strategy: repartition before heavy aggregation to reduce per-task data
df_repartitioned = df.repartition(500, 'customer_id')
result = df_repartitioned.groupBy('customer_id').agg({'amount': 'sum'})
# Strategy: filter early, aggregate late
result = (
df
.filter(df.year == 2024) # reduce data volume first
.filter(df.status == 'completed')
.groupBy('region')
.agg({'revenue': 'sum', 'order_id': 'count'})
)
# Strategy: use AQE to auto-optimise shuffle
spark.conf.set('spark.sql.adaptive.enabled', 'true')
spark.conf.set('spark.sql.adaptive.skewJoin.enabled', 'true')

Spark runs on the JVM and is subject to Java garbage collection pauses. For long-running jobs with large heaps, GC pauses can add minutes to job runtimes. Understanding and tuning the JVM GC is a key part of advanced Spark optimisation.

GC AlgorithmBest ForSpark Use Case
G1GC (default Java 9+)Large heaps (8GB+); balanced latency/throughputMost Spark workloads — recommended default
Parallel GCMaximum throughput; accepts longer pausesBatch ETL jobs where latency is not critical
ZGC (Java 15+)Ultra-low latency; very large heaps (100GB+)Near-real-time streaming, interactive queries
Shenandoah (OpenJDK)Low pause times; moderate throughputStreaming jobs with strict SLA

# G1GC configuration for Spark executors
spark.conf.set('spark.executor.extraJavaOptions',
'-XX:+UseG1GC '
'-XX:G1HeapRegionSize=16M '
'-XX:InitiatingHeapOccupancyPercent=35 '
'-XX:+G1PrintRegionLivenessInfo '
'-verbose:gc '
'-XX:+PrintGCDetails '
'-XX:+PrintGCDateStamps '
'-XX:OnOutOfMemoryError=kill -9 %p'
)
# Driver GC options
spark.conf.set('spark.driver.extraJavaOptions',
'-XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=35'
)

Java’s default serialiser creates many short-lived objects, generating heavy GC pressure. Kryo serialisation is 10x faster and creates fewer objects, significantly reducing GC overhead.

from pyspark.sql import SparkSession
spark = SparkSession.builder \
.config('spark.serializer', 'org.apache.spark.serializer.KryoSerializer') \
.config('spark.kryo.registrationRequired', 'false') \
.config('spark.kryo.unsafe', 'true') \
.getOrCreate()
# Register frequently used classes for maximum Kryo benefit
# (in Scala/Java — not directly from PySpark, but affects JVM-side data)
# spark.conf.set('spark.kryo.classesToRegister', 'MyClass1,MyClass2')

You cannot tune what you cannot measure. Spark provides several tools for real-time and post-mortem memory monitoring.

  • Storage Tab: shows all cached RDDs/DataFrames, their storage level, memory used, and disk used.
  • Executors Tab: shows memory used vs available per executor, GC time, and disk spill per executor.
  • Stages Tab: Spill (Memory) and Spill (Disk) columns show spill per stage/task.
  • Environment Tab: shows all active Spark configuration values.

# Check executor memory status
sc = spark.sparkContext
# Get executor info (Spark 3.x)
exec_info = sc.statusTracker().getExecutorInfos()
for info in exec_info:
print(f'Executor {info.executorId()}: '
f'maxMemory={info.maxMemory() // (1024**2)} MB')
# Get RDD storage info
for rdd_info in sc.statusTracker().getRDDInfos():
print(f'RDD {rdd_info.id()}: '
f'memSize={rdd_info.memSize() // (1024**2)} MB, '
f'diskSize={rdd_info.diskSize() // (1024**2)} MB')

# Access task-level metrics including spill
# Run in a loop after job completion to check metrics
def check_stage_metrics(spark, job_id=None):
"""Print spill stats from the Spark status tracker."""
tracker = spark.sparkContext.statusTracker()
active_jobs = tracker.getActiveJobIds()
for job in active_jobs:
stage_ids = tracker.getJobInfo(job).stageIds()
for sid in stage_ids:
info = tracker.getStageInfo(sid)
if info:
print(f'Stage {sid}: tasks={info.numCompletedTasks()}')
# Better: use Spark History Server or integrate with Prometheus/Grafana
spark.conf.set('spark.metrics.conf.executor.source.jvm.class',
'org.apache.spark.metrics.source.JvmSource')

OOM ScenarioRoot CauseFix
Driver OOMcollect(), toPandas(), or show() on very large DataFrame pulls all data to driverAvoid collect() on large data; use write() instead; increase spark.driver.memory
Executor OOM during shufflePer-task shuffle data exceeds execution memoryIncrease shuffle partitions; enable AQE; increase executor memory
Executor OOM during broadcastBroadcast variable too large for executor heapLower autoBroadcastJoinThreshold or disable; use sort-merge join instead
Python worker OOMPandas UDF processes too much data per batchReduce maxRecordsPerBatch; increase memoryOverhead; use chunked processing
OOM on window functionEntire partition loaded into execution memoryRepartition before windowing; add partition column to PARTITION BY
GC overhead limit exceededJVM spending >98% of time in GC — heap fragmentedSwitch to G1GC; reduce object creation; use Kryo; increase heap
Cached data evicted unexpectedlyExecution memory pressure evicts storage memoryReduce data cached; use DISK_ONLY level; increase memory.fraction

  • Set executor.memory to 70-80% of node memory per executor (leave room for OS and overhead).
  • Set executor.memoryOverhead to at least 15-20% of executor.memory for PySpark jobs.
  • Use 4-5 cores per executor for optimal HDFS throughput and memory sharing.
  • Set driver.memory appropriately — increase if collect() or large broadcasts are used.
  1. If jobs spill frequently: increase spark.memory.fraction (e.g. to 0.7 or 0.75) to give more to execution.
  2. If caching is critical: increase spark.memory.storageFraction (e.g. to 0.6).
  3. Enable off-heap for jobs with very large heaps (>16GB per executor) to reduce GC pause.
  1. Enable AQE: spark.sql.adaptive.enabled = true.
  2. Set shuffle.partitions to 2-3x the number of executor cores for typical workloads.
  3. Use broadcast joins for tables smaller than 100-200 MB.
  4. Repartition skewed data before heavy aggregations.
  1. Only cache DataFrames used 2+ times in the same job.
  2. Use MEMORY_AND_DISK_SER in production to prevent OOM from unexpected caching pressure.
  3. Always call unpersist() after the cached DataFrame is no longer needed.
  4. Monitor cache hit ratio in the Spark UI Storage tab.
  • Switch to KryoSerializer for all jobs using RDDs or custom serialised types.
  • Configure G1GC with -XX:InitiatingHeapOccupancyPercent=35 for proactive collection.
  • Add -XX:OnOutOfMemoryError=’kill -9 %p’ to auto-restart failed executors on OOM.

from pyspark.sql import SparkSession
spark = (
SparkSession.builder
.appName('ProductionETL')
# ── Executor sizing ──────────────────────────────────
.config('spark.executor.instances', '20')
.config('spark.executor.cores', '5')
.config('spark.executor.memory', '18g')
.config('spark.executor.memoryOverhead', '3g')
# ── Driver sizing ──────────────────────────────────
.config('spark.driver.memory', '8g')
.config('spark.driver.memoryOverhead', '2g')
# ── Memory fractions ──────────────────────────────
.config('spark.memory.fraction', '0.7')
.config('spark.memory.storageFraction', '0.4')
# ── Off-heap ──────────────────────────────────────
.config('spark.memory.offHeap.enabled', 'true')
.config('spark.memory.offHeap.size', '4g')
# ── Shuffle & AQE ─────────────────────────────────
.config('spark.sql.shuffle.partitions', '800')
.config('spark.sql.adaptive.enabled', 'true')
.config('spark.sql.adaptive.coalescePartitions.enabled','true')
.config('spark.sql.adaptive.skewJoin.enabled', 'true')
.config('spark.sql.autoBroadcastJoinThreshold', '100MB')
# ── Serialisation ─────────────────────────────────
.config('spark.serializer', 'org.apache.spark.serializer.KryoSerializer')
.config('spark.kryo.unsafe', 'true')
# ── Arrow / PySpark ───────────────────────────────
.config('spark.sql.execution.arrow.pyspark.enabled', 'true')
.config('spark.sql.execution.arrow.maxRecordsPerBatch', '50000')
# ── GC tuning ─────────────────────────────────────
.config('spark.executor.extraJavaOptions',
'-XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=35 '
'-XX:OnOutOfMemoryError=kill -9 %p')
# ── Shuffle compression ───────────────────────────
.config('spark.shuffle.compress', 'true')
.config('spark.shuffle.spill.compress', 'true')
.config('spark.io.compression.codec', 'lz4')
.getOrCreate()
)
# Print effective config for verification
print('Executor Memory:', spark.conf.get('spark.executor.memory'))
print('Memory Fraction:', spark.conf.get('spark.memory.fraction'))
print('Shuffle Partitions:',spark.conf.get('spark.sql.shuffle.partitions'))

ProblemSymptomSolution
Executor OOMjava.lang.OutOfMemoryError on executorIncrease executor.memory or reduce executor.cores
Driver OOMjava.lang.OutOfMemoryError on driverIncrease driver.memory; avoid large collect()
Python worker OOMPython process killed; task failsIncrease memoryOverhead; reduce Arrow batch size
Excessive spillHigh disk I/O; slow stagesMore shuffle partitions; enable AQE; add memory
High GC time>20% GC time in executor tabUse G1GC; enable Kryo; reduce object creation
Broadcast OOMBroadcast variable failsReduce autoBroadcastJoinThreshold or disable it
Skewed joinOne task takes 10x longerEnable AQE skew join; salt skewed keys
Cache eviction loopCache repeatedly evicted and recomputedIncrease storage fraction or use DISK_ONLY level
Slow serialisationHigh task serialisation timeSwitch to KryoSerializer
Small file shuffleThousands of tiny output filesEnable AQE coalesce; increase advisory partition size

PySpark memory management is a multi-layered discipline that spans JVM heap configuration, Python worker overhead, execution and storage memory balancing, shuffle tuning, spill avoidance, garbage collection optimisation, and real-time monitoring. No single parameter fix solves all problems — effective tuning requires understanding how the pieces interact.

The mental model to keep in mind: every byte of memory is shared between multiple competing consumers — execution tasks, cached data, shuffle buffers, Python workers, and the JVM itself. Your goal is to ensure each consumer gets enough memory at the right time, while minimising GC overhead and disk spills.

Start with proper executor sizing, enable Adaptive Query Execution, switch to Kryo serialisation, and monitor the Spark UI closely. Iterate based on what you observe. With each tuning cycle, your jobs will run faster, more reliably, and at lower infrastructure cost.


Discover more from DataSangyan

Subscribe to get the latest posts sent to your email.

Leave a Reply