PySpark Jobs skill for Codex
Fix the straggler task, not the cluster size. Codex tunes Spark jobs from evidence: the formatted plan plus stage metrics such as spill, shuffle size and max versus median task time. It chooses broadcast or sort-merge joins deliberately, handles skewed keys with AQE or salting, replaces Python UDFs with built-ins, writes right-sized files with dynamic partition overwrite, and keeps transforms pure so they test locally.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- pyspark-jobs Installs to ~/.agents/skills/pyspark-jobs/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install pyspark-jobs The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project pyspark-jobs Installs to .agents/skills/pyspark-jobs/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/pyspark-jobs.zipunzip -o pyspark-jobs.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) pyspark-jobs PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $pyspark-jobs, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and tunes PySpark DataFrame jobs on Spark 3.5 and 4.x - reading the physical plan, broadcast versus sort-merge joins, shuffle partition sizing, adaptive query execution, data skew and salting, partitioned and dynamic-overwrite writes, avoiding Python UDFs, and testing with assertDataFrameEqual. Use when the user writes a Spark job in Python, a Spark stage is slow or spills, one task runs far longer than the rest, executors die with out-of-memory errors, output has thousands of tiny files, or a Databricks or EMR job costs too much.
Use it when
- Writing or reviewing a PySpark batch job
- A Spark stage is slow, spills, or has a straggler task
- Executors or the driver run out of memory
- Spark output has too many small files or duplicates on rerun
Use something else when
- Data fits on one machine: use polars-dataframes or duckdb-analytics
- The problem is table format maintenance like compaction and snapshots: use lakehouse-iceberg
- The job is a real-time stream from Kafka: use kafka-streaming
How to check the result
Row counts and totals match the old output for the same partition, the plan shows the intended join strategy, and the Spark UI shows the slow stage's max task time near the median.
The mistake it prevents
Throwing bigger executors at a skewed join, when one hot key is sending most rows to a single task.
Done well means: the job uses built-in column expressions end to end, the plan has no surprise shuffles, joins pick the right strategy, skew is handled, output files land at a sane size, and the job can rerun for the same partition without duplicating data.
Before changing code
- Read
AGENTS.md, the job’s entry point and its submit config (spark-submitargs, Databricks job JSON, EMR step,spark-defaults.conf). Note Spark version:spark.version. Spark 4.0 turns ANSI SQL mode on by default: invalid casts and overflow now raise instead of returning null. - Get evidence before tuning. From the Spark UI (or history server) for the slow run, record: longest stage, shuffle read/write size, spill (memory and disk), task duration max vs median, and number of tasks. Ask the user for these if Codex cannot reach the UI (it is usually off the sandbox network).
- Print the plan:
df.explain(mode="formatted"). Look forExchange(shuffle),BroadcastHashJoinvsSortMergeJoin,BatchEvalPython/ArrowEvalPython(Python UDFs), andPushedFiltersin the scan.
Job skeleton
from pyspark.sql import SparkSession, functions as F, Window
spark = (
SparkSession.builder.appName("orders_daily")
.config("spark.sql.adaptive.enabled", "true") # default on since 3.2
.config("spark.sql.sources.partitionOverwriteMode", "dynamic")
.getOrCreate()
)
def transform(orders, customers, run_date: str):
latest = Window.partitionBy("order_id").orderBy(F.col("updated_at").desc())
return (
orders
.where(F.col("order_date") == F.lit(run_date)) # partition pruning
.withColumn("rn", F.row_number().over(latest))
.where("rn = 1").drop("rn")
.join(F.broadcast(customers.select("customer_id", "country")), "customer_id", "left")
.withColumn("tier", F.when(F.col("amount") >= 1000, "high")
.when(F.col("amount") >= 100, "mid").otherwise("low"))
)
if __name__ == "__main__":
import sys
run_date = sys.argv[1]
orders = spark.read.parquet("s3://lake/orders/")
customers = spark.read.parquet("s3://lake/customers/")
(transform(orders, customers, run_date)
.repartition("order_date")
.write.mode("overwrite") # dynamic: replaces only run_date
.partitionBy("order_date")
.parquet("s3://lake/orders_enriched/"))
Keep transform a pure function of DataFrames so it can be unit-tested; I/O stays at the edges.
Joins
| Situation | Strategy |
|---|---|
| One side fits comfortably in executor memory (tens to a few hundred MB) | F.broadcast(small); or raise spark.sql.autoBroadcastJoinThreshold (default 10 MB) |
| Both sides large | Sort-merge join; make sure join keys have the same type on both sides |
| Repeated joins on the same key across jobs | Bucketed tables or a pre-partitioned layout (Delta/Iceberg clustering) |
| Need only matching or non-matching rows | how="left_semi" / "left_anti" instead of join + filter |
| Key with heavy hot values | See skew below |
Null join keys never match; a large null-key population still lands in one partition during a shuffle. Filter or salt nulls explicitly.
Shuffle and partition sizing
- Target 100 to 200 MB per shuffle partition. With AQE on, set
spark.sql.adaptive.coalescePartitions.enabled=trueand leavespark.sql.shuffle.partitionshigh enough (for example total shuffle bytes / 128 MB); AQE merges small ones. repartition(n)orrepartition(col)shuffles;coalesce(n)only merges, cheaply, but can leave the final stage with too few tasks.- Cache (
df.cache()/persist) only a DataFrame reused by two or more actions, andunpersist()when done. Caching everything just fills executor memory.
Skew
Symptom: one or a few tasks take 10x the median and shuffle read far more bytes.
- Confirm the hot keys:
df.groupBy("customer_id").count().orderBy(F.desc("count")).show(20). - Let AQE split skewed partitions first:
spark.sql.adaptive.skewJoin.enabled=true(default on with AQE). Works for sort-merge joins. - Still skewed: broadcast the other side if it fits, or salt the hot keys:
N = 16
big_salted = big.withColumn("salt", (F.rand(seed=7) * N).cast("int"))
small_salted = small.crossJoin(spark.range(N).withColumnRenamed("id", "salt"))
joined = big_salted.join(small_salted, ["customer_id", "salt"]).drop("salt")
- Skewed aggregations: aggregate on
(key, salt)first, then onkey.
Python code on the hot path
| Pattern | Replace with |
|---|---|
@udf doing string, date or arithmetic logic | Built-ins in pyspark.sql.functions |
| Row-wise Python logic with no built-in equivalent | @pandas_udf (vectorised via Arrow), or useArrow=True on udf |
df.collect() / toPandas() on big data | Keep it distributed; collect only aggregates |
| Loops issuing many small Spark actions | One query with a join or groupBy |
Writing output
- Partition by low-cardinality columns that readers filter on (date, region). Partitioning by a high-cardinality ID creates millions of directories.
- Too many small files:
repartitionby the partition column beforewrite, or setspark.sql.files.maxRecordsPerFile. Aim for 128 MB to 1 GB files. - Idempotent reruns: dynamic partition overwrite (above) or table formats with
MERGE/replaceWhere. Plainmode("append")duplicates data on retry. - Overwriting a whole table path with static overwrite mode deletes every partition. Confirm with the user before running any job in overwrite mode against production paths.
Memory errors
| Error | Likely cause | First fix |
|---|---|---|
| Executor OOM / container killed by YARN or K8s | Skewed partition, huge broadcast, Python worker memory | Fix skew; lower broadcast; raise spark.executor.memoryOverhead for PySpark |
| Driver OOM | collect, toPandas, huge broadcast built on driver | Remove collect; reduce broadcast size |
| Heavy spill | Too few shuffle partitions | More partitions or let AQE coalesce from a higher number |
Testing
import pytest
from pyspark.sql import Row, SparkSession
from pyspark.testing import assertDataFrameEqual # Spark 3.5+
@pytest.fixture(scope="session")
def spark():
return (SparkSession.builder.master("local[2]")
.config("spark.sql.shuffle.partitions", "2").getOrCreate())
def test_latest_version_wins(spark):
orders = spark.createDataFrame(
[("o1", "c1", 50.0, "2025-09-01", "2025-09-01 10:00"),
("o1", "c1", 150.0, "2025-09-01", "2025-09-01 12:00")],
"order_id string, customer_id string, amount double, order_date string, updated_at string")
customers = spark.createDataFrame([("c1", "DE")], "customer_id string, country string")
out = transform(orders, customers, "2025-09-01").select("order_id", "amount", "tier")
assertDataFrameEqual(out, [Row(order_id="o1", amount=150.0, tier="mid")])
Local Spark needs a JVM; check java -version before assuming tests can run in the sandbox.
Verification and report
- Compare row counts and key totals between old and new output for the same partition.
- Re-check the plan and the Spark UI: target stage time, spill, max/median task duration.
- Rerun the same date and confirm the partition is replaced, not doubled.
**Job:** orders_daily (Spark 3.5, 20 x r6i.2xlarge)
**Problem:** stage 7 sort-merge join, 1 task 41 min vs median 40 s (customer c-000 = 18% of rows)
**Changes:** broadcast customers (62 MB), dynamic partition overwrite, removed Python UDF for tier
**Result:** 58 min -> 9 min; shuffle 410 GB -> 12 GB; output 96 files of ~300 MB (was 14,000)
**Parity:** row count and revenue identical for 2025-09-01 What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "PySpark Jobs"
short_description: "Fix the straggler task, not the cluster size"
default_prompt: "$pyspark-jobs One task in this Spark stage runs for 40 minutes while the others finish in seconds" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.