PySpark · working reference · DataFrame API

Everything is a question
about the shuffle.

Spark builds a plan and does nothing. An action fires it. The plan is cut into stages wherever data must cross the network — and that cut is where your money goes. Every operation below is marked by which side of the cut it sits on.

narrow shuffle action plan only
01

Execution model

The mental model that makes the rest of this document predictable. Get this right and tuning stops being guesswork.

The cast

Driver
Runs your Python, holds the SparkSession, builds the plan, schedules tasks, collects results. Single point of failure and the thing you OOM with collect().
jvm+py
Executor
JVM process on a worker. Runs tasks, holds cached blocks, serves shuffle files. Sized by cores and memory.
jvm
Job → Stage → Task
One action = one job. Job splits into stages at every shuffle. Stage runs one task per partition.
unit
Partition
The atom of parallelism. Too few = idle cores. Too many = scheduler overhead and tiny files.
unit
Catalyst
Query optimiser. Unresolved plan → analysed → optimised (predicate pushdown, projection pruning, constant folding) → physical plan → RDDs.
compile
Tungsten
Off-heap binary format plus whole-stage code generation. Why native functions beat Python UDFs by an order of magnitude.
runtime
AQE
Adaptive Query Execution. Re-plans at runtime using real shuffle statistics: coalesces partitions, splits skewed joins, flips sort-merge to broadcast. On by default since 3.2.
runtime
Py4J / Arrow
The Python↔JVM boundary. DataFrame calls cross it once (cheap). Python UDFs cross it per batch of rows (expensive) — Arrow makes that transfer columnar and far cheaper.
bridge

Narrow vs wide, concretely

Narrow — select · filter · withColumn · union · coalesce · map-side ops
Each output partition depends on exactly one input partition. Fused into the current stage. Effectively free on top of a scan.
narrow
Wide — groupBy · join · distinct · orderBy · repartition · window
Output partitions read from many inputs. Writes shuffle files to local disk, then fetches over the network. Ends the stage.
shuffle
Rule of thumb. Count the exchanges in df.explain(). That number, times your data volume, is roughly your runtime. Everything in §15 is about reducing one or the other.
02

SparkSession & config

# Local / standalone
from pyspark.sql import SparkSession
from pyspark.sql import functions as F, types as T
from pyspark.sql.window import Window

spark = (SparkSession.builder
    .appName("claims_load")
    .config("spark.sql.shuffle.partitions", "200")
    .config("spark.sql.session.timeZone", "UTC")
    .config("spark.sql.execution.arrow.pyspark.enabled", "true")
    .getOrCreate())

spark.sparkContext.setLogLevel("WARN")

# Spark Connect (3.4+) — thin client, no local JVM
spark = SparkSession.builder.remote("sc://host:15002").getOrCreate()

# In Databricks / Glue the session already exists — never build a new one
#   Databricks: `spark` is injected
#   Glue:       glueContext.spark_session
spark.conf.set(k, v)
Runtime config. Most spark.sql.* keys are settable mid-session; cluster/memory keys are not.
config
spark.conf.get(k)
Read effective value. Useful in notebooks to confirm what the platform actually set.
config
spark.version · spark.sparkContext.uiWebUrl
Version and the Spark UI link — the UI is where you actually diagnose things (§19).
info
spark.range(n)
Cheap synthetic DataFrame with column id. Ideal for testing skew, joins and window behaviour.
narrow
Timezone. Set spark.sql.session.timeZone explicitly to UTC. Timestamp semantics silently follow the JVM default otherwise, and a job that runs correctly in London breaks after the clocks change or when the cluster moves region.
03

Reading data

df = (spark.read
    .format("parquet")
    .option("mergeSchema", "false")
    .load("s3://bucket/claims/"))

# CSV — always give it a schema in production
df = (spark.read
    .schema(schema)                       # skip inference: one fewer full scan
    .option("header", True)
    .option("mode", "PERMISSIVE")      # PERMISSIVE | DROPMALFORMED | FAILFAST
    .option("columnNameOfCorruptRecord", "_corrupt")
    .csv("s3://bucket/raw/*.csv"))

# JDBC — parallelise or you get one task and a very long wait
df = (spark.read.format("jdbc")
    .option("url", url).option("dbtable", "claims")
    .option("partitionColumn", "claim_id")
    .option("lowerBound", 1).option("upperBound", 10_000_000)
    .option("numPartitions", 32)
    .option("fetchsize", 10_000)
    .load())
.parquet(path)
Columnar, splittable, schema embedded. The default choice. Supports projection and predicate pushdown.
narrow
.format("delta").load(path)
Parquet plus a transaction log — ACID, time travel, MERGE. See §17.
narrow
.json(path)
Line-delimited by default. multiLine=True for arrays — but multiLine files are not splittable, so one file = one task.
narrow
.csv(path)
Splittable if uncompressed or bzip2. Gzip CSV is a single-task trap.
narrow
.table("cat.schema.tbl")
Reads via the catalog — picks up partition metadata and statistics.
narrow
.option("basePath", p)
Keeps Hive-style partition columns when you glob into a subdirectory.
option
.option("recursiveFileLookup", True)
Walks nested directories without interpreting them as partitions.
option
.option("pathGlobFilter", "*.parquet")
Filter file names at listing time. Cheaper than reading and discarding.
option
F.input_file_name()
Source file per row. Essential for lineage columns in a bronze layer.
narrow
Schema inference costs a scan. On CSV/JSON, Spark reads the data once to guess types, then again to load. Supply .schema() and you halve the I/O — and you stop a stray value silently retyping a column between runs.
04

