A user-land broadcast-variable workaround for PySpark on Spark Connect and Databricks Serverless, where SparkContext.broadcast(value) is unavailable because the Connect client has no SparkContext.
Uses the public SparkSession.addArtifact(..., pyfile=True) API to ship a Python module containing your serialized data. Inside UDFs, importlib.import_module(name) hits Python's normal import cache — giving once-per-worker-process load semantics, the closest this workaround gets to real broadcast behavior.
Not a replacement for real broadcast. No peer-to-peer block distribution, session-scoped lifecycle only, practical size ceiling in the low hundreds of MB. Intended as a stopgap until SPARK-51705 lands a proper
SparkSession.broadcast(value)API.
pip install git+https://github.com/Tagar/pyspark-broadcast-lite.gitRequires Python ≥ 3.9 and either:
- Spark Connect (OSS, ≥ 3.5.0), or
- Apache Spark Classic ≥ 4.0.0 (
addArtifactwas added to Classic in SPARK-50718), or - Databricks Connect ≥ 14.0.1 (including Databricks Serverless).
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, udf
from broadcast_lite import broadcast_lite
spark = SparkSession.builder.getOrCreate()
lookup = {"a": "apple", "b": "banana", "c": "cherry"}
mod_name = broadcast_lite(spark, lookup)
@udf("string")
def enrich(key):
import importlib
return importlib.import_module(mod_name).DATA.get(key, "unknown")
df = spark.createDataFrame([("a",), ("b",), ("z",)], ["key"])
df.select(enrich(col("key")).alias("fruit")).show()
# +-------+
# | fruit|
# +-------+
# | apple|
# | banana|
# |unknown|
# +-------+For the once-per-partition setup pattern (recommended for high row counts), see examples/pandas_udf.py.
- The value is pickled on the driver with
pickle.HIGHEST_PROTOCOL. - For pickles ≤ 8 MB (default), the bytes are base64-encoded and inlined into a single
.pysource file whose import computesDATA. For larger values, a.zippackage carries the pickle as package data, loaded viapkgutil.get_dataover Python's built-inzipimport. - The file (or zip) is uploaded via
spark.addArtifact(path, pyfile=True). Spark Connect'sArtifactManagerinserts it intosys.pathon Python workers and triggersimportlib.invalidate_caches()for plain.pyartifacts. - Inside the UDF,
importlib.import_module(mod_name)executes the module body once per worker process and caches the result insys.modules. Subsequent calls are O(1) dictionary lookups.
| Captured-value size | Recommendation |
|---|---|
| < ~100 KB | Don't bother. Let cloudpickle embed it in the UDF closure. |
| 100 KB – 1 MB | Either works fine. |
| 1 MB – 10 MB | broadcast_lite worth it — avoids closure bloat. |
| 10 MB – 100 MB | Recommended. Cloudpickled closures become slow and large. |
| > 100 MB | Necessary. Raw cloudpickled closures hit spark.connect.grpc.maxInboundMessageSize (default 128 MiB) and spark.rpc.message.maxSize (default 128 MiB). |
| > 1 GB | Neither this nor sc.broadcast on managed services. Reshape the problem (join, structured store, per-partition cache). |
A scalar @udf calls your function once per row. Even after the initial module import, every row pays a sys.modules lookup + attribute access. For millions of rows, use iterator-style pandas_udf or mapInPandas to get explicit once-per-partition setup:
from typing import Iterator
import pandas as pd
from pyspark.sql.functions import pandas_udf
mod_name = broadcast_lite(spark, big_dict)
@pandas_udf("string")
def enrich(iterator: Iterator[pd.Series]) -> Iterator[pd.Series]:
# runs ONCE per partition
import importlib
data = importlib.import_module(mod_name).DATA
for batch in iterator:
yield batch.map(lambda k: data.get(k, "?"))
df.select(enrich(df.key)).show()Note:
RDD.mapPartitionsgives the same per-partition setup but is not available on Spark Connect (no RDD API). UsemapInPandas,mapInArrow, or iteratorpandas_udfinstead.
See examples/pandas_udf.py for a complete working example.
- Not a real broadcast. No peer-to-peer block distribution — artifacts are fetched per executor from the Connect server's staging area. First-call latency scales linearly with executor count. Real
sc.broadcastusesTorrentBroadcastforO(log N)fan-out. - Size ceiling. Practical limit in the low hundreds of MB. Above that, upload time and Python-worker memory become problems.
- Session-scoped lifecycle only. There's no analog of
Broadcast.unpersist(); uploaded modules live until the Connect session closes. - Undocumented use on Databricks. The OSS mechanism (
addArtifact(pyfile=True)→sys.pathon executors) is stable and documented, but Databricks does not advertise using it for data. The wiring works; the use case is "unofficial."
This package is a stopgap. The proper fix is tracked in SPARK-51705 ("Support sc.broadcast over Spark Connect"), which would add a native SparkSession.broadcast(value) API to both the DataFrame API and Spark Connect — making broadcast variables a first-class, typed, lifecycle-managed feature with the real TorrentBroadcast distribution topology under the hood.
If you want a clean, Databricks-supported broadcast API in a future Spark release, upvote the JIRA and follow the discussion.
git clone https://github.com/Tagar/pyspark-broadcast-lite.git
cd pyspark-broadcast-lite
pip install -e ".[dev]"
pytestThe tests in tests/test_module_gen.py exercise the file-generation helpers directly and do not require a Spark session.
Apache-2.0. See LICENSE.