PySpark / Performance Fig_09  ·  Lesson 09 / 14
Fig_09 · Shared variables · v1.0 OSS Spark + Azure Databricks

Broadcast variables

And accumulators · the two shared-variable types

Spark gives you exactly two ways to share data with your tasks: push a small read-only value out to every executor once, and pull aggregated numbers safely back to the driver. This lesson is about those two pipes — and the exactly-once trap that catches everyone on the way back.

Spark 3.2+ / DBR LTS shipped once / executor exactly-once actions only verified Jun 2026

01 · What it is

Two pipes across the driver↔executor boundary

Most values in your code reach the executors by being captured in a closure and serialized ad-hoc with every task. Spark's two shared variables cross that boundary in a controlled way instead — one going out, one coming back:

analogy Think of them as opposite-direction pipes. A broadcast variable pushes data out to every executor; an accumulator pulls aggregated numbers back to the driver. The deep dive below covers each pipe once; the diagrams show them running.

Broadcast variable

Read-only value, cached once per executor. Build with sc.broadcast(v); read on workers via bv.value.

Accumulator

Write-only on workers (acc.add(n)); only the driver reads acc.value after an action.

The trap

Trusting an accumulator updated inside a transformation (map/filter) — it may re-run and double-count.

Broadcast variable ≠ broadcast join A broadcast variable is a value you reference inside a UDF/map (sc.broadcast(obj) → bv.value). A broadcast join is a join strategy the planner picks via autoBroadcastJoinThreshold (10 MB OSS) or the BROADCAST hint (Lesson 02). Same word, different mechanism — the join hint takes a DataFrame; sc.broadcast() takes a plain Python object. This is the single distinction interviewers test most on this topic.

02 · Why it matters

What each pipe saves you

03 · How it works — deep dive

Ship a value out; count a number back

Each pipe is explained here once — its mechanism, its one hard constraint, and the trap it hides. The diagrams that follow show these ideas run; the tables and gotchas cross-reference them. Neither is re-explained.

3.1 · Broadcast variable — a read-only value, once per executor

analogy Like handing every cashier (executor) their own laminated price-list at the start of the shift, instead of faxing it to every customer (task). One copy per till, reused all day.

use case A 30 MB country_code → region dict (or a small ML model) referenced in a Python UDF scoring 5 billion rows — broadcast once per executor rather than shipping it with every task.

3.2 · Accumulator — count on workers, read on the driver

analogy Like a tally counter at a stadium turnstile — each gate (task) clicks it as people pass, but only the control room (driver) reads the total. Gates can add; they can't read the running count.

use case Counting malformed records while writing a cleaned dataset — bad_rows.add(1) inside the action that writes (foreachPartition), then log bad_rows.value on the driver, with no second pass.

Figure A · the two pipes side by side

Once-per-executor vs. actions-only, in two panels

Two independent panels, each isolating the one thing that trips people up. Left — a broadcast variable: drag the tasks-per-executor and watch the copy count stay at one per executor, not one per task. Right — an accumulator: run it inside an action or a transformation, then re-run to see the count stay correct or double. (Where the data physically travels across nodes is Figure B.)

Broadcast variable

A 50 MB lookup dict on 2 executors. Copies shipped = the counter to watch.

—
copies shipped
—
~bytes on the wire

Accumulator

sc.accumulator(0) — 2 executors × 3 tasks × 100 rows. True count = 600.

driver reads acc.value
0
Press Run action to fire the tasks.

Simplified illustration — real broadcast uses a torrent-like fan-out; "Re-run" models a recomputed stage / second action over an uncached DataFrame. Watch: the copy count on the left, the doubled total on the right.

04 · The code — and how to verify it

Three moves, each with its proof

Every snippet below is paired with how to prove it worked — the Spark-UI signal or the cross-check. Apply, then verify; never assume.

Simple reading rule When you see the word broadcast, ask one question first: "Is this a broadcast VARIABLE (a value read inside a UDF via bv.value), or a broadcast JOIN (a strategy the planner applies to a DataFrame)?" sc.broadcast() takes a plain object; broadcast(df) takes a DataFrame. Naming them apart up front prevents the #1 mix-up.

Broadcast a read-only lookup, read it in a UDF

python# DRIVER: build the small lookup once, then broadcast it (read-only) to every executor.
lookup = {"US": "NA", "CA": "NA", "DE": "EU", "IN": "APAC"}
bv = sc.broadcast(["US", "CA", "DE", "IN"])   # any plain Python object — shipped ONCE per executor
bv = sc.broadcast(lookup)                              # here: a dict; read on workers via bv.value

from pyspark.sql.functions import udf
from pyspark.sql.types import StringType

# WORKER: each task reads the SAME cached copy via bv.value — no per-task re-send.
@udf(StringType())
def to_region(code):
    return bv.value.get(code, "UNKNOWN")             # bv.value is READ-ONLY on the worker

regions = orders.withColumn("region", to_region("country_code"))

