Execution model
The mental model that makes the rest of this document predictable. Get this right and tuning stops being guesswork.
The cast
collect().Narrow vs wide, concretely
select · filter · withColumn · union · coalesce · map-side opsgroupBy · join · distinct · orderBy · repartition · windowdf.explain(). That number, times your data volume, is roughly your runtime. Everything in §15 is about reducing one or the other.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)spark.sql.* keys are settable mid-session; cluster/memory keys are not.spark.conf.get(k)spark.version · spark.sparkContext.uiWebUrlspark.range(n)id. Ideal for testing skew, joins and window behaviour.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.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).format("delta").load(path).json(path)multiLine=True for arrays — but multiLine files are not splittable, so one file = one task..csv(path).table("cat.schema.tbl").option("basePath", p).option("recursiveFileLookup", True).option("pathGlobFilter", "*.parquet")F.input_file_name().schema() and you halve the I/O — and you stop a stray value silently retyping a column between runs.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)DoubleType will not reconcile to the ledger. Max precision 38; watch precision growth on multiply.TimestampType vs TimestampNTZTypeArrayType · MapType · StructTypeVariantTypecol.cast("decimal(18,2)")null on failure unless ANSI mode is on.try_cast · try_divide · try_addspark.sql.ansi.enabledCore DataFrame operations
df.select("a", F.col("b").alias("c"))df.selectExpr("a", "b * 1.2 as gross")df.filter(cond) / .where(cond)df.withColumn("x", expr)df.withColumns({"x": e1, "y": e2})withColumn.df.withColumnRenamed(a, b) · .withColumnsRenamed({...})df.drop("a", "b")df.distinct() · .dropDuplicates([cols])dropDuplicates keeps an arbitrary row — use a window (§09) when you need a deterministic winner.df.orderBy(F.col("x").desc())df.sortWithinPartitions(...)df.limit(n)df.sample(fraction, seed=42)sampleBy for stratified.df.transform(fn)df.show(20, truncate=False)df.count()df.collect() · .toPandas()df.take(n) · .head(n) · .first()df.toLocalIterator()collect().df.isEmpty()count() == 0; stops after the first row.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)
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)otherwise means null for unmatched rows.F.coalesce(a, b, lit(0))df.coalesce(n), which repartitions.col.isNull() · col.isNotNull() · F.isnan()a.eqNullSafe(b)<=>). Two nulls compare true. Essential for hash-key comparison in CDC.df.na.fill({...}) · .na.drop(how, subset) · .na.replace()F.nullif(a, b) · F.nvl · F.ifnullStrings
F.concat_ws(sep, *cols)F.upper · lower · trim · ltrim · rtrim · lpad · rpadF.substring(c, pos, len) · F.split(c, pattern)substring is 1-indexed. split returns an array.F.regexp_replace · regexp_extract · regexp_extract_allcol.like("%x%") · .rlike(re) · .contains · startswith · endswithlike may push down; rlike generally will not.F.md5 · sha2(col, 256) · xxhash64 · hashsha2 for stable cross-system hashes; xxhash64 when speed matters and collisions are tolerable.F.format_number · format_string · printfDates & timestamps
F.current_date() · F.current_timestamp()F.to_date(c, fmt) · F.to_timestamp(c, fmt)DateTimeFormatter: yyyy-MM-dd HH:mm:ss.F.date_format(c, fmt)YYYY (week-year) vs yyyy — a classic year-end bug.F.date_add · date_sub · add_months · months_betweenF.date_trunc("month", ts) · F.trunc(d, "MM")date_trunc for timestamps, trunc for dates.F.datediff(end, start) · F.last_day · next_dayF.year · quarter · month · dayofmonth · weekofyear · hourF.unix_timestamp · from_unixtime · to_utc_timestamp · from_utc_timestampNumbers & misc
F.round(c, 2) · bround · floor · ceil · absbround is banker's rounding — the correct choice in some regulated reporting contexts.F.lit(v) · F.expr("sql text")expr takes any SQL expression string.F.monotonically_increasing_id()F.greatest(*c) · F.least(*c)F.assert_true(cond) · F.raise_error(msg)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(...)F.sum · avg · min · max · countcount("col") skips nulls; count("*") and count(lit(1)) do not.F.countDistinct(*cols)F.approx_count_distinct(c, rsd=0.05)F.collect_list · collect_setF.first(c, ignorenulls=True) · F.last.groupBy(k).pivot("col", values).rollup(...) · .cube(...)F.grouping_id() to identify the aggregation level.df.agg(...)F.percentile_approx(c, 0.5) · df.approxQuantile()df.summary() · df.describe()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.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 · crosscross requires explicit intent — Spark blocks accidental cartesians unless you ask.left_semiisin(subquery).left_antiPhysical strategies — what the planner actually picks
autoBroadcastJoinThreshold (default 10MB) or by hint.Hints
F.broadcast(df) · df.hint("broadcast")df.hint("merge" | "shuffle_hash" | "shuffle_replicate_nl")/*+ BROADCAST(d) */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"]).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()rank() · dense_rank()rank leaves gaps, dense_rank does not.lag(c, n, default) · lead(c, n, default)ntile(n) · percent_rank() · cume_dist()sum/avg/min/max(...).over(w).rowsBetween(a, b).rangeBetween(a, b)Window.unboundedPreceding / currentRow / unboundedFollowingorderBy, 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.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.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(...)F.get_json_object · json_tuplefrom_json with a schema.F.schema_of_json(sample)F.transform · filter · exists · forall · aggregateF.array_contains · size · sort_array · array_distinct · sliceF.arrays_zip · flatten · sequenceF.map_keys · map_values · map_from_entries · explode(map)df.union(o) vs df.unionByName(o, allowMissingColumns=True)union matches by position — a silent data-corruption risk. Always prefer unionByName.df.intersect(o) · df.exceptAll(o)exceptAll keeps duplicates — useful for row-level diffs in reconciliation tests.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×.UDFs & the pandas API
Ordered from cheapest to most expensive. Exhaust the option above before reaching for the one below.
F.* / F.exprF.transform, F.aggregate and friends handle per-element array logic natively.pandas_udf (vectorised)pd.Series. Typically 3–100× a plain Python UDF.applyInPandas / mapInPandas@udf# 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()
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.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.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)spark.udf.register("f", py_fn, T.StringType())CACHE TABLE t · UNCACHE TABLE tCACHE is eager, unlike df.cache().ANALYZE TABLE t COMPUTE STATISTICS FOR ALL COLUMNSMSCK REPAIR TABLE tWITH cte AS (...)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").partitionBy(cols).bucketBy(n, col).sortBy(col).saveAsTable(t).option("maxRecordsPerFile", n).insertInto(t).writeTo(t).append() / .overwritePartitions()maxRecordsPerFile, and target roughly 128MB–1GB per file.Partitioning & caching
df.repartition(n)df.repartition("key")df.repartitionByRange(n, "key")df.coalesce(n)coalesce(1) before a wide transformation runs that transformation single-threaded.df.rdd.getNumPartitions()F.spark_partition_id()Caching
df.cache()MEMORY_AND_DISK for DataFrames.df.persist(StorageLevel.MEMORY_AND_DISK_SER)df.unpersist()df.checkpoint()df.localCheckpoint()Performance tuning
Diagnose in this order
Exchange nodes in explain. Can a dimension be broadcast? Can two aggregations share one grouping?BatchEvalPython. Replace the UDF, or vectorise it.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 = 200spark.sql.adaptive.enabled = truespark.sql.adaptive.coalescePartitions.enabledspark.sql.adaptive.skewJoin.enabledspark.sql.adaptive.advisoryPartitionSizeInBytes = 64MBspark.sql.autoBroadcastJoinThreshold = 10MB-1 disables.spark.sql.files.maxPartitionBytes = 128MBspark.sql.optimizer.dynamicPartitionPruning.enabledspark.sql.execution.arrow.pyspark.enabledtoPandas and pandas UDFs.spark.executor.memoryOverheadspark.sql.parquet.filterPushdownStructured 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").outputMode("update").outputMode("complete").trigger(processingTime="1 minute").trigger(availableNow=True)once=True..foreachBatch(fn).withWatermark(col, delay)checkpointLocation.option("maxFilesPerTrigger", n) · maxBytesPerTriggerq.lastProgress · q.status · spark.streams.activelastProgress gives input rate, processing rate and state row counts.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 INTOOPTIMIZE tOPTIMIZE t ZORDER BY (col)CLUSTER BY (col)VACUUM t RETAIN 168 HOURSDESCRIBE HISTORY t.option("mergeSchema", "true").option("overwriteSchema", "true")RESTORE TABLE t TO VERSION AS OF ndeltaTable.generate("symlink_format_manifest")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"))
assertDataFrameEqualdf.observe(name, *metrics)lastProgress.df.exceptAll(expected)df.groupBy(keys).count().filter("count > 1").isEmpty() — run before every dimension join.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).Debugging & observability
df.explain(mode="formatted")simple, extended, codegen, cost, formatted.Exchange (shuffles), check PushedFilters, confirm which join node the planner chose.df.printSchema() · df.columns · df.dtypesspark.sparkContext.setJobDescription(s)df.groupBy(F.spark_partition_id()).count()Errors and what they usually mean
AnalysisException: cannot resolve 'x'df.columns.Column is not iterablelen(col), max(col), if col:. Use F.length, F.greatest, F.when.PicklingError: cannot pickle ...OutOfMemoryError on the drivercollect(), toPandas(), or an oversized broadcast. Also caused by a plan with tens of thousands of nodes.Container killed by YARN / exit 137memoryOverhead or reduce per-task data.Job aborted due to stage failure ... FetchFailed& binds tighter than ==. Parenthesise every comparison: (a == 1) & (b > 2). Use & | ~, never and or not.Gotchas worth memorising
union matches by positionunionByName.coalesce(1) is a bottleneck, not an optimisationrepartition(1) to keep the earlier stages parallel.monotonically_increasing_id is not a surrogate keyDoubleType sums will not tie back. DecimalType(18,2) throughout.a == b is null when either is null, so the row fails the filter. Use eqNullSafe when comparing hash keys across snapshots.YYYY vs yyyywithColumn in a loopselect with a list of expressions.where clause forces a full scan — the filter can no longer reach the file reader.spark.sql.caseSensitive=false. Two source columns differing only in case collide on read.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)rdd.mapPartitions(fn)mapInPandas is the modern equivalent.reduceByKey vs groupByKeyreduceByKey combines map-side and shuffles far less. The canonical RDD-era optimisation.sc.broadcast(obj) · bv.valuesc.accumulator(0)df.observe.sc.addPyFile · sc.addFileWhat is new and worth knowing
VariantType (4.0)upper() everywhere.df.plot (4.0)Platform notes
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.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.master("local[*]"), and drop shuffle.partitions to 2–8. The default 200 makes a ten-row test suite take minutes.