PySpark Jobs skill for Claude Code
Fewer shuffles, no stragglers, right-sized files. Claude writes PySpark with explicit schemas and built-in functions instead of Python row UDFs, checks the physical plan before running, and reads Spark UI stage metrics to find spill, skew and extra shuffles. It picks broadcast or salted joins from evidence, caches only reused data, and writes idempotent partition overwrites or MERGE into Delta Lake or Iceberg with sane file sizes.
- name
pyspark-jobs - size187 lines · ≈2,250 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and tunes PySpark batch jobs on Spark 3.5 and 4.x - DataFrame API over Python UDFs, explicit schemas, partition counts and file sizes, broadcast and sort-merge joins, skew handling with AQE and salting, caching only reused data, reading the Spark UI (stages, shuffle read/write, spill, task skew), and Delta Lake or Apache Iceberg tables with MERGE and partition overwrite. Use when a Spark job is slow, runs out of memory, has one straggler task, writes millions of small files, or when writing a new PySpark ETL on Databricks, EMR, Dataproc, or local Spark. Try asking
- This Spark job hangs at 199/200 tasks for an hour - fix the skew
- Rewrite this PySpark ETL that uses three Python UDFs to run faster
- Our daily job writes 40,000 tiny Parquet files - make the output sane
Use it when
- A Spark job is slow, spills, or fails with out-of-memory errors
- One or a few tasks run far longer than the rest
- Writing a new PySpark ETL on Databricks, EMR, Dataproc, or locally
- Output tables have too many small files or non-idempotent overwrites
Skip it when
- Data fits on one machine - use polars-dataframes or duckdb-analytics
- You need orchestration, retries and backfills - use data-pipelines
How to check the result
Compare Spark UI wall time, shuffle bytes, spill and max-to-median task time before and after, and confirm row counts and key checksums match the old output.
The common mistake it prevents
Overwriting a partitioned Parquet path without dynamic partition overwrite mode, which silently deletes every partition except the ones just written.
A Spark job is done when it uses built-in column expressions instead of Python row functions, reads only the columns and partitions it needs, shuffles once per logical step, has no task running more than a few times longer than the median, and writes files in the 128 MB-1 GB range to a table format that supports atomic commits.
Version notes
- Spark 4.0 turns ANSI SQL mode on by default (
spark.sql.ansi.enabled=true): invalid casts and integer overflow raise errors instead of returning NULL. Code ported from 3.x that relied on silent NULLs must usetry_cast/try_*functions or handle bad data explicitly. - Adaptive Query Execution (AQE) is on by default since 3.2: it coalesces shuffle partitions, converts joins to broadcast at runtime, and splits skewed partitions.
- Spark Connect clients (
pyspark-connect, Databricks serverless) do not exposesparkContextor RDD APIs; stay on the DataFrame API to remain portable.
Workflow
- [ ] 1. Read the job: inputs, sizes, partitioning, outputs, schedule
- [ ] 2. Check the plan: df.explain("formatted") for scans, filters pushed, exchanges
- [ ] 3. Run on a representative slice; open the Spark UI
- [ ] 4. Find the costly stage: shuffle size, spill, skewed tasks, GC
- [ ] 5. Fix the biggest cost first (join strategy, skew, partitioning, UDFs)
- [ ] 6. Fix output layout (file size, partition columns, table format)
- [ ] 7. Re-run and compare stage metrics and wall time
Code rules
from pyspark.sql import SparkSession, functions as F, types as T, Window
spark = SparkSession.builder.appName("orders_daily").getOrCreate()
schema = T.StructType([
T.StructField("order_id", T.StringType(), False),
T.StructField("customer_id", T.LongType(), True),
T.StructField("amount", T.DecimalType(12, 2), True),
T.StructField("created_at", T.TimestampType(), True),
])
orders = (
spark.read.schema(schema).json("s3://raw/orders/dt=2026-10-19/")
.select("order_id", "customer_id", "amount", "created_at") # prune early
.filter(F.col("amount") > 0)
.withColumn("order_date", F.to_date("created_at"))
)
w = Window.partitionBy("customer_id").orderBy(F.col("created_at").desc())
latest = orders.withColumn("rn", F.row_number().over(w)).filter("rn = 1").drop("rn")
- Supply schemas for JSON/CSV. Schema inference reads the data twice and guesses types differently per batch.
- Use
pyspark.sql.functionsfor everything they cover. A Python UDF serializes every row to a Python worker; it can be 10-100x slower. If unavoidable, use a vectorized@F.pandas_udfor Arrow-optimized UDFs, not a row UDF. - Never
collect()ortoPandas()a large DataFrame onto the driver. Uselimit()for samples and write results to storage. - Chain transformations; avoid loops that call
withColumnhundreds of times (plan blowup) - build a list of expressions and use oneselect, orwithColumns({...})(3.3+). - Do not use
count()for control flow on large data (if df.count() > 0); usedf.isEmpty()(3.3+) ordf.limit(1).count().
Partitions and files
| Situation | Action |
|---|---|
| Shuffle stages with thousands of tiny tasks | let AQE coalesce (spark.sql.adaptive.coalescePartitions.enabled, default on) |
| Shuffle tasks spilling to disk, multi-GB per task | raise spark.sql.shuffle.partitions (default 200) or spark.sql.adaptive.advisoryPartitionSizeInBytes |
| Reading many small files | spark.sql.files.maxPartitionBytes (default 128 MB) groups them; better, compact the source |
| Output has thousands of tiny files | repartition(n, *partition_cols) before write, or table-format compaction (OPTIMIZE) |
| Reduce partitions without shuffle | coalesce(n) (can create uneven, slow final tasks; prefer repartition when n is small relative to data) |
Target output files of 128 MB-1 GB. Partition tables on low-cardinality columns queried in filters (date), never on high-cardinality ids - each value becomes a directory.
Joins
| Case | Strategy |
|---|---|
One side under ~10 MB (spark.sql.autoBroadcastJoinThreshold) or AQE detects it | broadcast hash join (automatic) |
| Small side known but above threshold (up to a few hundred MB) | big.join(F.broadcast(small), "key") |
| Both large | sort-merge join; ensure keys have the same type on both sides |
| Repeated joins on the same key across jobs | bucketed or clustered tables on that key |
Join hygiene: filter and select before joining, deduplicate the dimension side, and check
for null or default keys ("", 0, "unknown") that pile onto one partition.
Skew
Symptom in the Spark UI: one or a few tasks in a stage take far longer (max task time greater than 5-10x the median) and read far more shuffle data.
- Confirm the hot keys:
df.groupBy("key").count().orderBy(F.desc("count")).show(20). - If AQE skew join is on (
spark.sql.adaptive.skewJoin.enabled, default true), check the plan reports skew handling; tuneskewedPartitionFactor/skewedPartitionThresholdInBytes. - Null or junk keys: filter them out and process separately.
- Remaining skew in joins: salt the hot keys.
N = 16
big_salted = big.withColumn("salt", (F.rand(seed=42) * N).cast("int"))
small_salted = small.crossJoin(spark.range(N).withColumnRenamed("id", "salt"))
joined = big_salted.join(small_salted, ["key", "salt"]).drop("salt")
For skewed aggregations, aggregate on (key, salt) first, then on key.
Caching
Cache only a DataFrame reused by two or more actions, and only after the expensive part:
df.persist(StorageLevel.MEMORY_AND_DISK), then unpersist() when done. Caching a
DataFrame used once wastes memory and adds a materialization step. Check the Storage tab:
a “fraction cached” below 100% means it does not fit.
Reading the Spark UI
| Where | Look for | Meaning |
|---|---|---|
| Jobs / Stages | longest stage, number of tasks | where the time goes |
| Stage detail: summary metrics | max vs median duration, shuffle read size | skew if max far above median |
| Stage detail | Spill (memory/disk) | partitions too large; more partitions or more memory per task |
| Executors | GC time above ~10% of task time | memory pressure; fewer cores per executor or more memory |
| SQL / DataFrame tab | plan with Exchange nodes, scan “files read”, “number of output rows” | extra shuffles, missing partition pruning |
On Databricks, the same metrics are in the Spark UI and query profile; on EMR/Dataproc use the Spark History Server for finished jobs.
Table formats
Write to Delta Lake or Apache Iceberg rather than bare Parquet directories when jobs update data, run concurrently, or need rollback.
# Idempotent daily load: replace only the partition being processed
(daily.write.format("delta").mode("overwrite")
.option("replaceWhere", "order_date = '2026-10-19'")
.save("s3://lake/orders"))
# Upsert (Delta)
from delta.tables import DeltaTable
(DeltaTable.forPath(spark, "s3://lake/customers").alias("t")
.merge(updates.alias("s"), "t.customer_id = s.customer_id")
.whenMatchedUpdateAll().whenNotMatchedInsertAll().execute())
- With plain Parquet, set
spark.sql.sources.partitionOverwriteMode=dynamicbeforemode("overwrite"), or a partitioned overwrite deletes every other partition. - Iceberg:
df.writeTo("catalog.db.orders").overwritePartitions()andMERGE INTOin SQL. - Deduplicate the source before MERGE; multiple source rows matching one target row fail the merge.
- Schedule compaction (
OPTIMIZE, Icebergrewrite_data_files) and snapshot expiry (VACUUM,expire_snapshots) - ask before shortening retention, it limits time travel.
Gotchas
F.rand()without a seed and non-deterministic UDFs make retried tasks produce different data; seed them.- Timestamps: set
spark.sql.session.timeZoneexplicitly (usually UTC);to_dateon a timestamp uses the session time zone. dropDuplicates()without columns compares all columns; pass the key columns.unionmatches by position; useunionByName(allowMissingColumns=True).- Writing then reading the same path in one job (overwrite while reading) can delete the input before it is read; write to a new location or use a table format.
- Local tests:
SparkSession.builder.master("local[2]")with small fixtures andspark.sql.shuffle.partitions=2keeps tests fast.
Verification
explain("formatted")showsPushedFiltersand pruned columns on scans, broadcast where expected, and no unexpected extraExchange.- Spark UI after the change: wall time, total shuffle bytes, spill, and max/median task time recorded before and after.
- Output check: row count and a key-level checksum match the previous implementation on the same input; file sizes in the target range.
- Re-running the job for the same date produces the same output (idempotent).
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.