# VERIFY — it's a *variable*, not a plan node, so check the Spark UI, not .explain():
#   Spark UI -> Storage tab shows ONE "Broadcast" block sized ~your value, NOT x num_tasks.
bv.unpersist()   # release when done (re-broadcast if reused) …
# bv.destroy()   # … or remove permanently (using it afterwards raises an error)

Count bad records inside an action (exactly-once)

python# DRIVER: sc.accumulator(value) is PySpark's ONLY accumulator constructor. It is UNNAMED.
# (Scala/Java have sc.longAccumulator("name"); PySpark has no sc.longAccumulator.)
bad_rows = sc.accumulator(0)

def validate_and_write(rows):
    for r in rows:
        if r["amount"] is None:
            bad_rows.add(1)                       # WORKER: only .add() — never read .value here
        # … write/emit the good row …

# Inside an ACTION (foreachPartition) -> each task's update applied EXACTLY ONCE.
orders.rdd.foreachPartition(validate_and_write)

print("bad rows:", bad_rows.value)                # DRIVER ONLY reads the merged total

# VERIFY — PySpark's sc.accumulator() is UNNAMED, so it is typically NOT surfaced by name
#   in the Spark UI Stages -> Accumulators view. Cross-check against a real aggregation:
print("true count:", orders.filter(orders.amount.isNull()).count())   # must match

The anti-pattern: incrementing inside a transformation

python# WRONG: incrementing inside a TRANSFORMATION (lazy + may re-run) — NOT exactly-once.
seen = sc.accumulator(0)
counted = orders.rdd.map(lambda r: (seen.add(1), r)[1])   # transformation, not action
counted.count()        # runs once  -> seen == N …
counted.count()        # runs AGAIN (not cached) -> seen == 2N  (inflated)

# A retried/speculative task or a recomputed stage can double-count even with ONE action.
# VERIFY / FIX — use a true aggregation and compare; they will NOT match after two actions:
print(orders.count())                       # the real count — trust this, not `seen`

Figure B · where it travels

See it run across the cluster

This figure isolates a different thing from Figure A: not the counters, but where data physically moves across 1 driver + 3 executors. Part A ships a broadcast variable out — once per executor, reused by every task on it. Part B runs an accumulator: tasks .add() to a local partial, the deltas flow back to the driver, and only the driver reads .value. Use Prev / Play / Next.

Driver + 3 executors — broadcast out, accumulate back

Dummy data + dummy PySpark; chips and code lines highlight per step.

step 1 / 4
readyPress Play to watch it run.
PySpark — dummy code
dummy data · orders

Simplified illustration — a broadcast variable (Part A) is not a broadcast join; the accumulator deltas (Part B) merge exactly-once only because they run inside a foreachPartition action.

05 · Comparison table

The two pipes, dimension by dimension

Read this by the property you care about — it answers "which pipe, and what's its catch?" at a glance. (The why is §3; this is the which.)

DimensionBroadcast variableAccumulator
DirectionDriver → executors (push out)Executors → driver (pull back)
AccessRead-only on workers (bv.value)Write-only on workers (acc.add); driver reads acc.value
Shipped / mergedCached once per executor, reused by its tasksPer-task partials merged on the driver
Createsc.broadcast(value)sc.accumulator(0) (only constructor; no longAccumulator)
Typical useSmall lookup dict/set/model in a UDF/mapCounters/metrics: bad-record count, audit tallies
Correctness caveatStatic & read-only — updates don't propagateExactly-once only inside actions; transformations may double-count
Releaseunpersist() / destroy()Lives with the SparkContext; reset by re-creating
Verify inSpark UI Storage tab (one Broadcast block)Cross-check acc.value vs a real aggregation (Python accumulators are unnamed)
NOT thisA broadcast join (a strategy, Lesson 02)A real aggregation (groupBy().count())

06 · Edge cases & limitations

Where it gets subtle

New nuance an interviewer probes — not a re-run of §3:

Hard limits worth stating plainly:

07 · Common mistakes

Gotchas — the mistake and its one-line fix

MistakeWhy it hurtsBetter move
Confusing a broadcast variable with a broadcast joinThey're different mechanisms — the #1 trap on this topic.sc.broadcast(dict) ships a value you read; join(broadcast(df), …) is a strategy.
Capturing a big object in a closureIt's serialized and shipped once per task, not per executor.Wrap it in sc.broadcast(...) and read bv.value.
Trusting an accumulator updated inside a map/filterExactly-once holds only inside actions; transformations may re-run.Use foreach/foreachPartition, or a real aggregation.
Assuming an accumulator updates without an actionIt's lazy — no action, no increment.Trigger the transformation with an action, then read .value.
Reading acc.value on a workerWorkers are write-only; you get a partial, not the total.Read on the driver, after the action.
Never releasing a broadcastIt pins executor Storage memory and pressures GC.Call unpersist()/destroy() when done.
Expecting a PySpark accumulator to show by name in the UIsc.accumulator(0) is unnamed (no sc.longAccumulator(name) in PySpark).Verify by comparing acc.value to filter().count().

08 · References

Sources