Schemas & types

# DDL string — shortest readable form
schema = "claim_id BIGINT, policy_no STRING, paid DECIMAL(18,2), ts TIMESTAMP"

# StructType — when you need nullability or nesting explicit
schema = T.StructType([
    T.StructField("claim_id", T.LongType(), nullable=False),
    T.StructField("paid",     T.DecimalType(18, 2)),
    T.StructField("party",    T.StructType([
        T.StructField("name", T.StringType()),
        T.StructField("tags", T.ArrayType(T.StringType())),
    ])),
])

df.printSchema()
df.schema.json()                      # persist and reuse across jobs
T.StructType.fromJson(json.loads(s))  # round-trip back
DecimalType(p, s)
Use for money. DoubleType will not reconcile to the ledger. Max precision 38; watch precision growth on multiply.
type
TimestampType vs TimestampNTZType
The first is instant-with-session-zone; NTZ (3.4+) is wall-clock, no zone. Pick deliberately for event data.
type
ArrayType · MapType · StructType
Nested types. Cheap to carry, cheap to prune (Parquet reads only the accessed leaf).
type
VariantType
Spark 4.0. Semi-structured blob with efficient path extraction — better than string-JSON for irregular payloads.
type
col.cast("decimal(18,2)")
Cast by DDL string or type object. Silent null on failure unless ANSI mode is on.
narrow
try_cast · try_divide · try_add
Explicitly null-on-failure variants. The honest choice when ANSI is enabled.
narrow
spark.sql.ansi.enabled
Errors instead of silent nulls on overflow and bad casts. Default true in Spark 4.0 — a real migration hazard from 3.x.
config
05

Core DataFrame operations

df.select("a", F.col("b").alias("c"))
Projection. Prunes columns at the scan when the source supports it.
narrow
df.selectExpr("a", "b * 1.2 as gross")
SQL expressions inline. Handy for porting existing SQL logic.
narrow
df.filter(cond) / .where(cond)
Identical. Pushed down to Parquet/JDBC where possible.
narrow
df.withColumn("x", expr)
Add or replace one column. Do not loop this — see the warning below.
narrow
df.withColumns({"x": e1, "y": e2})
3.3+. Batch version. Use this instead of chained withColumn.
narrow
df.withColumnRenamed(a, b) · .withColumnsRenamed({...})
Rename without reprojecting everything.
narrow
df.drop("a", "b")
Silently ignores missing names — convenient, occasionally hides a typo.
narrow
df.distinct() · .dropDuplicates([cols])
Deduplication requires a shuffle. dropDuplicates keeps an arbitrary row — use a window (§09) when you need a deterministic winner.
shuffle
df.orderBy(F.col("x").desc())
Global sort — full range-partitioning shuffle. Only sort what you are about to write or show.
shuffle
df.sortWithinPartitions(...)
Local ordering, no exchange. Improves compression and Parquet min/max skipping.
narrow
df.limit(n)
Adds a plan-level limit. Still a shuffle for global ordering guarantees in many plans.
shuffle
df.sample(fraction, seed=42)
Approximate row sample. sampleBy for stratified.
narrow
df.transform(fn)
Chain a function that takes and returns a DataFrame. The clean way to compose reusable pipeline steps.
compose
df.show(20, truncate=False)
Action. Prints to stdout.
action
df.count()
Action. Full scan unless the source has usable statistics.
action
df.collect() · .toPandas()
Action. Pulls every row to the driver. The most common cause of driver OOM.
action
df.take(n) · .head(n) · .first()
Action, but bounded. Safe way to peek.
action
df.toLocalIterator()
Action. Streams partition-by-partition to the driver — memory-safe alternative to collect().
action
df.isEmpty()
3.3+. Cheaper than count() == 0; stops after the first row.
action
The withColumn loop. for c in cols: df = df.withColumn(...) builds a deeply nested logical plan. At a few hundred iterations Catalyst analysis time explodes and the driver stalls before a single task runs. Build a list of expressions and issue one select, or use withColumns.
# Good: one projection, flat plan
exprs = [F.col(c).cast("decimal(18,2)").alias(c) if c in money else F.col(c)
         for c in df.columns]
df = df.select(*exprs)

# Composable steps
def add_audit(d):
    return d.withColumns({"loaded_at": F.current_timestamp(),
                          "src_file":  F.input_file_name()})

df = raw.transform(add_audit).transform(dedupe).transform(conform)
06

Column functions

All narrow. All executed in the JVM with code generation — which is exactly why you reach here before reaching for a UDF (§11).

Conditionals & nulls

F.when(c, v).when(c2, v2).otherwise(v3)
CASE WHEN. No otherwise means null for unmatched rows.
narrow
F.coalesce(a, b, lit(0))
First non-null. Not to be confused with df.coalesce(n), which repartitions.
narrow
col.isNull() · col.isNotNull() · F.isnan()
Null tests. NaN is not null in Spark.
narrow
a.eqNullSafe(b)
Null-safe equality (<=>). Two nulls compare true. Essential for hash-key comparison in CDC.
narrow
df.na.fill({...}) · .na.drop(how, subset) · .na.replace()
Bulk null handling by column map.
narrow
F.nullif(a, b) · F.nvl · F.ifnull
SQL-compatible null helpers.
narrow

Strings

