SPARKS
Last reviewed: 2026-06-16
Purpose: Comprehensive Knowledge Base (KB) article on Apache Spark โ covering architecture, core data abstractions, Spark SQL, PySpark DataFrames, performance tuning, Kubernetes deployment, and a complete ETL example.
Table of Contents
- Overview
- Spark Architecture
- Core Data Abstractions: RDD vs DataFrame vs Dataset
- Spark SQL
- PySpark DataFrame Operations
- Performance Tuning
- Spark on Kubernetes
- Complete PySpark ETL Example
- References
Overview
Apache Spark is a unified, open-source, distributed computing engine for large-scale data processing. It provides high-level APIs in Java, Scala, Python (PySpark), and R, along with an optimized engine that supports general execution graphs. Key capabilities include:
- Batch and stream processing under a single unified API (Structured Streaming)
- SQL analytics via Spark SQL with the Catalyst optimizer
- Machine learning through MLlib
- Graph processing via GraphX
Spark runs on standalone clusters, Hadoop YARN, Apache Mesos, and Kubernetes. It can read from diverse data sources: HDFS, S3, GCS, ADLS, JDBC, Cassandra, Kafka, and more.
Spark Architecture
A Spark application follows a masterโworker model with a single driver and multiple executors, coordinated by a cluster manager.
+-------------------------------------------------------+
| Driver Program |
| SparkContext / SparkSession |
| DAG Scheduler | Task Scheduler | Backend Scheduler |
+--------------------------+----------------------------+
|
Cluster Manager
(Standalone / YARN / K8s / Mesos)
|
+----------------+------------------+
| |
Executor 1 Executor N
+-------------+ +-------------+
| Cache/Tasks | ... | Cache/Tasks |
+-------------+ +-------------+
Driver
- Runs the user's
main()function and creates aSparkContext(orSparkSessionin modern Spark). - Converts user code into a DAG (Directed Acyclic Graph) of stages.
- Schedules tasks across executors via the cluster manager.
- Holds metadata about all persisted RDDs/DataFrames.
Executors
- Worker processes that run tasks and store data in memory or disk.
- Start once the application begins and shut down when it ends.
- Report task progress and cached block status back to the driver.
Cluster Manager
Allocates resources across applications. Options:
| Manager | Description | Typical Use Case |
|---|---|---|
| Standalone | Simple built-in cluster manager | Dev/test, small clusters |
| Hadoop YARN | Integrates with Hadoop ecosystem, resource-aware | Enterprise Hadoop deployments |
| Kubernetes | Container-native orchestration, elastic scaling | Cloud-native, microservices shops |
| Apache Mesos | General resource manager (Spark < 3.x) | Legacy multi-framework clusters |
Execution Flow
- Driver creates a
SparkSessionand connects to the cluster manager. - Cluster manager launches executors and allocates cores/memory.
- Driver sends task code to executors.
- Executors run tasks, cache data, and return results.
- Driver collects results or writes them to external storage.
Core Data Abstractions: RDD vs DataFrame vs Dataset
| Feature | RDD | DataFrame | Dataset (JVM only) |
|---|---|---|---|
| Introduced | Spark 1.0 | Spark 1.3 | Spark 1.6 |
| Type safety | Yes (compile-time) | No (runtime) | Yes (compile-time) |
| Schema | No (raw Java/Python objects) | Yes (tabular, named columns) | Yes (strongly-typed JVM objects) |
| Optimization | Manual (no Catalyst) | Catalyst optimizer + Tungsten | Catalyst + Tungsten + encoders |
| Serialization | Java/Kryo (slow) | Tungsten binary (fast) | Tungsten binary (fast) |
| API | Java, Scala, Python, R | Java, Scala, Python, R | Java, Scala |
| Use when | Need low-level control, custom transformations | High-level SQL-like analytics, performance matters | Type-safe transformations on the JVM |
When to use which
- RDD: Only when working with unstructured data (e.g., raw text streams) or when you need fine-grained control over partitioning and persistence.
- DataFrame: Default choice for structured/semi-structured data (JSON, Parquet, Avro, CSV). Leverages Catalyst for query optimization.
- Dataset: Use in Scala/Java when you need compile-time type safety on structured data (e.g., domain objects with case classes).
PySpark Note: Python does not support the typed
DatasetAPI. In PySpark,DataFrameis equivalent toDataset[Row].
Spark SQL
Spark SQL enables SQL queries on structured data, either via SQL strings or programmatic DataFrame operations.
Creating temporary views
from pyspark.sql import SparkSession
spark = SparkSession.builder \
.appName("SparkSQLDemo") \
.getOrCreate()
df = spark.read.json("s3://bucket/events/")
df.createOrReplaceTempView("events")
result = spark.sql("""
SELECT user_id, COUNT(*) AS event_count
FROM events
WHERE event_type = 'purchase'
GROUP BY user_id
ORDER BY event_count DESC
LIMIT 10
""")
result.show()
Catalyst Optimizer
Catalyst is Spark SQL's query optimizer. It performs:
- Analysis โ Resolves column names, types, and table references
- Logical optimization โ Predicate pushdown, constant folding, projection pruning
- Physical planning โ Chooses join strategies (broadcast vs sort-merge), bucketing
- Code generation โ Tungsten generates optimized JVM bytecode
You can view the query plan with:
Reading and writing with Spark SQL
# Parquet โ columnar, highly optimized
df = spark.read.parquet("hdfs:///data/events/")
df.write.mode("overwrite").parquet("hdfs:///data/events_clean/")
# Delta Lake โ ACID transactions, time travel
df.write.format("delta").save("/data/delta/events/")
# JDBC โ relational databases
df = spark.read \
.format("jdbc") \
.option("url", "jdbc:postgresql://host:5432/db") \
.option("dbtable", "public.orders") \
.option("user", "user") \
.option("password", "pass") \
.load()
PySpark DataFrame Operations
Below are the most common PySpark DataFrame operations with examples.
Setup
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, lit, when, sum, avg, count, \
upper, regexp_extract, to_date, date_format, current_timestamp
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType
spark = SparkSession.builder \
.appName("PySparkDataFrameOps") \
.config("spark.sql.adaptive.enabled", "true") \
.getOrCreate()
Create DataFrame
# From a list of tuples
data = [("Alice", 34, 72000.0), ("Bob", 28, 58000.0), ("Charlie", 45, 95000.0)]
schema = ["name", "age", "salary"]
df = spark.createDataFrame(data, schema)
df.show()
# +-------+---+------+
# | name|age|salary|
# +-------+---+------+
# | Alice| 34|72000.0|
# | Bob| 28|58000.0|
# |Charlie| 45|95000.0|
# +-------+---+------+
Transformations (lazy)
# Select, filter, withColumn
df_filtered = df.filter(col("age") >= 30) \
.select("name", "salary") \
.withColumn("bonus", col("salary") * 0.1)
# GroupBy + aggregation
df_grouped = df.groupBy("age").agg(
count("*").alias("count"),
avg("salary").alias("avg_salary")
)
# Sorting
df_sorted = df.orderBy(col("salary").desc())
# Handling nulls
df_cleaned = df.na.drop() # drop rows with any null
df_filled = df.na.fill({"salary": 50000.0}) # fill specific column
# Add a literal column
df_with_flag = df.withColumn("is_senior", when(col("age") >= 40, lit(True)).otherwise(lit(False)))
Actions (eager)
df.count() # number of rows
df.collect() # all rows to driver (careful: memory!)
df.show(5) # preview first 5 rows
df.take(3) # first 3 rows as list
df.describe().show() # summary statistics
Reading from various sources
# CSV
df_csv = spark.read.option("header", "true").option("inferSchema", "true").csv("s3://bucket/data/*.csv")
# JSON (nested)
df_json = spark.read.json("s3://bucket/events/*.json")
# Parquet (columnar, schema-preserving)
df_parquet = spark.read.parquet("/data/events/")
# Avro (requires spark-avro)
df_avro = spark.read.format("avro").load("/data/events.avro")
# Kafka streaming
df_kafka = spark.readStream \
.format("kafka") \
.option("kafka.bootstrap.servers", "broker:9092") \
.option("subscribe", "events-topic") \
.load()
Writing data
df.write.mode("overwrite").parquet("/output/events/")
df.write.mode("append").format("delta").save("/output/delta/events/")
df.write.mode("error").option("header", "true").csv("/output/csv/events/")
UDFs (User Defined Functions)
from pyspark.sql.functions import udf
from pyspark.sql.types import StringType
def age_category(age):
return "young" if age < 35 else "senior" if age >= 60 else "mid"
age_category_udf = udf(age_category, StringType())
df.withColumn("category", age_category_udf(col("age"))).show()
Note: Avoid Python UDFs when possible โ they serialize each row row-by-row. Prefer built-in Spark SQL functions (
when,regexp_extract, etc.) or Pandas UDFs (vectorized, Arrow-native) for performance.
Performance Tuning
Partitioning
Partitions define the unit of parallelism. A partition is processed by one task on one executor core.
# Repartition (shuffles all data โ expensive)
df_repartitioned = df.repartition(200, col("country"))
# Coalesce (reduces partitions without full shuffle โ use for writing)
df_coalesced = df.coalesce(8) # from e.g. 200 โ 8
# Set shuffle partitions globally
spark.conf.set("spark.sql.shuffle.partitions", 200)
Guidelines: - Target ~128โ256 MB per partition after shuffle. - Too few partitions โ executors idle (not enough parallelism). - Too many partitions โ excessive scheduling/serialization overhead.
Caching & Persistence
Materialise a DataFrame (or RDD) in memory/disk to avoid recomputation.
# Cache (memory only, default MEMORY_ONLY)
df_cached = df.filter(col("age") > 30).cache()
df_cached.count() # triggers caching
# Persist with storage level
from pyspark import StorageLevel
df.persist(StorageLevel.MEMORY_AND_DISK) # spill to disk if memory full
df.persist(StorageLevel.MEMORY_ONLY_SER) # serialised objects (smaller, CPU overhead)
# Unpersist when done
df.unpersist()
Storage levels:
| Level | Space Used | CPU Time | In Memory | On Disk |
|---|---|---|---|---|
| MEMORY_ONLY | High | Low | Yes | No |
| MEMORY_ONLY_SER | Low | High | Yes (serialized) | No |
| MEMORY_AND_DISK | High | Low | Yes | Yes |
| MEMORY_AND_DISK_SER | Low | High | Yes (serialized) | Yes |
| DISK_ONLY | Low | High | No | Yes |
Broadcast Joins
When one side of a join is small (< ~10 MB default), broadcast it to all executors to avoid a full shuffle.
from pyspark.sql.functions import broadcast
# Large table: billions of rows
orders = spark.read.parquet("/data/orders/")
# Small table: a few thousand rows
countries = spark.read.parquet("/data/countries/")
# Explicit broadcast hint (avoids shuffle)
result = orders.join(broadcast(countries), "country_code")
Configurable via:
If the small table is โค this threshold, Spark automatically broadcasts it.
Adaptive Query Execution (AQE) โ Spark 3.x
AQE re-optimizes the query plan at runtime. Enable it:
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true") # default
AQE features: - Dynamic coalescing of shuffle partitions - Dynamic join switching (sort-merge โ broadcast if one side is small) - Dynamic skew join handling (splits skewed partitions)
Other tuning tips
| Parameter | Recommendation | Effect |
|---|---|---|
spark.executor.instances |
Based on cluster size | Number of executors |
spark.executor.memory |
4โ8 GB per core | Heap per executor |
spark.executor.cores |
2โ5 (YARN), 1โ5 (K8s) | Parallel tasks per executor |
spark.sql.files.maxPartitionBytes |
128 MB (default) | Max bytes per partition when reading files |
spark.sql.broadcastTimeout |
600 (seconds) | Timeout for broadcast build |
spark.serializer |
org.apache.spark.serializer.KryoSerializer |
Faster serialization |
spark.shuffle.compress |
true |
Compress map output files |
Spark on Kubernetes
Spark 2.3+ natively supports Kubernetes as a cluster manager. Pods are dynamically created per application.
Architecture
- Driver runs as a Kubernetes Pod (not restartable by default โ use
spark.kubernetes.driver.pod.name) - Executors run as short-lived Pods, scaled by the shuffle/partition needs
- Cluster manager is the Kubernetes API server โ no standalone Master/YARN ResourceManager
Submission Example
$SPARK_HOME/bin/spark-submit \
--master k8s://https://<k8s-api-server>:6443 \
--deploy-mode cluster \
--name spark-pi \
--conf spark.executor.instances=10 \
--conf spark.kubernetes.container.image=bitnami/spark:3.5 \
--conf spark.kubernetes.driver.pod.name=spark-pi-driver \
--conf spark.kubernetes.authenticate.driver.serviceAccountName=spark \
--conf spark.kubernetes.namespace=spark-jobs \
--conf spark.kubernetes.executor.volumes.hostPath.data.mount.path=/mnt/data \
--conf spark.kubernetes.executor.volumes.hostPath.data.options.path=/data \
local:///opt/spark/examples/src/main/python/pi.py 100
Best practices on Kubernetes
- Use dynamic resource allocation:
spark.dynamicAllocation.enabled=true(executors scale with workload). - Set resource requests/limits โ Spark can request specific CPU/memory from Kubernetes:
- Use node selectors / taints to pin Spark pods to specific GPU or high-memory nodes.
- Enable local storage with PVs or hostPath for shuffle spill.
- Node Autoscaling โ on cloud (EKS, GKE, AKS), cluster-autoscaler works well with Spark's bursty nature.
- Monitoring โ Spark metrics via Prometheus + Grafana (
spark.ui.prometheus.enabled=true).
Spark Operator (Kubernetes-native)
The Spark Operator (by Google) lets you submit Spark applications declaratively via CRDs:
apiVersion: sparkoperator.k8s.io/v1beta2
kind: SparkApplication
metadata:
name: spark-etl-job
spec:
type: Scala
mode: cluster
image: myregistry/spark:3.5
mainClass: com.example.ETLJob
mainApplicationFile: local:///opt/app/etl.jar
sparkVersion: "3.5.0"
executor:
instances: 10
cores: 2
memory: "4g"
driver:
cores: 1
memory: "2g"
restartPolicy:
type: OnFailure
Complete PySpark ETL Example
The following example reads raw JSON events, validates and transforms them, then writes the result as partitioned Parquet. It demonstrates a realistic ETL pipeline with error handling and performance best practices.
#!/usr/bin/env python3
"""
Complete PySpark ETL Pipeline
Source: JSON event files (S3 / HDFS / local)
Target: Parquet partitioned by date
Pipeline stages:
1. Read raw JSON with schema enforcement
2. Validate fields (null checks, type casting)
3. Enrich with derived columns (event_date, category)
4. Filter out invalid or test records
5. Write partitioned Parquet with mode=overwrite
"""
import sys
from pyspark.sql import SparkSession, DataFrame
from pyspark.sql.functions import (
col, to_date, when, coalesce, lit, input_file_name, current_timestamp
)
from pyspark.sql.types import (
StructType, StructField, StringType, LongType, DoubleType, TimestampType, BooleanType
)
# ---------------------------------------------------------------------------
# Schema โ enforce at read time to avoid runtime inference errors
# ---------------------------------------------------------------------------
EVENT_SCHEMA = StructType([
StructField("event_id", StringType(), nullable=False),
StructField("user_id", StringType(), nullable=True),
StructField("event_type", StringType(), nullable=True),
StructField("amount", DoubleType(), nullable=True),
StructField("timestamp", TimestampType(), nullable=True),
StructField("country", StringType(), nullable=True),
StructField("is_test", BooleanType(), nullable=True),
])
def create_spark_session(app_name: str = "PySparkETL") -> SparkSession:
"""Create a SparkSession with performance-optimised settings."""
return (
SparkSession.builder
.appName(app_name)
.config("spark.sql.adaptive.enabled", "true")
.config("spark.sql.adaptive.coalescePartitions.enabled", "true")
.config("spark.sql.adaptive.advisoryPartitionSizeInBytes", "128MB")
.config("spark.sql.files.maxPartitionBytes", "128MB")
.config("spark.sql.shuffle.partitions", "200")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.getOrCreate()
)
def read_raw_events(spark: SparkSession, input_path: str) -> DataFrame:
"""Read raw JSON events with schema enforcement."""
print(f"[INFO] Reading events from: {input_path}")
return (
spark.read
.schema(EVENT_SCHEMA)
.option("badRecordsPath", f"{input_path}/_bad_records")
.json(input_path)
)
def validate_events(df: DataFrame) -> DataFrame:
"""
Validate and clean events.
- Drop rows missing required fields (event_id)
- Coalesce null amounts to 0.0
- Set default country 'UNKNOWN' where null
- Exclude test records explicitly flagged as is_test = true
"""
return (
df
.filter(col("event_id").isNotNull())
.withColumn("amount", coalesce(col("amount"), lit(0.0)))
.withColumn("country", coalesce(col("country"), lit("UNKNOWN")))
.filter(col("is_test").isNull() | (col("is_test") == lit(False)))
.drop("is_test")
)
def enrich_events(df: DataFrame) -> DataFrame:
"""
Enrich events with derived columns.
- event_date: date extracted from timestamp
- event_hour: hour of the event
- amount_category: buckets for analysis
- ingest_timestamp: when the pipeline ran
"""
return (
df
.withColumn("event_date", to_date(col("timestamp")))
.withColumn("event_hour", date_format(col("timestamp"), "yyyy-MM-dd HH:00:00"))
.withColumn("amount_category",
when(col("amount") == 0, "free")
.when(col("amount") < 20, "low")
.when(col("amount") < 100, "medium")
.otherwise("high"))
.withColumn("ingest_timestamp", current_timestamp())
.withColumn("source_file", input_file_name())
)
def write_parquet(df: DataFrame, output_path: str, partition_col: str = "event_date") -> None:
"""
Write the DataFrame as partitioned Parquet.
Uses coalesce โ if number of output files is too high, repartition before write.
"""
print(f"[INFO] Writing partitioned Parquet to: {output_path}")
# If the DataFrame has many small partitions, coalesce to ~128 MB files
# Approximate: row_count * avg_row_bytes / 128 MB
row_estimate = df.count()
avg_row_bytes = 500 # rough estimate in bytes
target_files = max(1, int((row_estimate * avg_row_bytes) / (128 * 1024 * 1024)))
if target_files < df.rdd.getNumPartitions():
df = df.coalesce(target_files)
(
df.write
.mode("overwrite")
.partitionBy(partition_col)
.option("compression", "snappy")
.parquet(output_path)
)
print(f"[INFO] Successfully wrote {row_estimate} rows to {output_path}")
def run_quality_checks(df: DataFrame) -> dict:
"""
Run basic data quality checks.
Returns a dict of metrics for logging / alerting.
"""
total = df.count()
null_amt = df.filter(col("amount").isNull()).count()
zero_amt = df.filter(col("amount") == 0).count()
countries = df.select("country").distinct().count()
metrics = {
"total_rows": total,
"null_amounts": null_amt,
"zero_amounts": zero_amt,
"distinct_countries": countries,
}
print(f"[QC] {metrics}")
if null_amt > 0:
print(f"[WARN] Found {null_amt} rows with null amounts after validation")
return metrics
def main():
INPUT_PATH = sys.argv[1] if len(sys.argv) > 1 else "/data/events/raw"
OUTPUT_PATH = sys.argv[2] if len(sys.argv) > 2 else "/data/events/clean"
spark = create_spark_session()
try:
# 1. Read
raw_df = read_raw_events(spark, INPUT_PATH)
# 2. Validate
clean_df = validate_events(raw_df)
# 3. Enrich
enriched_df = enrich_events(clean_df)
# 4. Quality checks (cache before action to avoid re-read)
enriched_df.cache()
run_quality_checks(enriched_df)
# 5. Write
write_parquet(enriched_df, OUTPUT_PATH, partition_col="event_date")
except Exception as e:
print(f"[ERROR] ETL pipeline failed: {e}", file=sys.stderr)
raise
finally:
if 'enriched_df' in dir():
enriched_df.unpersist()
spark.stop()
if __name__ == "__main__":
main()
Running the ETL
# Local mode
python3 etl_pipeline.py /data/events/raw /data/events/clean
# Cluster mode via spark-submit
spark-submit \
--master yarn \
--deploy-mode cluster \
--num-executors 20 \
--executor-memory 8g \
--executor-cores 4 \
--conf spark.sql.adaptive.enabled=true \
etl_pipeline.py hdfs:///events/raw/ hdfs:///events/clean/