PySpark Tuning Flags cheat sheet
Adaptive execution, shuffle sizing, join strategy, executor memory, and skew handling — the flags that decide whether a job finishes.
Adaptive query execution
spark.sql.adaptive.enabled=true- On by default since Spark 3.2. AQE re-plans between stages using real shuffle statistics, which is why static tuning matters far less than it used to.
spark.sql.adaptive.coalescePartitions.enabled=true- Merges tiny post-shuffle partitions so 200 nearly empty tasks collapse into a handful. It can only reduce the partition count, never increase it.
spark.sql.adaptive.advisoryPartitionSizeInBytes=64m- The post-shuffle partition size AQE aims for. It is ignored while parallelismFirst is on, which surprises nearly everyone tuning it the first time.
spark.sql.adaptive.coalescePartitions.parallelismFirst=false- Defaults to true, which makes AQE target minPartitionSize of 1 MB and fill the cluster rather than honor your advisory size. Set false to regain control.
spark.sql.adaptive.localShuffleReader.enabled=true- When AQE downgrades a sort-merge join to a broadcast, this reads shuffle blocks locally and skips a network round trip. Leave it on.
Shuffle and partitioning
spark.sql.shuffle.partitions=200- The default, and wrong for almost every workload. With AQE it acts as an upper bound, so set it generously high rather than trying to be precise.
df.repartition(200, "customer_id")- A full shuffle producing even partitions with keys co-located. Use it before a wide join or a partitioned write, not as a reflex after every step.
df.coalesce(10)- Merges partitions without a shuffle, but the narrow dependency propagates upstream and can throttle the parallelism of everything that feeds it.
SELECT /*+ REBALANCE(customer_id) */ * FROM orders- Asks AQE for evenly sized output partitions and splits skewed ones. A better pre-write step than a fixed repartition count you have to keep retuning.
spark.default.parallelism- Applies only to RDD operations, never to DataFrame shuffles. Setting it and expecting Spark SQL to change behavior is a common dead end.
Join strategy
spark.sql.autoBroadcastJoinThreshold=10485760- 10 MB by default, compared against an estimated size. Raising it to 100 MB is usually safe once you know the driver and executors have headroom.
spark.sql.autoBroadcastJoinThreshold=-1- Disables broadcasting entirely. The right first move when the driver runs out of memory collecting a small side that statistics badly underestimated.
spark.sql.adaptive.autoBroadcastJoinThreshold- AQE's own threshold, applied to actual shuffle output rather than an estimate. It falls back to the static value when you leave it unset.
df.join(broadcast(dim), "customer_id")- Forces a broadcast regardless of statistics. Only safe when the dimension is genuinely bounded, since a full copy lands in every executor.
spark.sql.broadcastTimeout=300- Seconds to wait for the broadcast to build. The error reads like an unrelated timeout, but it almost always means the small table is not small.
spark.sql.join.preferSortMergeJoin=true- Shuffle hash join uses less CPU but builds its table in memory. Sort-merge spills gracefully, which is why it remains the default for large inputs.
Executor memory and cores
spark.executor.cores=5- Four or five is the practical ceiling. Beyond that, object store clients contend and a single executor loss costs proportionally more retried work.
spark.executor.memory=16g- JVM heap per executor. Divide by cores to see what one task actually gets, and that number is what predicts whether a sort spills.
spark.executor.memoryOverhead- Defaults to the larger of 384 MB and ten percent of executor memory. Python workers live in this pool, so PySpark jobs need it raised well above default.
spark.memory.fraction=0.6- The share of heap available to execution and storage together. The remaining forty percent absorbs user objects and JVM overhead.
spark.memory.storageFraction=0.5- The part of that pool cached blocks may hold against eviction. Execution can borrow past it, but cached data cannot borrow back.
spark.executor.pyspark.memory- A hard cap on Python worker memory, unset by default. Without it one pandas UDF can push the container past its limit and get the executor killed.
File I/O and layout
spark.sql.files.maxPartitionBytes=134217728- 128 MB, the target size of a single input split. Lower it to raise parallelism when a small cluster is reading a handful of very large files.
spark.sql.files.openCostInBytes=4194304- 4 MB, the assumed cost of opening a file. It is what packs many tiny files into one task, and why file count hurts scheduling more than scan time.
spark.sql.parquet.filterPushdown=true- On by default but useless unless the writer produced row-group statistics for the filtered column. Sort on your common filter column at write time.
spark.sql.sources.partitionOverwriteMode=dynamic- Overwrites only the partitions present in the DataFrame. The static default replaces the entire table and has erased plenty of production data.
spark.sql.parquet.compression.codec=zstd- snappy is the default. zstd typically yields twenty to thirty percent smaller files at similar read speed, which pays off in object store cost.
spark.sql.files.maxRecordsPerFile=1000000- Caps rows per output file, disabled by default with a value of 0. The simplest guard against one skewed partition producing a 20 GB Parquet file.
Skew and spill
spark.sql.adaptive.skewJoin.enabled=true- On by default. AQE splits an oversized shuffle partition and replicates the matching side, which resolves most skewed joins with no code change.
spark.sql.adaptive.skewJoin.skewedPartitionFactor=5- A partition counts as skewed at five times the median. Both this and the byte threshold must be exceeded, so a uniformly large stage is left alone.
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes=256m- The absolute floor before skew handling engages. Lower it when the straggler task is clearly disproportionate but still under 256 MB.
df.withColumn("salt", (rand() * 20).cast("int"))- Manual salting for skew AQE cannot see, such as a group by on one hot key. Explode the other side by the same factor before joining on the salted key.
spark.shuffle.file.buffer=1m- 32 KB by default. Raising it cuts syscall overhead during heavy shuffle writes, at the cost of that buffer for every open shuffle file.
spark.eventLog.enabled=true- Without event logs the history server has nothing once the job exits, and spill metrics are unrecoverable. Turn it on before the incident, not after.
PySpark specifics and diagnostics
spark.sql.execution.arrow.pyspark.enabled=true- Uses Arrow for toPandas and createDataFrame, often an order of magnitude faster. It falls back silently unless you also disable the fallback flag.
spark.sql.execution.arrow.maxRecordsPerBatch=10000- The default batch size handed to a pandas UDF. Lower it when one batch of very wide rows exhausts the Python worker's memory.
df.mapInPandas(fn, schema)- Streams Arrow batches to Python without materializing a whole group, which keeps memory bounded compared with applyInPandas on a skewed key.
df.explain("formatted")- Read the plan before changing any flag. Count the Exchange nodes to count shuffles, and confirm which join strategy the optimizer actually chose.
spark.sql.ansi.enabled=true- The default in Spark 4.0. Overflow and invalid casts raise instead of returning null, turning silent data corruption into a visible job failure.
spark.dynamicAllocation.enabled=true- Requires the external shuffle service or shuffle tracking. Without either, Spark reclaims executors still holding shuffle files and the stage retries forever.
From DataLane — tutorials at/blog, practice SQL live in theplayground.