F.concat_ws(sep, *cols)
Join with separator, skipping nulls. Use for building hash inputs.
narrow
F.upper · lower · trim · ltrim · rtrim · lpad · rpad
The conforming set. Apply consistently before hashing or joining.
narrow
F.substring(c, pos, len) · F.split(c, pattern)
substring is 1-indexed. split returns an array.
narrow
F.regexp_replace · regexp_extract · regexp_extract_all
Java regex syntax. Group 0 is the whole match.
narrow
col.like("%x%") · .rlike(re) · .contains · startswith · endswith
Predicates. like may push down; rlike generally will not.
narrow
F.md5 · sha2(col, 256) · xxhash64 · hash
Change-detection keys. sha2 for stable cross-system hashes; xxhash64 when speed matters and collisions are tolerable.
narrow
F.format_number · format_string · printf
Presentation formatting. Returns strings — never use for arithmetic.
narrow

Dates & timestamps

F.current_date() · F.current_timestamp()
Evaluated once per query, not per row.
narrow
F.to_date(c, fmt) · F.to_timestamp(c, fmt)
Parse. Patterns follow Java DateTimeFormatter: yyyy-MM-dd HH:mm:ss.
narrow
F.date_format(c, fmt)
Render to string. Beware YYYY (week-year) vs yyyy — a classic year-end bug.
narrow
F.date_add · date_sub · add_months · months_between
Calendar arithmetic.
narrow
F.date_trunc("month", ts) · F.trunc(d, "MM")
Period grain for reporting. date_trunc for timestamps, trunc for dates.
narrow
F.datediff(end, start) · F.last_day · next_day
Day differences and month-end — the accounting workhorses.
narrow
F.year · quarter · month · dayofmonth · weekofyear · hour
Date-part extraction for conformed date dimensions.
narrow
F.unix_timestamp · from_unixtime · to_utc_timestamp · from_utc_timestamp
Epoch and zone conversion. Store UTC, convert at the presentation layer.
narrow

Numbers & misc

F.round(c, 2) · bround · floor · ceil · abs
bround is banker's rounding — the correct choice in some regulated reporting contexts.
narrow
F.lit(v) · F.expr("sql text")
Literal and escape hatch. expr takes any SQL expression string.
narrow
F.monotonically_increasing_id()
Unique but not consecutive, and partition-dependent. Never use as a surrogate key across runs.
narrow
F.greatest(*c) · F.least(*c)
Row-wise max/min across columns, null-skipping.
narrow
F.assert_true(cond) · F.raise_error(msg)
Fail the job from inside an expression. Inline contract enforcement.
narrow
07

Aggregations

agg = (df.groupBy("policy_no", F.date_trunc("month", "ts").alias("period"))
    .agg(
        F.sum("paid").alias("paid_total"),
        F.countDistinct("claim_id").alias("claims"),
        F.max("ts").alias("last_event"),
        F.collect_set("peril").alias("perils"),
        F.sum(F.when(F.col("status") == "OPEN", F.col("paid"))).alias("open_paid"),
    ))
df.groupBy(...).agg(...)
Partial aggregation happens map-side first, so the shuffle carries aggregates rather than rows. Cheap relative to a join.
shuffle
F.sum · avg · min · max · count
count("col") skips nulls; count("*") and count(lit(1)) do not.
shuffle
F.countDistinct(*cols)
Exact and expensive — cannot be partially aggregated in the same way.
shuffle
F.approx_count_distinct(c, rsd=0.05)
HyperLogLog. Orders of magnitude cheaper; use whenever the number feeds a dashboard rather than a reconciliation.
shuffle
F.collect_list · collect_set
Materialises a full group into one array. Unbounded memory per group — dangerous on skewed keys.
shuffle
F.first(c, ignorenulls=True) · F.last
Non-deterministic without an ordering. Prefer a window function when order matters.
shuffle
.groupBy(k).pivot("col", values)
Always supply the value list — otherwise Spark runs an extra job to discover distinct values.
shuffle
.rollup(...) · .cube(...)
Subtotals and all-combination totals. Use F.grouping_id() to identify the aggregation level.
shuffle
df.agg(...)
Global aggregate, no grouping. Collapses to a single partition.
shuffle
F.percentile_approx(c, 0.5) · df.approxQuantile()
Approximate quantiles with configurable error.
shuffle
df.summary() · df.describe()
Quick profile: count, mean, stddev, quartiles. Exploratory only.
action
Conditional aggregation beats filter-then-join. F.sum(F.when(cond, col)) gives you a filtered measure inside a single pass. Building each measure as a separate filtered DataFrame and joining them back costs one shuffle per measure.
08

Joins

# Column list form — no duplicate key columns in the output
j = fact.join(dim, on=["policy_key"], how="left")

# Expression form — full control, but both key columns survive
j = fact.alias("f").join(dim.alias("d"),
        (F.col("f.policy_key") == F.col("d.policy_key")) &
        (F.col("f.event_ts").between(F.col("d.valid_from"), F.col("d.valid_to"))),
        how="left")                       # SCD2 point-in-time lookup

# Force a broadcast when you know the side is small
j = fact.join(F.broadcast(dim), "policy_key", "left")

Join types

inner · left · right · full · cross
Standard. cross requires explicit intent — Spark blocks accidental cartesians unless you ask.
shuffle
left_semi
Exists-check. Returns left columns only, no row multiplication. Replaces isin(subquery).
shuffle
left_anti
Not-exists. The clean way to find new or deleted keys in a CDC delta.
shuffle

Physical strategies — what the planner actually picks

