Spark vs Ray¶
A ML engineer asks in Slack: "we already have a 40-node Spark cluster for ETL — can't we just run our hyperparameter sweep as a Spark job over the search space instead of standing up Ray?" The sweep is 200 independent training runs, each holding a model in memory across several epochs, with early-stopping logic that needs to talk back to a scheduler. Predict before you read on: does Spark's DataFrame/partition model fit this, or does it fight it?
It fights it. Spark and Ray both spread Python over machines, but they answer different questions.
Spark: how do I transform this dataset (partitions, shuffle, SQL)? Ray: how do I run this Python call graph (tasks, actors, object store)?
If you force Ray to do a 20 TB join, you will reinvent a worse Spark. If you force Spark to do distributed PyTorch and a hyperparam sweep, you will reinvent a worse Ray.
Related: Ray, Spark, analytics platform.
The core difference¶
Spark's abstraction is a DataFrame/RDD: narrow vs wide transformations, shuffle, Catalyst. The unit of scheduling is a task on a partition.
Ray's abstraction is a dynamic task/actor graph. The unit is a function invocation or a stateful actor. The object store holds futures/objects, not a query plan.
# Spark: relational
df.groupBy("customer_id").agg(F.sum("revenue"))
# Ray: Python
@ray.remote
def fit(config):
return train(config)
ray.get([fit.remote(c) for c in search_space])
What each is for¶
| Spark is for | Ray is for |
|---|---|
| SQL, joins, group-bys on lakes | Tune, Train, Serve, RLlib |
| Iceberg/Delta/Hudi IO | Embarrassingly parallel Python |
| ETL that must be replayable | Actors holding a model or sim |
| Tabular MLlib (limited) | PyTorch/TF distributed |
Ray Data exists and looks like a DataFrame. It is aimed at ML preprocessing pipelines (images, tensors, map_batches), not as your company warehouse.
Performance character¶
| Spark | Ray | |
|---|---|---|
| Large shuffle | Excellent | Not the design centre |
| Million tiny Python calls | JVM + UDF tax | Excellent |
| SQL optimizer | Catalyst + AQE | Not Spark |
| Memory | JVM + Tungsten | Plasma/object store |
| Heterogeneous data | Awkward | Natural |
Python UDFs in Spark are the tell: if the job is the UDF, Ray (or plain multiprocessing) may be the engine. If the job is the join, stay in Spark SQL and avoid UDFs.
Choose Spark when¶
- Read Parquet/Iceberg → join/agg → write.
- Analysts need SQL.
- Structured Streaming / batch ETL is the product.
- Feature tables for ML (wide group-bys) before training.
Choose Ray when¶
- Hyperparameter search, distributed training, online model serve.
- Work is "map this Python function over 10k experiments."
- Stateful actors (simulations, model replicas).
- Deep learning — Spark MLlib will not save you.
Choose neither when¶
- Single-box pandas/scikit fits in RAM — you do not need a cluster.
- The serving path is 5 ms in an existing JVM/Go service — don't insert Ray Serve for fashion.
- You need a fraud score in 200 ms with Kafka state — that is Flink/KV, not Ray, not Spark.
- You need a dashboard — ClickHouse.
Anti-pattern
"We're a Python shop, so Ray for ETL." PySpark is Python. The issue is the dataflow, not the logo on the intern's laptop.
Running example: SaaS analytics + a model¶
From analytics-platform and a churn model:
Kafka → Spark/Flink → Iceberg feature tables
→ Ray Train (weekly)
→ Ray Serve or a boring sidecar
Customer UI still reads ClickHouse aggregates, not Ray.
Spark (or dbt) owns features that are SQL. Ray owns fit(). ClickHouse owns the product. Three tools because three workloads.
Fraud: do not call Ray Serve from the authorisation path unless you have measured p99. In-process model in the scorer is the V1 in fraud.
APIs side by side¶
Spark
df = spark.read.parquet("s3://bucket/data/")
result = df.groupBy("customer_id").agg(F.sum("revenue"))
result.write.mode("overwrite").parquet("s3://bucket/output/")
Ray tasks
@ray.remote
def process(batch):
return model.predict(batch)
results = ray.get([process.remote(b) for b in batches])
Ray actors
@ray.remote
class ModelServer:
def __init__(self):
self.model = load_model()
def predict(self, x):
return self.model.predict(x)
Ray Data (ML ingest, not a warehouse)
ds = ray.data.read_parquet("s3://bucket/data/")
ds = ds.map_batches(transform_fn, batch_format="pandas")
ds.write_parquet("s3://bucket/output/")
ML split that holds up¶
| Step | Default engine |
|---|---|
| Warehouse / lake transforms | Spark |
| Small-data sklearn | pandas, one box |
| Distributed GBDT on huge tables | Spark ML or specialised (XGBoost on Spark); not Ray required |
| DL / Tune / RL | Ray |
| Feature store serving | KV / CH / dedicated FS |
| Product dashboard | ClickHouse |
Both together¶
Ops note: two clusters, two UIs, two failure modes. Worth it when each cluster is at capacity doing its job. Not worth it to "standardise on Ray" for SQL.
Decision checklist¶
Is it SQL/joins/shuffle on a lake? → Spark
Is it train/tune/serve Python models? → Ray
Is it both? → Spark then Ray
Is it streaming state? → Flink, not this page
Does it fit on a laptop? → Neither cluster
Worked example: churn model on SaaS events¶
Analytics events in Iceberg, 1 TB/day.
| Step | Engine | Why not the other |
|---|---|---|
| Sessionize / 30 d aggregates | Spark SQL | Ray shuffle is not your friend at 30 TB |
| Join to billing Postgres dump | Spark or Trino | Ray Data will not federate PG well |
| Train LightGBM on 50 GB features | Spark ML or one big box | Ray optional |
| Train a small net + Tune 200 trials | Ray Tune | Spark cluster sitting idle per trial is waste |
| Serve 50 QPS batch scores nightly | Spark job | Ray Serve unnecessary |
| Serve 5k QPS online | Ray Serve or a boring Flask+ONNX sidecar | Spark |
Most companies need Spark for step 1 and a single VM for step 3. Ray arrives with Tune/DL. Buying Ray on day one of an ETL project is the anti-pattern.
UDF as a smell¶
If 80% of Spark time is Python UDF (not vectorised pandas UDFs even), you are using Spark as a task scheduler. Ray (or Airflow + processes) may be cheaper. If 80% of time is exchange hashpartitioning, you are using Ray as a worse warehouse if you moved off Spark.
Ray Data vs Spark, honestly¶
Ray Data shines when:
- Images/video/tokens,
map_batchesinto GPU train. - Streaming pipeline into training, not into a 5-year lake.
Spark shines when:
- Predicate pushdown, partition prune, AQE, Iceberg commits, SQL analysts.
A "lakehouse on Ray Data" in 2026 may exist in a blog. Your on-call still needs Iceberg stats and Spark/Trino. Do not strand the warehouse.
Actors vs Spark mapPartitions¶
mapPartitions can hold a model per executor. That is a poor actor: recovery, routing, and serving QPS are worse than Ray Serve or a dedicated service. Use Spark to score a table offline. Use actors/services to score a request.
Fraud 200 ms: fraud in-process model, not Ray remote per payment (unless colocated and measured).
Review script¶
- Is the unit a table or a Python call?
- Is there a large shuffle?
- GPU?
- Online vs offline?
- If "both," draw the handoff (feature table in object storage). No shared RAM fairy.
Object store vs shuffle service¶
Spark's shuffle is a designed data exchange (with spill, encryption, AQE). Ray's object store is a general plasma/shared memory for futures. Dumping a 5 TB join through Ray objects is possible in theory and a support nightmare in practice.
Conversely, launching 5,000 Spark jobs for 5,000 hyperparam trials makes the YARN/k8s scheduler the bottleneck Ray Tune already solved.
Serving¶
Ray Serve: replicas, autoscale, HTTP. Good at Python models. Spark: not a request server. Flink: not a request server (unless you stretch it).
Online fraud: in-process in the scorer (fraud). Ray remote per request adds tail latency unless colocated and warmed. Measure p99.
Team topology¶
Spark lives with data engineering. Ray lives with ML platform. The handoff is a table (features) plus a training job. Shared cluster "to save money" often means ML experiments evict ETL or vice versa. Separate queues at least.
Choose-neither recap¶
Flink for keyed streaming. CH for tiles. pandas for 2 GB. Don't cluster-ify a Jupyter experiment that runs in 40 s.
FAQ¶
Ray Data for warehouse ETL? Not as the company SoR. Use Spark/dbt/Iceberg.
Spark for hyperparam? Painful. Ray Tune or a job queue.
Dask vs Ray vs Spark? Dask is pandas-shaped single-ish team. Spark is SQL/lake. Ray is tasks/actors/ML. Dask is not covered in this academy; do not add it to V1 because a notebook imported it.
GPU Spark? Exists; still not the DL path. Ray Train / dedicated GPU schedulers.
Can one k8s run both? Yes, separate queues and node groups. Mixed autoscaling is how ETL OOMs training.
Anti-patterns¶
- Rewriting
groupByin Ray tasks to "unify Python." - Spark UDF calling
ray.getinside a map (nested clusters). - Ray Serve as the only way to run a 5 QPS sklearn model (a container is enough).
Handoff contract¶
Iceberg feature table
snapshot_id pinned for the run
grain: customer_id, date
produced by: Spark job gold.features
consumed by: Ray Train job, versioned
If this contract is missing, ML and DE will fight over a moving Parquet path. Metadata. Pin the Iceberg snapshot in the training job's run config so "the data moved" is a reproducible ID, not a Slack argument.