Broadcast hash join
Small side shipped to every executor; large side never moves. No shuffle. Triggered under autoBroadcastJoinThreshold (default 10MB) or by hint.
narrow
Sort-merge join
The default for two large sides. Shuffles both, sorts both, merges. Predictable but the expensive one.
shuffle
Shuffle hash join
Shuffles both, builds a hash table on the smaller. Faster than SMJ when one side fits in memory and no sort is needed downstream.
shuffle
Broadcast nested loop
The fallback for non-equi joins. O(n·m). If you see this on big data, it is why the job is not finishing.
shuffle

Hints

F.broadcast(df) · df.hint("broadcast")
Force the broadcast. Overrides the size threshold — and will OOM executors if the side is not actually small.
hint
df.hint("merge" | "shuffle_hash" | "shuffle_replicate_nl")
Pin the strategy when AQE keeps choosing badly.
hint
/*+ BROADCAST(d) */
SQL form of the same.
hint
Ambiguous columns. After join(other, expr) both key columns exist with the same name and select("k") raises AnalysisException. Either join with the string/list form, alias both sides and qualify every reference, or drop the duplicate immediately: j.drop(dim["policy_key"]).
Row explosion. A left join against a dimension with duplicate business keys multiplies your fact rows and quietly doubles every measure. Assert uniqueness of the join key before the join, not after the reconciliation fails.
09

Window functions

w = Window.partitionBy("policy_no").orderBy(F.col("ts").desc())

latest = (df
    .withColumn("rn", F.row_number().over(w))
    .filter("rn = 1").drop("rn"))        # deterministic dedupe

# Running total — explicit frame
wr = (Window.partitionBy("policy_no").orderBy("ts")
        .rowsBetween(Window.unboundedPreceding, Window.currentRow))
df = df.withColumn("cum_paid", F.sum("paid").over(wr))

# SCD2 close-out: next row's start becomes this row's end
ws = Window.partitionBy("nk").orderBy("valid_from")
df = df.withColumn("valid_to",
        F.coalesce(F.lead("valid_from").over(ws) - F.expr("INTERVAL 1 SECOND"),
                   F.lit("9999-12-31").cast("timestamp")))
row_number()
1,2,3 — no ties. The dedupe and "latest record" tool.
shuffle
rank() · dense_rank()
Ties share a rank; rank leaves gaps, dense_rank does not.
shuffle
lag(c, n, default) · lead(c, n, default)
Previous/next row. Period-on-period deltas and interval close-out.
shuffle
ntile(n) · percent_rank() · cume_dist()
Bucketing and distribution position.
shuffle
sum/avg/min/max(...).over(w)
Aggregate without collapsing rows. Running totals, group shares, moving averages.
shuffle
.rowsBetween(a, b)
Physical frame — counts rows. Predictable. Use this for moving windows.
frame
.rangeBetween(a, b)
Logical frame — compares values of the ordering column. Ties collapse into one frame.
frame
Window.unboundedPreceding / currentRow / unboundedFollowing
Frame bounds.
frame
Two frame defaults, and they differ. With orderBy, the default frame is RANGE UNBOUNDED PRECEDING → CURRENT ROW, so an aggregate becomes a running total. Without orderBy, the frame is the entire partition, so it becomes a group total. If you meant a group total but added an ordering, you silently get a running one. State the frame explicitly whenever you aggregate over a window.
No partitionBy = one partition. Window.orderBy("ts") alone moves the whole dataset into a single task. Spark warns about it; the job then hangs or spills. Always partition, even if by a coarse key.
10

Nested data & reshaping

# Flatten a struct
df.select("party.*")
df.select(F.col("party.name").alias("party_name"))

# Array to rows
df.select("claim_id", F.explode("perils").alias("peril"))
df.select("claim_id", F.explode_outer("perils").alias("peril"))   # keeps empty/null
df.select("claim_id", F.posexplode("perils").alias("pos", "peril"))

# JSON string column to struct
df.withColumn("payload", F.from_json("raw", schema))
df.withColumn("raw", F.to_json("payload"))
spark.read.json(df.rdd.map(lambda r: r.raw))   # infer schema from a sample

# Unpivot: wide to long (3.4+ native)
long = df.unpivot(["claim_id"], ["jan", "feb", "mar"], "month", "amount")
# pre-3.4 equivalent
long = df.select("claim_id", F.expr(
    "stack(3, 'jan', jan, 'feb', feb, 'mar', mar) as (month, amount)"))
F.struct(*cols) · F.array(*cols) · F.create_map(...)
Build nested values.
narrow
F.get_json_object · json_tuple
Extract from JSON strings without a schema. Slower than from_json with a schema.
narrow
F.schema_of_json(sample)
Derive a DDL schema string from one example document.
narrow
F.transform · filter · exists · forall · aggregate
Higher-order functions over arrays with lambdas. Native speed, no UDF needed.
narrow
F.array_contains · size · sort_array · array_distinct · slice
Array manipulation set.
narrow
F.arrays_zip · flatten · sequence
Zip parallel arrays, flatten nesting, generate ranges (date spines for calendar dimensions).
narrow
F.map_keys · map_values · map_from_entries · explode(map)
Map access; exploding a map yields key and value columns.
narrow
df.union(o) vs df.unionByName(o, allowMissingColumns=True)
union matches by position — a silent data-corruption risk. Always prefer unionByName.
narrow
df.intersect(o) · df.exceptAll(o)
Set operations. exceptAll keeps duplicates — useful for row-level diffs in reconciliation tests.
shuffle
Higher-order functions over UDFs. F.filter("items", lambda x: x["qty"] > 0) stays inside the JVM. The same logic in a Python UDF serialises every array to Python and back. On nested data the gap is usually 10–50×.
11

UDFs & the pandas API

Ordered from cheapest to most expensive. Exhaust the option above before reaching for the one below.

1. Built-in F.* / F.expr
Code-generated, optimisable, pushdown-aware. Covers more than most people assume — check §06 and §10 before writing anything custom.
fastest
2. Higher-order functions
F.transform, F.aggregate and friends handle per-element array logic natively.
fast
3. pandas_udf (vectorised)
Arrow-batched, operates on pd.Series. Typically 3–100× a plain Python UDF.
moderate
4. applyInPandas / mapInPandas
Whole partition or whole group as a DataFrame. For genuinely stateful or library-dependent logic.
moderate
5. Plain @udf
Row-at-a-time pickle round-trip. Opaque to Catalyst — blocks predicate pushdown and defeats code generation.
slowest
# Vectorised: Series -> Series
from pyspark.sql.functions import pandas_udf

@pandas_udf("double")
def fx(amount: pd.Series, rate: pd.Series) -> pd.Series:
    return amount * rate

df.withColumn("gbp", fx("amount", "rate"))

# Iterator variant — set up an expensive resource once per batch stream
from typing import Iterator

@pandas_udf("string")
def classify(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
    model = load_model()          # once per executor task, not per row
    for s in it:
        yield pd.Series(model.predict(s))

# Grouped map: entire group as a pandas DataFrame
def reconcile(pdf: pd.DataFrame) -> pd.DataFrame:
    pdf["variance"] = pdf["actual"] - pdf["budget"].cumsum()
    return pdf

df.groupBy("cost_centre").applyInPandas(reconcile, schema="cost_centre string, variance double")

# Plain UDF — last resort. Always declare the return type.
from pyspark.sql.functions import udf

@udf(T.StringType())
def normalise(s):
    return None if s is None else s.strip().upper()
UDFs must be null-safe. Spark calls your function with None. A UDF that assumes a string throws Py4JJavaError deep inside a task, and the stack trace points at the executor rather than your code. Guard the null branch first, every time.
Pandas API on Spark. import pyspark.pandas as ps gives a pandas-shaped surface over a distributed frame — good for porting existing analyst code, but it will happily generate hidden shuffles (any operation needing a global index). Read the plan before trusting it in production.
12

Spark SQL & catalog

df.createOrReplaceTempView("claims")          # session-scoped
df.createOrReplaceGlobalTempView("claims")    # cross-session: global_temp.claims

out = spark.sql("""
    SELECT policy_no, SUM(paid) AS paid
    FROM claims
    WHERE ts >= :cutoff
    GROUP BY policy_no
""", cutoff="2026-01-01")                    # parameterised (3.4+) — no string concat

spark.catalog.listDatabases()
spark.catalog.listTables("analytics")
spark.catalog.tableExists("analytics.dim_policy")
spark.catalog.refreshTable("analytics.fct_claim")   # after external writes
spark.catalog.clearCache()
spark.sql(text, **params)
Returns a DataFrame. Identical optimiser path to the DataFrame API — mix them freely by preference.
plan
spark.udf.register("f", py_fn, T.StringType())
Expose a Python function to SQL. Same performance caveats as §11.
register
CACHE TABLE t · UNCACHE TABLE t
SQL cache control. CACHE is eager, unlike df.cache().
action
ANALYZE TABLE t COMPUTE STATISTICS FOR ALL COLUMNS
Feeds cost-based optimisation and join-strategy selection. Frequently the cheapest tuning available.
action
MSCK REPAIR TABLE t
Rediscover Hive partitions after files land out-of-band. Common in Glue-catalogued lakes.
action
WITH cte AS (...)
CTEs are inlined, not materialised. Referencing one twice recomputes it — cache or checkpoint if the branch is expensive.
plan
13

Writing data

(df.write
   .format("parquet")
   .mode("overwrite")                      # append | overwrite | ignore | error
   .partitionBy("event_date")
   .option("compression", "snappy")
   .save("s3://bucket/curated/claims/"))

# Replace only the partitions present in this batch, leave the rest alone
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")

# Control file count before writing
df.repartition("event_date").write.partitionBy("event_date").save(path)

# Managed / catalogued table
df.writeTo("catalog.schema.fct_claim").partitionedBy("event_date").createOrReplace()
.mode("overwrite")
Deletes the target path first. Destructive and not atomic on object stores — one reason to use Delta or Iceberg.
action
.partitionBy(cols)
Hive-style directories. Choose low cardinality columns that appear in filters. Date is usually right; customer ID never is.
action
.bucketBy(n, col).sortBy(col).saveAsTable(t)
Pre-shuffles on disk so future joins on that key skip the exchange. Table-only; requires a metastore.
action
.option("maxRecordsPerFile", n)
Caps file size without an extra repartition.
option
.insertInto(t)
Positional column matching — not by name. A schema change upstream silently shifts your data.
action
.writeTo(t).append() / .overwritePartitions()
DataFrameWriterV2. Cleaner semantics; preferred for Delta and Iceberg.
action
Small files. 200 shuffle partitions × 365 date partitions = 73,000 files, most of them a few KB. Query planning then takes longer than the query. Repartition on the partition column before writing, or set maxRecordsPerFile, and target roughly 128MB–1GB per file.
14

Partitioning & caching

df.repartition(n)
Full shuffle to exactly n even partitions. Use to increase parallelism or fix skew.
shuffle
df.repartition("key")
Hash-partition by column. Co-locates a key so a following aggregation or join avoids its own exchange.
shuffle
df.repartitionByRange(n, "key")
Range partitioning via sampling. Better for sorted output and for keys with clustered distribution.
shuffle
df.coalesce(n)
Merges partitions without a shuffle — but the reduced parallelism propagates backwards through the whole stage. coalesce(1) before a wide transformation runs that transformation single-threaded.
narrow
df.rdd.getNumPartitions()
Current partition count. First thing to check when a stage has one straggler task.
info
F.spark_partition_id()
Partition of each row. Group by it to measure skew directly.
narrow

Caching

df.cache()
Lazy — materialises on the next action. Level is MEMORY_AND_DISK for DataFrames.
lazy
df.persist(StorageLevel.MEMORY_AND_DISK_SER)
Explicit level. Serialised trades CPU for a much smaller footprint.
lazy
df.unpersist()
Always clean up. Stale cached blocks evict the ones you actually need.
evict
df.checkpoint()
Writes to reliable storage and truncates the lineage. The fix for iterative loops whose plans grow unboundedly.
action
df.localCheckpoint()
Same truncation, executor-local storage, no HDFS/S3 requirement. Not fault tolerant.
action
Cache is not free. It consumes the same memory the shuffle needs. Cache only when a DataFrame is read more than once and is expensive to recompute. A single linear pipeline should cache nothing.
15

Performance tuning

Diagnose in this order

1. Is it skew?
Spark UI: one task's duration or shuffle-read size is many times the median. Fix with AQE skew join, salting, or broadcast.
common
2. Is it spill?
Non-zero "Spill (disk)" in the stage detail. More partitions, more executor memory, or less data per key.
common
3. Is it file count?
Thousands of tiny inputs, or long job-start latency before any task runs. Compact the source.
common
4. Is it an avoidable shuffle?
Count Exchange nodes in explain. Can a dimension be broadcast? Can two aggregations share one grouping?
common
5. Is it Python?
Stage time dominated by BatchEvalPython. Replace the UDF, or vectorise it.
common

Skew: salting

# One key holds 60% of the rows; that key's task decides your runtime.
N = 32
fact_s = fact.withColumn("salt", (F.rand() * N).cast("int"))
dim_s  = (dim
    .withColumn("salt", F.explode(F.sequence(F.lit(0), F.lit(N - 1))))) # fan out

joined = (fact_s.join(dim_s, ["policy_key", "salt"], "left")
                .drop("salt"))

Key configuration

spark.sql.shuffle.partitions = 200
Post-shuffle partition count. With AQE on, treat this as a ceiling — AQE coalesces down. Raise it for very large shuffles.
core
spark.sql.adaptive.enabled = true
Master AQE switch. Leave on.
core
spark.sql.adaptive.coalescePartitions.enabled
Merges small post-shuffle partitions using real statistics. Removes most manual partition tuning.
aqe
spark.sql.adaptive.skewJoin.enabled
Splits outsized partitions during sort-merge joins automatically. Handles moderate skew without salting.
aqe
spark.sql.adaptive.advisoryPartitionSizeInBytes = 64MB
Target size AQE aims for when coalescing.
aqe
spark.sql.autoBroadcastJoinThreshold = 10MB
Auto-broadcast cutoff. Raising to 50–100MB is common on well-resourced clusters. -1 disables.
join
spark.sql.files.maxPartitionBytes = 128MB
Input split size — sets your initial partition count on file sources.
io
spark.sql.optimizer.dynamicPartitionPruning.enabled
Prunes fact partitions using the filtered dimension at runtime. Enormous win on star schemas.
join
spark.sql.execution.arrow.pyspark.enabled
Columnar transfer for toPandas and pandas UDFs.
python
spark.executor.memoryOverhead
Off-heap headroom. Python workers live here — raise it when PySpark jobs get killed by the resource manager.
memory
spark.sql.parquet.filterPushdown
Predicate pushdown to the file reader. On by default; a Python UDF in the predicate silently disables it.
io
Star schema note. Join depth equals shuffle count. If every dimension broadcasts, a ten-table star costs one scan of the fact and zero exchanges. That is the whole argument for keeping dimensions narrow and broadcastable rather than snowflaking them.
16

Structured Streaming

Same DataFrame API, incremental execution. Micro-batches by default.

src = (spark.readStream
    .format("cloudFiles")                       # Databricks Auto Loader
    .option("cloudFiles.format", "json")
    .schema(schema)
    .load("s3://bucket/landing/"))

agg = (src
    .withWatermark("event_ts", "10 minutes")      # bounds state; drops late data
    .groupBy(F.window("event_ts", "5 minutes"), "policy_no")
    .agg(F.sum("paid").alias("paid")))

q = (agg.writeStream
    .format("delta")
    .outputMode("append")
    .option("checkpointLocation", "s3://bucket/_ckpt/claims_agg")
    .trigger(availableNow=True)                 # catch up, then stop
    .start("s3://bucket/curated/claims_agg"))

q.awaitTermination()
.outputMode("append")
Only new final rows. Requires a watermark for aggregations.
mode
.outputMode("update")
Rows whose value changed this batch. Pairs naturally with a MERGE sink.
mode
.outputMode("complete")
Rewrites the entire result each batch. Only viable for small aggregates.
mode
.trigger(processingTime="1 minute")
Fixed cadence micro-batches.
trigger
.trigger(availableNow=True)
Process all available data in multiple batches, then stop. The batch-on-streaming-infrastructure pattern — replaces once=True.
trigger
.foreachBatch(fn)
Each micro-batch as a normal DataFrame. The escape hatch for MERGE upserts and multi-sink writes.
sink
.withWatermark(col, delay)
Tells Spark how late data can be, so state can be dropped. Without it, stateful queries grow until they fail.
state
checkpointLocation
Offsets plus state. Deleting it resets the stream; changing the query shape can make it incompatible. One checkpoint per query, never shared.
state
.option("maxFilesPerTrigger", n) · maxBytesPerTrigger
Rate limiting so backfills don't produce one enormous batch.
option
q.lastProgress · q.status · spark.streams.active
Observability. lastProgress gives input rate, processing rate and state row counts.
info
Watermark and output mode are one decision. Append mode emits a window only once the watermark has passed its end, so results lag by the watermark delay. Choose the delay from real observed lateness — not from the reporting SLA you wish you had.
17

Delta Lake

from delta.tables import DeltaTable

# SCD1 upsert
tgt = DeltaTable.forPath(spark, "s3://bucket/silver/dim_policy")
(tgt.alias("t")
   .merge(updates.alias("s"), "t.policy_key = s.policy_key")
   .whenMatchedUpdateAll(condition="t.hash_key <> s.hash_key")   # skip no-op rows
   .whenNotMatchedInsertAll()
   .whenNotMatchedBySourceUpdate(set={"is_deleted": "true"})       # soft delete
   .execute())

# Time travel
spark.read.format("delta").option("versionAsOf", 42).load(path)
spark.read.format("delta").option("timestampAsOf", "2026-08-01").load(path)

# Change Data Feed — downstream incremental consumption
(spark.read.format("delta")
   .option("readChangeFeed", "true")
   .option("startingVersion", 42).load(path))   # adds _change_type, _commit_version
MERGE INTO
Upsert, SCD1/SCD2 and soft deletes in one atomic commit. Narrow the match condition with a partition or date predicate or it rewrites the whole table.
action
OPTIMIZE t
Compacts small files. Run after streaming ingestion or many small MERGEs.
action
OPTIMIZE t ZORDER BY (col)
Multi-dimensional clustering so file-skipping works on non-partition predicates.
action
CLUSTER BY (col)
Liquid clustering. Replaces partitioning plus Z-order; adapts as the data evolves and avoids the cardinality trap.
ddl
VACUUM t RETAIN 168 HOURS
Deletes unreferenced files. Destroys time travel beyond the retention window.
action
DESCRIBE HISTORY t
Full commit log: operation, predicate, row counts, user. Your audit trail.
action
.option("mergeSchema", "true")
Additive schema evolution on write.
option
.option("overwriteSchema", "true")
Replace the schema entirely. Deliberate breaking change only.
option
RESTORE TABLE t TO VERSION AS OF n
Roll back a bad load without restoring from backup.
action
deltaTable.generate("symlink_format_manifest")
Expose a Delta table to engines that only read Parquet (Athena, Presto).
action
Iceberg and Hudi occupy the same slot with the same primitives — snapshot isolation, time travel, MERGE, compaction. Iceberg adds hidden partitioning and partition evolution; the tuning instincts transfer directly.
18

Testing & data quality

# Built-in test helpers (3.5+)
from pyspark.testing import assertDataFrameEqual, assertSchemaEqual

assertDataFrameEqual(actual, expected, checkRowOrder=False, rtol=1e-6)
assertSchemaEqual(actual.schema, expected.schema)

# Session fixture — reuse one session for the whole suite
@pytest.fixture(scope="session")
def spark():
    s = (SparkSession.builder.master("local[2]")
         .config("spark.sql.shuffle.partitions", "2")   # 200 is absurd on 10 rows
         .config("spark.sql.session.timeZone", "UTC")
         .getOrCreate())
    yield s
    s.stop()

# Inline contracts on the pipeline itself
df.observe("dq", F.count(F.lit(1)).alias("rows"),
                 F.sum(F.col("paid").isNull().cast("int")).alias("null_paid"))
assertDataFrameEqual
Row-order-insensitive comparison with float tolerance. Removes most bespoke test helpers.
test
df.observe(name, *metrics)
Collect metrics during the existing pass — no second scan. Read them from the query listener or lastProgress.
narrow
df.exceptAll(expected)
Row-level diff both ways for reconciliation. Non-empty either direction means a mismatch.
shuffle
Uniqueness assertion
df.groupBy(keys).count().filter("count > 1").isEmpty() — run before every dimension join.
shuffle
Great Expectations / Soda / dbt tests
Declarative suites with documentation and history. Best value at layer boundaries rather than on every intermediate step.
framework
Structure for testability. Keep I/O at the edges and put the logic in pure DataFrame → DataFrame functions. Then tests construct small frames with spark.createDataFrame and never touch S3, and the same function composes into the pipeline via df.transform(fn).
19

Debugging & observability

df.explain(mode="formatted")
The most readable plan view. Modes: simple, extended, codegen, cost, formatted.
plan
Read the plan bottom-up
Scans at the bottom, result at the top. Count Exchange (shuffles), check PushedFilters, confirm which join node the planner chose.
plan
df.printSchema() · df.columns · df.dtypes
Free, no job triggered.
info
Spark UI → Stages
Task duration distribution. A max far above the median is skew. Shuffle read/write and spill columns live here too.
ui
Spark UI → SQL
Per-node row counts on the actual plan. Where you find the join that produced ten times the rows you expected.
ui
spark.sparkContext.setJobDescription(s)
Label jobs so the UI is navigable in a long pipeline.
ui
df.groupBy(F.spark_partition_id()).count()
Direct measurement of partition balance.
action

Errors and what they usually mean

AnalysisException: cannot resolve 'x'
Plan-time. Typo, wrong case, or an ambiguous column after a join. Check df.columns.
plan
Column is not iterable
A Python builtin applied to a Column — len(col), max(col), if col:. Use F.length, F.greatest, F.when.
syntax
PicklingError: cannot pickle ...
A UDF closed over a connection, client or module object. Construct it inside the function, or use an iterator pandas UDF.
udf
OutOfMemoryError on the driver
Almost always collect(), toPandas(), or an oversized broadcast. Also caused by a plan with tens of thousands of nodes.
memory
Container killed by YARN / exit 137
Overhead memory exhausted — usually the Python workers. Raise memoryOverhead or reduce per-task data.
memory
Job aborted due to stage failure ... FetchFailed
An executor died and its shuffle files went with it. Symptom of memory pressure or a lost node, not a code bug.
shuffle
Precedence errors in filters
Python's & binds tighter than ==. Parenthesise every comparison: (a == 1) & (b > 2). Use & | ~, never and or not.
syntax
20

Gotchas worth memorising

Lazy means recomputed
Two actions on the same DataFrame run the lineage twice. Cache, or restructure so there is one action.
cost
union matches by position
Two frames with the same columns in different orders will union silently and wrongly. Use unionByName.
correctness
coalesce(1) is a bottleneck, not an optimisation
It reduces parallelism upstream. If you need one output file, write normally then compact, or repartition(1) to keep the earlier stages parallel.
cost
monotonically_increasing_id is not a surrogate key
Values depend on partitioning and change between runs. Use a hash of the natural key, or a proper key-generation step.
correctness
Float money
DoubleType sums will not tie back. DecimalType(18,2) throughout.
correctness
Null-aware comparison
a == b is null when either is null, so the row fails the filter. Use eqNullSafe when comparing hash keys across snapshots.
correctness
YYYY vs yyyy
Week-year vs calendar year. Rows in the last days of December land in the following year.
correctness
Partitioning on a high-cardinality column
One directory per customer produces millions of directories and a metastore that cannot be listed. Partition on date; cluster or Z-order on the rest.
cost
Chained withColumn in a loop
Plan grows quadratically; the driver stalls in analysis. One select with a list of expressions.
cost
UDFs block pushdown
A UDF in a where clause forces a full scan — the filter can no longer reach the file reader.
cost
Overwrite is not atomic on S3
A failure mid-write leaves the target partially deleted. Use Delta/Iceberg, or write to a new path and swap.
correctness
Case sensitivity is off by default
spark.sql.caseSensitive=false. Two source columns differing only in case collide on read.
correctness
21

RDDs & low-level

You should rarely need this layer — no Catalyst, no Tungsten, no code generation. Worth recognising for legacy code and for the few things the DataFrame API cannot express.

df.rdd · rdd.toDF(schema)
Conversions. Going to RDD costs full deserialisation of every row into Python objects.
expensive
rdd.mapPartitions(fn)
Per-partition processing — amortise connection setup across rows. mapInPandas is the modern equivalent.
narrow
reduceByKey vs groupByKey
reduceByKey combines map-side and shuffles far less. The canonical RDD-era optimisation.
shuffle
sc.broadcast(obj) · bv.value
Ship a lookup dict or model to every executor once, rather than per task.
narrow
sc.accumulator(0)
Write-only counter aggregated at the driver. Not reliable under task retries — prefer df.observe.
metric
sc.addPyFile · sc.addFile
Distribute code or reference files to executors.
setup

What is new and worth knowing

Spark Connect (3.4+)
Client/server split. Thin Python client, no local JVM — the basis for Databricks Connect and IDE-native development.
3.4
Python data source API (4.0)
Write custom readers and writers in pure Python instead of Scala.
4.0
VariantType (4.0)
Efficient semi-structured storage with fast path extraction — the right home for irregular JSON payloads.
4.0
String collations (4.0)
Per-column collation, including case-insensitive comparison without upper() everywhere.
4.0
ANSI mode default (4.0)
Overflows and bad casts now raise instead of returning null. Test 3.x pipelines against this before upgrading.
4.0
df.plot (4.0)
Native plotting on DataFrames for quick inspection.
4.0
22

Platform notes

Databricks
spark and dbutils pre-injected. Photon replaces parts of the execution engine — some UDF patterns fall back to Spark and lose the benefit. Auto Loader (cloudFiles) for incremental file ingestion; Unity Catalog for three-level namespaces.
platform
AWS Glue
Session via glueContext.spark_session. DynamicFrame adds schema-flexible handling and resolve-choice semantics; toDF() / fromDF() to move between the two. Job bookmarks provide the incremental state.
platform
EMR / EMR Serverless
Closest to open-source Spark. You own executor sizing and the S3 committer choice — use the magic or directory committer deliberately.
platform
Local development
master("local[*]"), and drop shuffle.partitions to 2–8. The default 200 makes a ten-row test suite take minutes.
platform
Portability. The DataFrame API is the same everywhere. What differs is the session bootstrap, the catalog namespace, the file committer, and the incremental-state mechanism. Isolate those four things behind a thin layer and pipelines move between platforms with the logic untouched.
PySpark working reference · DataFrame API · Spark 3.4 – 4.0 narrow · shuffle · action — the only three costs that matter

Get in touch!

What type of project are you interested in?
Where can I reach you?
Where would you like to discuss?