Memory Atlas Β· Data processing

PySpark

Build a durable mental model of how Spark plans, moves, and optimizes distributed workβ€”then retrieve it under interview pressure.

Chapters
06
Advanced
02
Mode
Recall

Read for structure. Pause at each memory map and answer before revealing the detail.

Foundation

PySpark Overview and Recall Plan

#

PySpark recall atlas

60-second map

Direct answer: PySpark is the Python API for Apache Spark. A driver builds a lazy DAG; executors run its tasks over partitions. Prefer DataFrames and Spark SQL so Catalyst and Tungsten can optimize the work. Performance is usually governed by shuffle volume, partition size, join choice, skew, caching discipline, and file layout.

Memory map: API -> plan -> stages -> tasks -> partitions -> files. At each arrow ask: what stays local, what crosses the network, and where can data become unbalanced?

Execution vocabulary

TermRecall in one line
ApplicationOne driver plus its executors.
DriverRuns user code, creates plans, schedules work, and collects small results.
ExecutorWorker JVM that runs tasks and stores shuffle/cache blocks.
DAGDependency graph built from lazy transformations.
JobWork triggered by one action.
StageA pipeline of narrow transformations; shuffle boundaries split stages.
TaskOne stage's work for one partition.
PartitionBasic unit of parallelism and fault recovery.
TransformationLazy operation returning a new RDD/DataFrame.
ActionOperation that starts execution or writes/returns a result.
ShuffleNetwork/disk redistribution, usually the dominant cost.

Recall stack

  1. Say the direct answer in one sentence.
  2. Draw driver -> DAG -> stages -> tasks -> executors/partitions.
  3. Identify the shuffle and the data size on each side of it.
  4. Name the observable evidence: Spark UI, explain("formatted"), partition counts, and input/output sizes.
  5. Close with the production trade-off, not a memorized configuration value.

WHY PYSPARK MATTERS FOR YOUR INTERVIEW

  • Almost every data engineering role today requires Spark knowledge
  • Senior engineers asked: internals (DAG, stages, shuffle) not just "what is Spark"
  • Code questions are common: they will ask you to write PySpark code live
  • Optimization questions are the #1 differentiator at senior level
  • AQE (Adaptive Query Execution) is a hot 2024-2026 topic

3-DAY SCHEDULE

πŸ—ΊοΈMemory Map
DAY 15-6 hoursARCHITECTURE + RDD
Spark Architecture (Driver, Executor, Cluster Manager)
SparkContext vs SparkSession
DAG (Directed Acyclic Graph) β€” stages, tasks
Lazy Evaluation β€” transformations vs actions
Narrow vs Wide Transformations
RDD: what it is, 5 properties
RDD Transformations (map, flatMap, filter, reduceByKey, groupByKey)
RDD Actions (collect, count, take, reduce, foreach)
RDD Persistence (cache, persist, storage levels)
RDD Lineage + Checkpointing
Broadcast Variables + Accumulators
map () vs flatMap() vs mapPartitions()
DAY 25-6 hoursDATAFRAME + SPARKSQL
RDD vs DataFrame vs Dataset β€” when to use each
SparkSession creation + config
Reading multiple file formats (CSV, JSON, Parquet, ORC, JDBC, Delta)
Reading multiple files from multiple sources at once
Schema: inferSchema vs explicit StructType
Core transformations: select, filter, groupBy, agg, join, union
withColumn, withColumnRenamed, drop, alias
Handling NULLs: na.drop, na.fill, isNull, isNotNull
Window Functions (ROW_NUMBER, LAG, LEAD, RANK, running totals)
UDFs: Python UDF vs Pandas UDF (Arrow-based)
Joins: types and hints
Writing: partitionBy, bucketBy, saveAsTable, write modes
explode, flatten nested JSON, struct, array columns
Spark SQL: createTempView, sql()
DAY 35-6 hoursOPTIMIZATION + PERFORMANCE
Catalyst Optimizer: 4 phases (Analysis β†’ Logical Opt β†’ Physical Plan β†’ CodeGen)
Tungsten Engine (off-heap, codegen, vectorized execution)
Predicate Pushdown β€” when it works and when it doesn't
repartition () vs coalesce()
spark.sql.shuffle.partitions β€” tuning
Join Strategies: Broadcast, Shuffle Hash, Sort-Merge, BNLJ, Cartesian
Broadcast join β€” threshold, hints
Data Skew β€” detection + solutions (salting, AQE, broadcast)
AQE (Adaptive Query Execution) β€” 3 main features
cache() vs persist() β€” storage levels
Checkpointing vs caching
groupByKey vs reduceByKey vs aggregateByKey
Small files problem and solutions
Spark configurations (memory, parallelism, serialization)
Spark UI β€” how to read it for debugging
Dynamic Allocation

PRIORITY MATRIX

MUST KNOW β€” Will definitely be asked (60%)

  1. DAG, stages, tasks, lazy evaluation
  2. Narrow vs Wide transformations
  3. repartition() vs coalesce()
  4. groupByKey vs reduceByKey (performance!)
  5. cache() vs persist() storage levels
  6. Broadcast join β€” threshold, when to use
  7. AQE β€” 3 features (coalesce, join switching, skew)
  8. Python UDF vs Pandas UDF (performance)
  9. RDD vs DataFrame vs Dataset
  10. Data skew detection + salting fix

SHOULD KNOW β€” High probability (30%)

  1. Catalyst 4 phases
  2. Tungsten engine
  3. Spark UI reading
  4. Window functions + code
  5. Reading multiple sources at once
  6. Schema inference vs explicit StructType
  7. Predicate pushdown
  8. Checkpointing vs caching
  9. spark.sql.shuffle.partitions
  10. Broadcast variables + accumulators

NICE TO KNOW β€” Differentiators (10%)

  1. mapPartitions() vs map()
  2. CombineByKey vs aggregateByKey internals
  3. Dynamic allocation configs
  4. Kryo serialization
  5. Off-heap memory (Tungsten)

THE 10-YEAR ENGINEER FRAMING

"I've used PySpark in production for [X] years. When someone says
'the job is slow', I don't guess β€” I open Spark UI, look at the
Stages tab for skewed tasks, check GC time on the Executors tab,
look at the SQL plan for Sort-Merge Joins that should be Broadcast,
and check if AQE is enabled. The answer is almost always one of:
data skew, too many/few partitions, wrong join strategy, or
DataFrame recomputed multiple times instead of being cached."

SPARK ECOSYSTEM OVERVIEW

πŸ“ Architecture Diagram
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    SPARK ECOSYSTEM                       β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  LANGUAGE APIs:  Python (PySpark), Scala, Java, R       β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  HIGH-LEVEL APIs:                                        β”‚
β”‚  Spark SQL / DataFrame API (structured)                 β”‚
β”‚  Streaming (Structured Streaming)                       β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  CORE ENGINE:                                            β”‚
β”‚  Catalyst Optimizer + Tungsten Execution Engine         β”‚
β”‚  RDD API (low-level)                                    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  STORAGE:  HDFS, S3, ADLS, Delta Lake, Iceberg          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  CLUSTER:  YARN, Kubernetes, Mesos, Standalone          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Three memory maps from the quick recalls

Architecture: D-C-E / J-S-T / N-W

Driver coordinates; cluster manager allocates; executors compute. An action creates a job, shuffle boundaries create stages, and a task processes one partition. Narrow dependencies stay in a stage; wide dependencies shuffle.

DataFrames: C-T-A / S-F-W-G-J

Catalyst optimizes, Tungsten executes, and Arrow accelerates Python vectorization. The core interview flow is select, filter, withColumn, groupBy, and join, followed by windows, null handling, nested data, and writes.

Optimization: P-J-S-C-F

Inspect the plan, choose the join, treat skew, cache only reused expensive results, and control file size. AQE can adjust runtime partitions and joins, but evidence still comes from the UI and physical plan.

Foundation

Architecture and RDDs

#

Architecture and RDDs

Direct answer: Spark's driver turns lazy transformations into a DAG, divides a job into shuffle-delimited stages, and schedules one task per partition on executors. An RDD is an immutable, partitioned, lineage-backed distributed collection; lineage gives fault recovery without replicating every intermediate result.

Memory map: driver -> DAG -> job -> stages -> tasks -> executor partitions. For RDDs remember immutable, distributed, lazy, partitioned, resilient.

🧠 MASTER MEMORY MAP β€” Day 1

🧠 SPARK ARCHITECTURE = "DCE" (Driver β†’ Cluster Manager β†’ Executors)
SPARK ARCHITECTURE"DCE" (Driver β†’ Cluster Manager β†’ Executors)
DDriver: Brain of the app. Creates DAG, schedules tasks, ONE per app
CCluster Manager: Resource allocator (YARN/K8s/Mesos/Standalone)
EExecutors: JVM workers. Run tasks, hold cached data. Many per app
DAG FLOW"DAG β†’ Stages β†’ Tasks β†’ Partitions"
DAG breaks at WIDE transformations (shuffle boundaries)
Each Stage = group of tasks (no shuffle within)
Each Task = one partition processed
LAZY EVALUATION"Plan first, execute only on ACTION"
Transformations→build DAG (nothing runs!)
Actions (collect, count, show, write) β†’ trigger execution
RDD 5 PROPERTIES"RD-ILP"
RResilient: fault-tolerant via lineage
DDistributed: spread across cluster
IImmutable: never modified, transformations make new RDDs
LLazily evaluated
PPartitioned: split into partitions for parallelism
NARROW vs WIDE = "One-to-One vs Shuffle"
NARROW: each output partition from ONE input partition (map, filter, flatMap)
WIDE: output partitions from MULTIPLE input partitions→SHUFFLE! (groupBy, join, sort)
RDD TRANSFORMATIONS"MFR-GJU"
Mmap(), flatMap(), mapPartitions()
Ffilter()
RreduceByKey(), repartition()
GgroupByKey() (⚠️ AVOID β€” use reduceByKey!)
Jjoin(), cogroup()
Uunion(), distinct()

Recall Stack: Spark execution

Answer First: The driver builds a lazy dependency graph, an action creates a job, shuffle boundaries divide it into stages, and executors run one task per partition.

Memory Map: driver -> DAG -> job -> stage -> task -> partition. Narrow dependencies pipeline; wide dependencies shuffle.

SECTION 1: SPARK ARCHITECTURE

Answer First: Apache Spark is a distributed compute engine that evaluates lazy data-processing plans across a cluster and can reuse intermediate data in memory.

Memory Map: input -> partitions -> transformations -> action -> distributed result.

Q1: What Is Apache Spark?

Definition (1 line): Apache Spark is an open-source, in-memory distributed computing engine designed for large-scale data processing across clusters of machines.

Simple Explanation: Before Spark, Hadoop MapReduce was the standard. But MapReduce writes intermediate results to disk after every step β€” read from disk, process, write to disk, repeat. For jobs that iterate over data many times (like ML training), this disk I/O made things painfully slow.

Spark solved this by keeping data in memory between processing steps. Instead of reading/writing to disk 10 times during an ML training loop, Spark reads once and keeps the data in RAM across all 10 iterations.

Real-world Analogy: Imagine cooking a recipe that requires 5 steps. MapReduce is like putting all ingredients back in the fridge after each step, then taking them out again for the next step. Spark is like keeping everything on the kitchen counter β€” you only go to the fridge once at the start and once at the end. The result? Up to 100x faster for iterative workloads.

Key facts to memorize:

  • Written in Scala, runs on the JVM
  • Supports Python (PySpark), Scala, Java, R, SQL
  • Processes batch (large datasets) and streaming (real-time data)
  • Can run on YARN, Kubernetes, Mesos, or Standalone cluster
  • Default since Spark 2.0: DataFrame API (not RDD) with Catalyst optimizer

Interview Tip: "Spark's core advantage over MapReduce is in-memory computation β€” intermediate data stays in RAM instead of being written to disk. This makes iterative algorithms like ML training up to 100x faster. For single-pass ETL jobs, the speedup is smaller (~2-3x) because disk I/O isn't the bottleneck."

What NOT to say: "Spark is 100x faster than Hadoop." β€” This is an oversimplification. It's 100x faster for iterative workloads because of in-memory caching. For simple one-pass jobs, the difference is much smaller. Saying "100x faster" without qualification signals shallow understanding.

Answer First: The driver plans and coordinates work, the cluster manager grants resources, and executors run tasks and store shuffle or cached data.

Memory Map: driver -> cluster manager -> executors -> tasks/partitions.

Q2: Explain Spark Architecture β€” the 3 Components

Definition (1 line): Spark uses a master-worker architecture with three components: Driver (brain), Cluster Manager (resource allocator), and Executors (workers).

Simple Explanation: When you submit a Spark job, one machine becomes the Driver β€” it plans the work and tells others what to do. The Cluster Manager (like YARN) finds available machines and allocates resources. The Executors are the actual workers β€” they receive tasks from the Driver, process data, and send results back.

Real-world Analogy: Think of a construction project:

  • Driver = The architect who creates the blueprint, decides what gets built in what order, and coordinates everyone
  • Cluster Manager = The HR department that assigns workers from the labor pool to this specific project
  • Executors = The construction workers who actually build things, following the architect's blueprint

If the architect (Driver) leaves, the entire project stops. If one worker (Executor) gets sick, the architect reassigns that work to another worker.

🧠 Memory Map
DRIVER (ONE per application)
Runs your main() / SparkSession code
Converts your code β†’ Logical Plan β†’ DAG
Splits DAG into Stages (at shuffle boundaries)
Splits Stages into Tasks (one per partition)
Talks to Cluster Manager to get resources
Talks to Executors to send tasks and get results
Hosts SparkContext and SparkSession objects
⚠️If Driver dies β†’ entire application fails!
⚠️Driver memory must hold the result of collect() β€” never collect() huge data!
CLUSTER MANAGER (External)
Allocates resources (CPU, RAM) for the application
Options: YARN (Hadoop), Kubernetes (cloud-native), Mesos, Standalone
Spark just asks "give me N executors with X cores and Y GB RAM"
CM manages competing applications on the same cluster
EXECUTORS (MANY per application)
JVM processes launched on worker nodes by Cluster Manager
Each executor has: N CPU cores + M GB RAM
Run tasks assigned by Driver (one task per core at a time)
Store cached/persisted data in their memory
Each application gets its OWN dedicated executors (isolation!)
If executor dies β†’ tasks fail, Driver reschedules on other executors
πŸ“ Architecture Diagram
SPARK CLUSTER DIAGRAM:

    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚         DRIVER NODE          β”‚
    β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
    β”‚  β”‚    SparkSession       β”‚   β”‚
    β”‚  β”‚    DAGScheduler       β”‚   β”‚
    β”‚  β”‚    TaskScheduler      β”‚   β”‚
    β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚ Task assignments
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚      CLUSTER MANAGER         β”‚
    β”‚  (YARN / Kubernetes / etc)   β”‚
    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚              β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”
    β”‚EXECUTOR 1β”‚    β”‚EXECUTOR 2β”‚
    β”‚ Core 1   β”‚    β”‚ Core 1   β”‚
    β”‚ Core 2   β”‚    β”‚ Core 2   β”‚
    β”‚ Memory   β”‚    β”‚ Memory   β”‚
    β”‚ Task A   β”‚    β”‚ Task B   β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

What the interviewer is testing: Can you explain how the three components interact? Do you understand the flow β€” that the Driver plans, CM allocates resources, and Executors execute? Can you explain what happens when something fails?

What NOT to say: "The Cluster Manager runs the tasks." β€” No! The CM only allocates resources (CPU/memory). It's the Executors that run tasks, and the Driver that schedules them.

Answer First: SparkSession is the unified modern entry point for DataFrame and SQL work; its underlying SparkContext owns low-level connection and RDD execution services.

Memory Map: SparkSession -> SQL/DataFrame APIs -> SparkContext -> cluster.

Q3: SparkContext vs SparkSession β€” What's the Difference?

Definition (1 line): SparkContext was Spark 1.x's entry point for RDD-only operations. SparkSession (Spark 2.0+) is the unified entry point that wraps SparkContext + SQLContext + HiveContext into one object.

Simple Explanation: In Spark 1.x, you needed different objects for different APIs β€” SparkContext for RDDs, SQLContext for DataFrames, HiveContext for Hive tables. This was confusing. Spark 2.0 introduced SparkSession as a single entry point for everything. You create one SparkSession and it gives you access to all APIs.

Code:

python β€” editable
# SparkContext (Spark 1.x) β€” entry point for RDD API
from pyspark import SparkContext
sc = SparkContext("local[*]", "MyApp")

# SparkSession (Spark 2.0+) β€” entry point for EVERYTHING
# Wraps SparkContext + SQLContext + HiveContext in one object
from pyspark.sql import SparkSession

spark = SparkSession.builder \
    .appName("MyApp") \
    .master("yarn") \
    .config("spark.executor.memory", "4g") \
    .config("spark.executor.cores", "2") \
    .config("spark.sql.shuffle.partitions", "200") \
    .enableHiveSupport() \
    .getOrCreate()               # creates new or returns existing session

sc = spark.sparkContext          # access SparkContext from SparkSession

Interview Tip: "In modern Spark, always start with SparkSession. SparkContext is still there under the hood β€” you can access it via spark.sparkContext when you need RDD operations. But SparkSession is the recommended entry point."

Answer First: Spark records transformations as a DAG; an action creates a job, shuffle boundaries create stages, and each stage launches one task per partition.

Memory Map: DAG -> action/job -> shuffle boundary/stage -> partition/task.

Q4: What Is a DAG? How Does Spark Execute Your Code?

Definition (1 line): A DAG (Directed Acyclic Graph) is Spark's internal execution plan β€” a graph of all transformations your code performs, organized so Spark can optimize and execute them efficiently.

Simple Explanation: When you write Spark code, nothing executes immediately (lazy evaluation). Instead, Spark builds a DAG β€” a plan that records every transformation and how they depend on each other. When you call an action (like count() or write()), Spark looks at the entire DAG, optimizes it (removes unnecessary steps, reorders operations), and then executes it.

Real-world Analogy: Think of planning a road trip. You don't start driving after deciding the first stop β€” you plan the entire route first. Once you see the full route, you might realize you can skip a detour, combine two stops into one, or take a faster highway. That's exactly what Spark does with your code β€” it sees the entire plan before executing, so it can optimize the route.

How DAG becomes execution:

πŸ“‹ Overview
DAGSTAGES β†’ TASKS
Step 1: DAG β€” Spark records every transformation as a graph node
Step 2: STAGES β€” Spark cuts the DAG at shuffle boundaries (wide transformations)
Step 3: TASKS β€” Each stage is split into tasks (one task per partition)
Step 4: EXECUTION β€” Tasks run in parallel across executors
Example:
textFile→filter → flatMap → map → reduceByKey → sortByKey → collect()
β–² β–² β–²
narrow ops WIDE (new stage) WIDE (new stage)
Result: 3 stages
Stage 1: textFile→filter → flatMap → map → write shuffle files
Stage 2: shuffle read→reduceByKey → write shuffle files
Stage 3: shuffle read→sortByKey → collect
If Stage 1 has 100 partitions→100 tasks
Each task processes ONE partition
Tasks run IN PARALLEL across executors (up to num_cores tasks at once)

Key components inside the Driver:

ComponentWhat It DoesOperates At
DAGSchedulerBreaks DAG into stages at shuffle boundariesStage level
TaskSchedulerAssigns tasks to executors with data localityTask level
Catalyst OptimizerOptimizes the logical plan before executionQuery level

Interview Tip: "A DAG is Spark's execution plan. It breaks at wide transformation boundaries into stages. Each stage has one task per partition. Tasks run in parallel on executors. The DAGScheduler manages stage scheduling; the TaskScheduler manages individual task assignment to executors with data locality preference."

What NOT to say: "Spark processes data step by step." β€” No! Spark plans the ENTIRE DAG first, then executes. This whole-plan view is what enables optimizations like predicate pushdown and column pruning.

Answer First: Transformations are lazy so Spark can inspect and optimize the complete plan before an action triggers execution.

Memory Map: transformations -> logical plan -> optimization -> action -> execution.

Q5: What Is Lazy Evaluation? Why Does Spark Use It?

Definition (1 line): Lazy evaluation means Spark does NOT execute transformations immediately β€” it only builds a plan (DAG). Execution only happens when you call an action (like count(), show(), write()).

Simple Explanation: When you write df.filter("age > 30"), Spark doesn't actually filter anything. It just notes: "When you eventually need results, I'll filter age > 30." Only when you call df.count() does Spark look at the entire chain of transformations, optimize them, and execute them all at once.

Why this is powerful (3 reasons):

  1. Optimization β€” Catalyst can see the ENTIRE plan before executing. It can push filters earlier, prune unnecessary columns, and choose the best join strategy.
  2. Fault Recovery β€” Since Spark "remembers" how to compute each step (lineage), it can recompute lost partitions from the source.
  3. Efficiency β€” Spark never computes data you don't need. df.filter(...).first() only reads ONE partition, not the entire dataset.

Code β€” See lazy evaluation in action:

python β€” editable
# EXAMPLE: Lazy evaluation in action
df = spark.read.csv("/huge/file.csv")       # ← NO execution (just reads metadata)
df2 = df.filter("age > 30")                 # ← NO execution (builds DAG node)
df3 = df2.select("name", "age")             # ← NO execution (builds DAG node)
df4 = df3.withColumn("age_group",           # ← NO execution (builds DAG node)
        when(col("age") > 60, "senior").otherwise("adult"))

# ONLY NOW does Spark execute β€” when you call an ACTION:
result = df4.count()    # ← TRIGGERS execution of the ENTIRE plan
df4.show()              # ← TRIGGERS execution again (recomputes from scratch unless cached!)

Transformations vs Actions β€” Quick Reference:

Transformations (Lazy β€” build DAG)Actions (Trigger execution)
filter(), select(), where()count(), show(), first()
map(), flatMap(), groupBy()collect(), take(n), top(n)
join(), union(), distinct()write.parquet(), write.csv()
withColumn(), drop(), agg()reduce(), foreach(), toPandas()

What NOT to say: "Lazy evaluation means Spark is slow to start." β€” No! It means Spark is smart about when to start. By waiting, it can see the full plan and optimize it. This makes execution FASTER, not slower.

Answer First: A shuffle redistributes records between partitions for wide dependencies, adding serialization, network, disk, sorting, and stage-boundary cost.

Memory Map: map output -> partition exchange -> fetch/sort -> reduce task.

Q6: What Is a Shuffle? Why Is It the Most Expensive Operation?

Definition (1 line): A shuffle is when Spark must redistribute data across the network β€” sending records from multiple input partitions to new output partitions β€” because the next operation needs data grouped by a different key.

Simple Explanation: Imagine you have 100 files of customer orders, each file containing orders from all states. Now someone asks: "Give me all orders grouped by state." You need to open all 100 files, pull out the California orders from each, and put them together. That data movement β€” pulling records from many sources and regrouping them β€” is a shuffle.

Why shuffles are expensive (4 costs):

🧠 Memory Map
A SHUFFLE INVOLVES
1. SERIALIZE→Convert in-memory objects to bytes (CPU cost)
2. DISK WRITE→Write shuffle files to local disk (I/O cost)
3. NETWORK→Transfer data across machines (network cost)
4. DESERIALIZE→Convert bytes back to in-memory objects (CPU cost)
A single shuffle can take 80% of your job's total runtime!

Real-world Analogy: Shuffles are like mailing packages. If all your data is already in the right place (narrow transformation), you just process it β€” no mailing needed. But if you need to regroup data by a different key (wide transformation), you have to package it up, put it on a truck, drive it to the right destination, and unpack it. That's slow and expensive.

Narrow vs Wide Transformations

🧠 Memory Map
NARROW TRANSFORMATION (no shuffle)
Each output partition depends on exactly ONE input partition
Can be "pipelined" β€” executed without waiting for other partitions
No data movement across the network
No stage boundary
Examples:
map(f)β†’apply function to each element
filter(f)β†’keep elements matching condition
flatMap(f)β†’map + flatten output
mapPartitions(f)β†’apply function to entire partition at once
union()β†’combine two RDDs (no shuffle)
sample()β†’random sample
coalesce(n)β†’reduce partitions (merges local partitions)
WIDE TRANSFORMATION (shuffle required)
Output partitions depend on MULTIPLE input partitions
Must wait for ALL map tasks to complete before reduce tasks start
Writes intermediate data to LOCAL DISK (shuffle files)
Creates a new STAGE boundary
Examples:
groupByKey()β†’group all values by key (EXPENSIVE shuffle!)
reduceByKey()β†’reduce locally then shuffle (smarter!)
groupBy()β†’SQL-style group by
join()β†’must shuffle both sides (unless broadcast)
repartition(n)β†’full shuffle to redistribute
distinct()β†’must shuffle to find unique values
sortBy() / sort()β†’must shuffle to globally sort
cogroup()β†’group multiple RDDs by key

Interview Tip: "Minimizing shuffles is the #1 performance optimization in Spark. I look for three things: Can I use reduceByKey instead of groupByKey? Can I broadcast the small table in a join? Can I pre-partition data to avoid shuffles in repeated operations?"

What NOT to say: "Shuffles are always bad." β€” Not always. Shuffles are necessary for correct results in operations like joins and aggregations. The goal is to minimize unnecessary shuffles, not avoid them entirely.

Recall Stack: RDDs and fault recovery

Answer First: An RDD is immutable, distributed, partitioned, lazy, and resilient through lineage. Cache for repeated reuse; checkpoint to truncate expensive or unstable lineage.

Memory Map: create -> transform -> partition -> action -> lineage recovery. For key/value work, prefer local aggregation before shuffle.

SECTION 2: RDD β€” RESILIENT DISTRIBUTED DATASET

Answer First: An RDD is an immutable, partitioned distributed collection whose lost partitions can be recomputed from lineage.

Memory Map: immutable records -> partitions -> lazy transforms -> lineage recovery.

Q7: What Is an RDD? Explain Its 5 Properties.

Definition (1 line): An RDD (Resilient Distributed Dataset) is Spark's fundamental data abstraction β€” an immutable, distributed collection of records that can be processed in parallel and recovered from failures using lineage.

Simple Explanation: An RDD is like a spreadsheet that's been split across multiple machines. Each machine holds a few rows (a partition). You can apply transformations to all partitions in parallel β€” filter rows, transform values, join with another RDD. If one machine crashes and loses its rows, Spark re-creates them by replaying the transformations from the original data (lineage).

Real-world Analogy: Think of an RDD like a recipe card for your data:

  • The recipe card tells you HOW to produce the data (lineage)
  • It doesn't store the data itself until you ask for it (lazy)
  • If you lose one batch (partition), re-run the recipe (recompute from lineage)
  • Multiple chefs (executors) can cook different portions simultaneously (parallel)

The 5 Properties (memorize "RD-ILP"):

PropertyWhat It MeansWhy It Matters
ResilientRecovers from failures via lineageNo data loss even if machines crash
DistributedData split across multiple nodesEnables parallel processing
ImmutableCannot be modified in placeSimplifies fault recovery (just replay)
Lazily evaluatedOnly computes on actionEnables optimization before execution
PartitionedData split into partitionsUnit of parallelism (1 task per partition)

Code β€” Creating RDDs:

python β€” editable
sc = spark.sparkContext

# From a Python collection
rdd = sc.parallelize([1, 2, 3, 4, 5], numSlices=4)  # 4 partitions
print(rdd.getNumPartitions())   # 4

# From a file
rdd = sc.textFile("hdfs:///path/to/file.txt", minPartitions=10)

# From another RDD (transformation)
filtered = rdd.filter(lambda x: x > 2)  # new RDD, not executed yet

When to use RDD vs DataFrame:

Use RDD WhenUse DataFrame When
Low-level control over partitioningStructured data with columns
Unstructured data (text, logs)SQL-like operations (filter, join, agg)
Custom serialization neededWant Catalyst optimizer benefits
Fine-grained data manipulationPerformance-critical workloads

Interview Tip: "In modern Spark (2.0+), DataFrames are preferred over RDDs because they benefit from Catalyst optimization. But RDDs are still the foundation β€” every DataFrame operation compiles down to RDD operations internally. Understanding RDDs helps you debug performance issues and understand Spark's execution model."

What NOT to say: "RDD is deprecated." β€” RDDs are NOT deprecated. DataFrames are the recommended API for structured data, but RDDs are still fully supported and necessary for certain use cases (custom partitioning, unstructured data, low-level control).

Answer First: RDD transformations return new lazy RDDs; prefer narrow operations and map-side aggregation when they produce the required result.

Memory Map: map/filter/flatMap -> key transform -> local combine -> optional shuffle.

Q8: Explain Key RDD Transformations with Code

Why this matters: Interviewers often ask you to write RDD-based code to test if you understand the fundamentals beyond the DataFrame API.

python β€” editable
# ─── map() β€” apply function to EACH element, one-to-one ───
rdd = sc.parallelize([1, 2, 3, 4])
doubled = rdd.map(lambda x: x * 2)
doubled.collect()   # [2, 4, 6, 8]

# ─── filter() β€” keep elements matching condition ───
evens = rdd.filter(lambda x: x % 2 == 0)
evens.collect()     # [2, 4]

# ─── flatMap() β€” one input β†’ ZERO OR MORE outputs (flattened) ───
sentences = sc.parallelize(["hello world", "foo bar"])
words = sentences.flatMap(lambda s: s.split(" "))
words.collect()     # ['hello', 'world', 'foo', 'bar']
# map() would give: [['hello', 'world'], ['foo', 'bar']]  (nested lists!)
# flatMap() flattens: ['hello', 'world', 'foo', 'bar']     (flat list!)

# ─── mapPartitions() β€” function runs ON ENTIRE PARTITION (not row-by-row) ───
# MORE EFFICIENT for expensive initialization (DB connections, ML models)
def process_partition(iterator):
    # Setup once per partition (not once per row!)
    connection = create_db_connection()
    for record in iterator:
        yield connection.lookup(record)
    connection.close()

result = rdd.mapPartitions(process_partition)

mapPartitions vs map β€” Interview favorite:

Aspectmap()mapPartitions()
Function calledOnce per ELEMENTOnce per PARTITION
Setup costPaid for every elementPaid once per partition
Use whenSimple transformsExpensive setup (DB, HTTP, model loading)
MemoryLow (one element at a time)Higher (entire partition in memory)
python β€” editable
# ─── reduceByKey() vs groupByKey() β€” CRITICAL interview question ───

pairs = sc.parallelize([("a", 1), ("b", 2), ("a", 3), ("b", 4)])

# reduceByKey β€” aggregates LOCALLY first, then shuffles partial results
total = pairs.reduceByKey(lambda a, b: a + b)
total.collect()     # [('a', 4), ('b', 6)]
# What happens: Each partition reduces locally, only small partial sums are shuffled

# groupByKey β€” shuffles ALL raw values, THEN groups them
grouped = pairs.groupByKey().mapValues(list)
grouped.collect()   # [('a', [1, 3]), ('b', [2, 4])]
# What happens: ALL individual values sent across network β†’ much more data shuffled!

Why reduceByKey is better than groupByKey β€” visual explanation:

🧠 Memory Map
Dataset: [("a", 1), ("a", 1), ("a", 1), ("a", 1), ("a", 1)] (5 records for key "a")
Assume 2 partitions, each with ~2-3 records.
groupByKey flow:
Partition 1: ("a",1), ("a",1), ("a",1) ──shuffle──→ Reducer: ("a", [1,1,1,1,1]) β†’ sum = 5
Partition 2: ("a",1), ("a",1) ──shuffle──→ 5 values sent over network!
reduceByKey flow:
Partition 1: ("a",1), ("a",1), ("a",1) β†’ local reduce β†’ ("a", 3) ──shuffle──→ ("a", 3+2) = 5
Partition 2: ("a",1), ("a",1) β†’ local reduce β†’ ("a", 2) ──shuffle──→ Only 2 values sent!
⚠️For a key with 1 million values:
groupByKey: sends 1,000,000 values over network→OOM risk!
reduceByKey: sends ~N values (one per partition) β†’ fast and safe!
python β€” editable
# ─── aggregateByKey β€” when output type differs from input ───
# Use case: computing average (need sum AND count, not just sum)
pairs = sc.parallelize([("a", 1), ("b", 2), ("a", 3), ("b", 4)])

zero_value = (0, 0)  # (sum, count)
seq_func = lambda acc, val: (acc[0] + val, acc[1] + 1)     # within partition
comb_func = lambda acc1, acc2: (acc1[0] + acc2[0], acc1[1] + acc2[1])  # across partitions

avg = pairs.aggregateByKey(zero_value, seq_func, comb_func) \
           .mapValues(lambda x: x[0] / x[1])
avg.collect()   # [('a', 2.0), ('b', 3.0)]

# ─── join() β€” join two key-value RDDs by key ───
rdd1 = sc.parallelize([("a", 1), ("b", 2)])
rdd2 = sc.parallelize([("a", "x"), ("b", "y")])
joined = rdd1.join(rdd2)
joined.collect()    # [('a', (1, 'x')), ('b', (2, 'y'))]

# ─── coalesce() vs repartition() ───
rdd.repartition(10)    # full shuffle, increases OR decreases to 10 equal partitions
rdd.coalesce(4)         # no full shuffle, reduces (merges adjacent partitions)
# RULE: Use coalesce() to DECREASE partitions (no shuffle)
#       Use repartition() to INCREASE partitions (requires shuffle)

Answer First: Actions trigger the DAG and either return bounded results, write output, or apply side effects; driver-returning actions must remain size-safe.

Memory Map: lazy RDD -> action -> jobs/stages/tasks -> driver or storage sink.

Q9: What Are RDD Actions? List the Key Ones.

Definition (1 line): Actions are RDD operations that trigger execution of the DAG and return results to the Driver or write data to storage.

Simple Explanation: Transformations are lazy β€” they build a plan. Actions are eager β€” they say "execute this plan NOW and give me results." Every Spark program must end with at least one action, otherwise nothing actually runs.

python β€” editable
# Actions TRIGGER computation and return results to Driver
rdd = sc.parallelize([1, 2, 3, 4, 5])

rdd.collect()               # returns ALL elements as Python list ⚠️ OOM risk on large RDDs!
rdd.count()                 # count of elements β†’ 5
rdd.first()                 # first element β†’ 1
rdd.take(3)                 # first N elements β†’ [1, 2, 3]
rdd.top(3)                  # top N (sorted descending) β†’ [5, 4, 3]
rdd.takeSample(False, 3)    # 3 random samples (False = without replacement)
rdd.reduce(lambda a, b: a + b)    # aggregate all elements β†’ 15
rdd.fold(0, lambda a, b: a + b)   # like reduce but with zero value β†’ 15
rdd.foreach(print)          # apply function to each element (no return value)
rdd.saveAsTextFile("/path/")    # write each partition as a text file
rdd.countByValue()          # count occurrences of each unique value
rdd.countByKey()            # for key-value RDDs

⚠️ TRAP β€” collect() is dangerous:

python β€” editable
# On a 100 GB dataset:
result = df.collect()  # ← tries to send ALL 100 GB to Driver memory β†’ OutOfMemoryError!

# Safe alternatives:
df.show(20)              # show first 20 rows (small data to Driver)
df.take(100)             # take 100 rows only
df.write.parquet("/out") # write to storage (no data to Driver)
df.count()               # just returns a number

Answer First: Persist an expensive RDD only when it will be reused; cache() chooses the default storage level while persist() makes that level explicit.

Memory Map: reuse -> choose storage level -> materialize -> reuse -> unpersist.

Q10: Explain RDD Persistence β€” cache() vs persist()

Definition (1 line): Persistence stores an RDD's computed data in memory/disk so it doesn't have to be recomputed every time it's used. cache() is a shortcut for persist() with a default storage level.

Simple Explanation: Without caching, every time you call an action on an RDD/DataFrame, Spark recomputes it from scratch β€” re-reading from disk, re-applying all transformations. If you use the same data multiple times (like training an ML model), caching saves the computed result so the second use is instant.

Real-world Analogy: Imagine you calculate a complex financial report from raw transaction data. Without caching, every time someone asks for a number from that report, you recalculate the entire thing from scratch. With caching, you calculate it once, pin it to the whiteboard, and everyone reads from the whiteboard.

Code:

python β€” editable
from pyspark import StorageLevel

# cache() = persist(MEMORY_AND_DISK) for DataFrames
# cache() = persist(MEMORY_ONLY) for RDDs
df.cache()

# persist() lets you choose storage level explicitly
rdd.persist(StorageLevel.MEMORY_ONLY)             # JVM heap, deserialized (fastest access)
rdd.persist(StorageLevel.MEMORY_AND_DISK)         # Memory first, spill to disk if full
rdd.persist(StorageLevel.MEMORY_ONLY_SER)         # Serialized in memory (more compact)
rdd.persist(StorageLevel.MEMORY_AND_DISK_SER)     # Serialized + disk spill
rdd.persist(StorageLevel.DISK_ONLY)               # Always on disk (slowest)
rdd.persist(StorageLevel.OFF_HEAP)                # Tungsten off-heap memory

# Unpersist when done (free up memory for other operations)
rdd.unpersist()

When to cache vs when NOT to:

Cache When βœ“Don't Cache When βœ—
Used MORE THAN ONCE in the same jobUsed only once
Expensive computation (complex joins, aggregations)Simple fast reads (small CSV)
Iterative algorithms (ML training loops)Data too large to fit in memory
Intermediate results reused in multiple branchesStreaming (data changes constantly)

⚠️ TRAP β€” cache() is LAZY:

python β€” editable
rdd.cache()              # ← nothing happens yet! Just marks for caching
rdd.count()              # ← NOW the RDD is computed AND cached
rdd.collect()            # ← reads from cache (fast!)

Interview Tip: "Cache is lazy β€” it only stores data on the first action after cache() is called. And cache() for DataFrames defaults to MEMORY_AND_DISK, not MEMORY_ONLY. This is a common trap question."

Answer First: Lineage is the dependency recipe used to recompute lost partitions; checkpointing materializes reliable storage and truncates a long lineage chain.

Memory Map: source -> lineage chain -> failure/recompute; checkpoint -> new recovery root.

Q11: What Is RDD Lineage? What Is Checkpointing?

Definition:

  • Lineage: The chain of transformations that produced an RDD. Spark tracks this to recompute lost data.
  • Checkpointing: Saving an RDD to reliable storage (HDFS) and cutting the lineage chain.

Simple Explanation: Lineage is Spark's fault tolerance mechanism. Instead of replicating data (like HDFS does with 3 copies), Spark remembers HOW to produce the data. If a partition is lost, it replays the transformations from the source data.

But for very long chains (like 100 ML iterations), the lineage becomes dangerously long β€” recomputing from scratch would take forever. Checkpointing saves the data to disk and "cuts" the lineage, so recovery only needs to go back to the checkpoint.

Code:

python β€” editable
# View lineage of an RDD
rdd1 = sc.textFile("/data/logs.txt")
rdd2 = rdd1.filter(lambda line: "ERROR" in line)
rdd3 = rdd2.map(lambda line: line.split(","))
rdd4 = rdd3.map(lambda parts: (parts[0], parts[1]))
rdd5 = rdd4.reduceByKey(lambda a, b: a + b)

print(rdd5.toDebugString())
# Shows: rdd5 ← rdd4 ← rdd3 ← rdd2 ← rdd1 ← textFile(logs.txt)

# PROBLEM: After 100 ML iterations, lineage is 100 steps deep
# If one partition fails β†’ recompute ALL 100 steps from scratch!

# SOLUTION: Checkpoint! Saves to HDFS, truncates lineage
sc.setCheckpointDir("hdfs:///checkpoints/")
rdd5.checkpoint()   # mark for checkpoint
rdd5.count()        # triggers: compute, save to HDFS, new lineage starts from checkpoint

# Best practice: cache THEN checkpoint (avoids computing twice)
rdd5.cache()
rdd5.checkpoint()
rdd5.count()   # computes once β†’ stores in cache AND saves to HDFS

Lineage vs Checkpointing comparison:

AspectLineageCheckpointing
StorageIn memory (metadata only)On disk (HDFS/S3)
Recovery speedSlow for long chainsFast (read from disk)
Storage costZero (just metadata)Stores full data
When to useShort pipelinesLong iterative algorithms, streaming

Answer First: Broadcast variables distribute one read-only value per executor, while accumulators provide driver-readable aggregate updates whose retry semantics require care.

Memory Map: driver value -> broadcast -> executor reads; task updates -> accumulator -> driver.

Q12: What Are Broadcast Variables and Accumulators?

Definition:

  • Broadcast variable: A read-only variable efficiently shared with all executors (sent once, not per task).
  • Accumulator: A write-only variable that executors can add to, but only the Driver can read.

Simple Explanation: Normally, when your Spark function references a variable from the Driver, Spark serializes and sends a copy with EVERY task. If you have 1000 tasks and a 100 MB lookup table, that's 100 GB of unnecessary network traffic! Broadcast variables solve this by sending the data to each executor ONCE.

Accumulators solve the opposite problem β€” you want executors to report metrics back to the Driver (like counting bad records). Each executor adds to the counter, and the Driver reads the total.

Code:

python β€” editable
# ─── BROADCAST VARIABLES ───
# USE CASE: lookup tables, configuration data, ML model parameters

# WITHOUT broadcast (BAD for large lookups)
country_codes = {"US": "United States", "IN": "India", "GB": "United Kingdom"}
rdd.map(lambda code: country_codes.get(code, "Unknown"))
# ^ Python closure: serialized and sent with EVERY task!
# 1000 tasks Γ— 100 MB table = 100 GB network traffic!

# WITH broadcast (GOOD)
bc_codes = sc.broadcast(country_codes)
rdd.map(lambda code: bc_codes.value.get(code, "Unknown"))
# ^ Sent ONCE to each executor, all tasks on that executor share it

bc_codes.unpersist()    # release from executor memory
bc_codes.destroy()      # remove from ALL executors immediately
python β€” editable
# ─── ACCUMULATORS ───
# USE CASE: counters (bad records, events, debug metrics)

error_count = sc.accumulator(0)
malformed_count = sc.accumulator(0)

def parse_line(line):
    try:
        parts = line.split(",")
        if len(parts) != 5:
            malformed_count.add(1)
            return None
        return parts
    except Exception:
        error_count.add(1)
        return None

results = rdd.map(parse_line).filter(lambda x: x is not None)
results.count()    # trigger action

print(f"Errors: {error_count.value}")
print(f"Malformed: {malformed_count.value}")

⚠️ TRAP β€” Accumulator double counting:

python β€” editable
# Accumulators in TRANSFORMATIONS can be counted MULTIPLE TIMES
# because Spark may retry failed tasks!
# If a map() task fails and retries β†’ accumulator incremented AGAIN

# SAFE: Use accumulators inside foreach() (an action β€” runs exactly once per record)
rdd.foreach(lambda x: error_count.add(1) if x is None else None)

Interview Tip: "Broadcast variables are essential for broadcast joins β€” when one side of a join is small enough to fit in executor memory (typically <100 MB), broadcasting it avoids a full shuffle. This is one of the most impactful Spark optimizations."

Scenario ownership

The executor-OOM, skew-straggler, driver-collect, and word-count drills live in Scenarios and Labs so their full solutions have one owner.

Intermediate

DataFrames and Spark SQL

#

DataFrames and Spark SQL

Direct answer: A DataFrame is a distributed table with a schema. Its unresolved logical plan is analyzed and optimized by Catalyst, then executed as a physical plan. Prefer built-in expressions over Python UDFs because Spark can understand, push down, prune, and code-generate built-ins.

Memory map: read with an explicit schema -> select/filter early -> transform with expressions -> aggregate/join/window -> inspect the plan -> write appropriately sized columnar files.

MASTER MEMORY MAP β€” Day 2

🧠 SPARKSESSION = "Entry point for EVERYTHING"
RDD vs DataFrame vs Dataset = "Low -> High -> Typed"
RDD: Low-level, no schema, no optimizer, Python/Java/Scala
DataFrame: High-level, schema, Catalyst/Tungsten optimized, SQL-like
Dataset: High-level, TYPED (compile-time safety), JVM only (Scala/Java)
PySpark: Only RDD + DataFrame (no Dataset in Python!)
SPARKSESSION"Entry point for EVERYTHING"
spark.read.* -> read data
spark.sql("...") -> run SQL
spark.sparkContext -> access RDD API
spark.createDataFrame() -> create DF from RDD or list
spark.catalog.* -> manage databases, tables, functions
READING FILES"Format.Option.Schema.Load"
format: csv, json, parquet, orc, delta, jdbc, avro
option: header, inferSchema, delimiter, mode
schema: StructType([StructField(...)]) β€” ALWAYS explicit in production!
load: .load("/path/") or .(path) shortcut
READ MULTIPLE FILES"Glob/List/Directory"
Glob: spark.read.csv("/data/2024/*") <- all files matching pattern
List: spark.read.csv(["/f1", "/f2"]) <- specific files
Dir: spark.read.parquet("/data/") <- all parquet in dir
TRANSFORMATIONS"SWGJW-NAU"
Sselect() / withColumn()
Wwhere() / filter()
GgroupBy() + agg()
Jjoin()
WwithColumnRenamed() / drop()
Nna.fill() / na.drop()
Aalias() / cast()
Uunion() / unionByName()
WINDOW FUNCTIONS"PARTITION + ORDER + FRAME"
Window.partitionBy("col").orderBy("col")
rowsBetween(Window.unboundedPreceding, Window.currentRow)
COLUMN OPERATIONS"col / lit / when / cast"
col("name") -> reference a column
lit(100) -> create a constant column
when().otherwise() -> conditional logic (CASE WHEN)
cast("double") -> type conversion
NULL HANDLING"isNull / fillna / dropna / coalesce"
isNull() / isNotNull() -> filter nulls
na.fill() / na.drop() -> handle null rows
coalesce(a, b, c) -> first non-null value
STRING FUNCTIONS"concat / trim / regexp / split"
concat, concat_ws, substring, trim, ltrim, rtrim
regexp_replace, regexp_extract, split, lower, upper
DATE FUNCTIONS"current / diff / add / format"
current_date(), current_timestamp()
datediff(), months_between(), date_add(), date_sub()
date_format(), to_date(), to_timestamp()

Recall Stack: data model and schema

Answer First: DataFrames add named, typed columns to distributed data, allowing Spark to validate expressions and optimize a logical plan. Use explicit production schemas to avoid inference scans and silent type drift.

Memory Map: source -> schema -> unresolved plan -> analyzed plan -> optimized plan -> physical plan.

Catalyst query planning

Answer First: Catalyst turns DataFrame and SQL expressions into an analyzed logical plan, applies logical optimization rules, compares physical strategies, and selects an executable physical plan. Inspect the result with explain; do not infer optimization from source code alone.

Memory Map: parse -> analyze -> optimize -> plan -> execute.

Catalyst planning phases

USER CODE (DataFrame/SQL)
↓
1. ANALYSIS
Resolve columns, functions, tables, and types against the catalog.
↓
2. LOGICAL OPTIMIZATION
Apply rules such as predicate pushdown, column pruning,
constant folding, and eligible join reordering.
↓
3. PHYSICAL PLANNING
Generate candidate scan/join/exchange strategies; use available
statistics and costs to choose a physical plan.
↓
4. EXECUTION PREPARATION
Apply physical rules and whole-stage code generation where eligible.

Predicate pushdown and column pruning

python β€” editable
# Push an eligible filter to a columnar source and read only needed columns.
result = (
    spark.read.parquet("data/")
         .filter(col("year") == 2024)
         .select("name", "amount")
)

# A Python UDF is opaque to Catalyst and can block source pushdown.
blocked = df.filter(my_udf(col("year")) == 2024)

Inspect the plan

python β€” editable
df.explain()             # physical plan
df.explain(True)         # parsed, analyzed, optimized, and physical plans
df.explain("formatted")  # structured physical plan
df.explain("cost")       # plan plus available statistics
df.explain("codegen")    # generated code where supported

Answer First: Prefer DataFrames for schema-aware ETL and SQL optimization; use RDDs for genuinely low-level record control, and remember that typed Datasets are a Scala/Java API rather than a PySpark API.

Memory Map: RDD (objects) -> DataFrame (named rows) -> Dataset (typed JVM records).

SECTION 1: RDD vs DataFrame vs Dataset

Definition: A DataFrame is a distributed collection of data organized into named columns, equivalent to a table in a relational database, optimized by Spark's Catalyst optimizer and Tungsten execution engine.

Simple Explanation: Think of an RDD as a raw list of items with no labels. A DataFrame is like an Excel spreadsheet with column headers. A Dataset adds strict data typing (only in Scala/Java).

Real-world Analogy: RDD is a box of unsorted papers. DataFrame is papers organized in a filing cabinet with labeled folders. Dataset is a filing cabinet where each folder only accepts a specific document type.

FeatureRDDDataFrameDataset (Scala/Java)
API LevelLow-levelHigh-level SQLHigh-level typed
SchemaNoYes (column names)Yes (typed case class)
Type SafetyPython runtimeRuntime onlyCOMPILE TIME
Catalyst Optim.NoneFullFull
TungstenNoneFullFull
PerformanceSlowestFastFast
LanguagePython/Scala/JavaAll languagesScala/Java ONLY
Null HandlingManualAutomaticAutomatic
When to useUnstructured dataStructured dataN/A in PySpark
Complex customSQL-like ops
logicMost cases
βœ… Pro Tip
Interview Tip: "In PySpark, the choice is RDD vs DataFrame. I use DataFrame by default β€” it is faster (Catalyst + Tungsten), has a cleaner API, and supports SQL syntax. I use RDD only when the DataFrame API cannot express my logic or for unstructured text/binary data."

What NOT to say: "Dataset is available in PySpark." It is not β€” Dataset is Scala/Java only. In PySpark, DataFrame IS the Dataset[Row] equivalent.

Answer First: SparkSession is the unified entry point for DataFrame, SQL, catalog, streaming, and underlying SparkContext access; build or reuse one session, then create DataFrames with an explicit schema when practical.

Memory Map: builder -> config -> getOrCreate -> read/createDataFrame -> lazy plan.

SECTION 2: SparkSession + DataFrame Creation

What is SparkSession?

Definition: SparkSession is the unified entry point for all Spark functionality β€” reading data, running SQL, accessing the catalog, and configuring the application.

Simple Explanation: Before you do anything in Spark, you need a SparkSession. It is the single door you walk through to access everything Spark offers.

βœ… Pro Tip
Interview Tip: Always mention getOrCreate() β€” it prevents creating multiple sessions in the same application. Mention that SparkSession replaced the older SparkContext + SQLContext + HiveContext pattern.

What NOT to say: "You need a SparkContext to use DataFrames." SparkSession is the entry point since Spark 2.0. SparkContext is still used internally but you access it via spark.sparkContext.

Creating SparkSession

python β€” editable
from pyspark.sql import SparkSession

spark = SparkSession.builder \
    .appName("DataPipeline") \
    .master("yarn") \
    .config("spark.executor.memory", "8g") \
    .config("spark.executor.cores", "4") \
    .config("spark.executor.instances", "10") \
    .config("spark.sql.shuffle.partitions", "400") \
    .config("spark.sql.adaptive.enabled", "true") \
    .config("spark.sql.autoBroadcastJoinThreshold", str(50 * 1024 * 1024)) \
    .enableHiveSupport() \
    .getOrCreate()

# getOrCreate() -> creates new session OR returns existing one
# Prevents creating multiple SparkSessions in the same application

DataFrame Creation Methods

Definition: A DataFrame can be created from files (CSV, JSON, Parquet, etc.), databases (JDBC), in-memory data (lists, RDDs), or existing tables.

python β€” editable
# --- FROM A PYTHON LIST ---
data = [("Alice", 30, "Engineering"), ("Bob", 25, "Marketing")]
columns = ["name", "age", "department"]
df = spark.createDataFrame(data, columns)

# --- FROM AN RDD ---
rdd = spark.sparkContext.parallelize([("Alice", 30), ("Bob", 25)])
df = rdd.toDF(["name", "age"])

# --- FROM A PANDAS DATAFRAME ---
import pandas as pd
pdf = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25]})
df = spark.createDataFrame(pdf)

# --- FROM AN EXISTING TABLE (Hive/Delta metastore) ---
df = spark.table("database_name.table_name")

# --- FROM A RANGE ---
df = spark.range(0, 100, 1)  # single column 'id' with values 0-99
βœ… Pro Tip
Interview Tip: When asked "how do you create a DataFrame?", list at least 3 methods: from files, from in-memory data, and from existing tables. Mention spark.createDataFrame() for testing and spark.read for production.

Answer First: A production schema is a contract for column names, types, and nullability. Explicit schemas avoid inference scans and make malformed or drifting input visible.

Memory Map: StructType -> StructField -> name/type/nullability -> validation.

SECTION 3: Schema Definition (StructType, StructField)

Definition: A schema in PySpark defines the structure of a DataFrame β€” column names, data types, and nullability β€” using StructType (the table) and StructField (each column).

Simple Explanation: A schema is the blueprint of your data. Just like a building blueprint specifies where every room goes, a schema specifies what every column looks like before any data arrives.

Real-world Analogy: Schema is like the column headers and data validation rules in an Excel template β€” it says "Column A must be text, Column B must be a number, Column C cannot be blank."

python β€” editable
from pyspark.sql.types import (
    StructType, StructField,
    StringType, IntegerType, LongType, DoubleType, FloatType,
    BooleanType, DateType, TimestampType, ArrayType, MapType
)

# Define schema explicitly (no inferSchema scan needed)
booking_schema = StructType([
    StructField("booking_id",   StringType(),    nullable=False),
    StructField("customer_id",  LongType(),      nullable=True),
    StructField("flight_code",  StringType(),    nullable=True),
    StructField("amount",       DoubleType(),    nullable=True),
    StructField("booking_date", DateType(),      nullable=True),
    StructField("is_cancelled", BooleanType(),   nullable=True),
    # Nested struct:
    StructField("address", StructType([
        StructField("city",    StringType(), True),
        StructField("country", StringType(), True)
    ]), True),
    # Array column:
    StructField("tags", ArrayType(StringType()), True),
    # Map column:
    StructField("metadata", MapType(StringType(), StringType()), True)
])

df = spark.read.schema(booking_schema).csv("/data/bookings.csv", header=True)
AspectinferSchema=TrueExplicit Schema
PerformanceExtra pass over dataNo extra pass
Type accuracyOften wrong (123 as Long)You control every type
Null handlingGuesses nullabilityYou define nullable
Streaming supportNOT supportedRequired
Schema enforcementNoneFail fast on bad data
Production useNEVERALWAYS
βœ… Pro Tip
Interview Tip: Always say "In production I define schemas explicitly using StructType. inferSchema triggers an additional read pass over the data and can misdetect types." Mention that streaming sources REQUIRE explicit schemas.

What NOT to say: "I just use inferSchema=True." This signals you have not worked on production pipelines. Also do not say "I use DDL strings for complex schemas" β€” StructType is the standard.

Answer First: Match the reader to the source, supply schema and failure mode deliberately, and parallelize remote reads only with safe partition bounds. Prefer columnar formats for analytical reuse.

Memory Map: format -> schema -> options -> paths/partitions -> scan plan.

SECTION 4: Reading Data (CSV, JSON, Parquet, JDBC, Delta)

βœ… Pro Tip
Definition: spark.read is the DataFrameReader API that loads data from external storage into a DataFrame, supporting multiple formats and configuration options.

Simple Explanation: spark.read is how you bring outside data into Spark. You specify the format, set options (like whether there is a header row), and point it to the file path.

Reading Single Files β€” All Formats

python β€” editable
# --- CSV ---
df_csv = spark.read \
    .option("header", "true") \
    .option("inferSchema", "true") \
    .option("delimiter", ",") \
    .option("nullValue", "N/A") \
    .option("dateFormat", "yyyy-MM-dd") \
    .csv("/data/bookings.csv")
# WARNING: inferSchema triggers extra read pass β€” use explicit schema in prod

# --- JSON ---
df_json = spark.read \
    .option("multiLine", "true") \
    .option("mode", "PERMISSIVE") \
    .json("/data/events/*.json")
# mode options: PERMISSIVE (default, nulls bad fields) | DROPMALFORMED | FAILFAST

# --- PARQUET (PREFERRED FORMAT) ---
df_parquet = spark.read.parquet("/data/warehouse/bookings/")
# Schema embedded in file (no inferSchema needed)
# Columnar format with predicate pushdown
# Efficient compression (Snappy by default)

# --- ORC ---
df_orc = spark.read.orc("/data/hive_table/")

# --- JDBC (relational database) ---
df_jdbc = spark.read \
    .format("jdbc") \
    .option("url", "jdbc:postgresql://db-host:5432/mydb") \
    .option("dbtable", "bookings") \
    .option("user", "etl_user") \
    .option("password", "secret") \
    .option("driver", "org.postgresql.Driver") \
    .option("numPartitions", "10") \
    .option("partitionColumn", "booking_id") \
    .option("lowerBound", "1") \
    .option("upperBound", "1000000") \
    .load()

# --- AVRO ---
df_avro = spark.read.format("avro").load("/data/kafka_output/")

# --- DELTA LAKE ---
df_delta = spark.read.format("delta").load("/data/delta/bookings/")
# Or using table name if registered in metastore:
df_delta = spark.table("bookings_db.bookings")
READ MODE COMPARISON TABLE
Mode | Behavior on Bad Records
--------------|----------------------------------------------------
PERMISSIVE | Sets bad fields to null, stores raw in _corrupt_record
DROPMALFORMED | Silently drops bad rows
FAILFAST | Throws exception immediately on bad data

Reading Multiple Files from Multiple Sources

python β€” editable
# --- GLOB PATTERN (most common) ---
df = spark.read.csv("/data/2024/*/bookings_*.csv", header=True)
# Reads: /data/2024/jan/bookings_1.csv, /data/2024/feb/bookings_2.csv etc.

# --- LIST OF SPECIFIC FILES ---
df = spark.read.parquet(
    "/data/2024/jan/bookings.parquet",
    "/data/2024/feb/bookings.parquet",
    "/data/2024/mar/bookings.parquet"
)
# Or pass as Python list:
files = ["/data/2024/jan/bookings.parquet", "/data/2024/feb/bookings.parquet"]
df = spark.read.parquet(*files)   # unpack list

# --- DIRECTORY (all files in dir with same format) ---
df = spark.read.parquet("/data/2024/")   # reads ALL parquet files in dir

# --- ADD SOURCE FILE NAME COLUMN ---
from pyspark.sql.functions import input_file_name
df = spark.read.option("header", "true") \
    .csv("/data/2024/*/bookings_*.csv") \
    .withColumn("source_file", input_file_name())

# --- READ MULTIPLE FORMATS AND UNION ---
df_csv = spark.read.option("header", True).csv("/landing/batch/")
df_json = spark.read.json("/landing/api/events/")
df_parquet = spark.read.parquet("/landing/warehouse/")

# Align columns then union
cols = ["booking_id", "customer_id", "amount", "booking_date"]
df_all = df_csv.select(cols) \
    .union(df_json.select(cols)) \
    .union(df_parquet.select(cols))

# Better: unionByName (aligns by column name, not position)
df_all = df_csv.unionByName(df_json, allowMissingColumns=True) \
               .unionByName(df_parquet, allowMissingColumns=True)

# --- READ FROM DIFFERENT STORAGE SYSTEMS ---
df_s3 = spark.read.parquet("s3a://my-bucket/data/")
df_adls = spark.read.parquet("abfss://container@storage.dfs.core.windows.net/data/")
df_hdfs = spark.read.parquet("hdfs://namenode:8020/data/")
df_local = spark.read.parquet("file:///local/path/data/")

# Combine multiple storage locations:
df_combined = spark.read.parquet(
    "s3a://bucket/data/2023/",
    "s3a://bucket/data/2024/",
    "hdfs://namenode/archive/2022/"
)
βœ… Pro Tip
Interview Tip: When discussing file reading, always mention: (1) explicit schema over inferSchema, (2) Parquet as the preferred format for analytics, (3) partition pruning for performance. For JDBC, mention numPartitions + partitionColumn for parallel reads.

What NOT to say: "I just use spark.read.csv(path) with defaults." This shows you do not understand production considerations like schema enforcement, read modes, or performance tuning.

Recall Stack: transformations and expressions

Answer First: Transformations extend a lazy plan; actions trigger execution. Prefer built-in column expressions so Catalyst can reason about filters, projections, types, and code generation.

Memory Map: select -> filter -> derive -> aggregate -> action. Keep projections narrow and avoid long withColumn chains.

SECTION 5: DataFrame Transformations

Definition: Transformations are lazy operations that define a computation plan on a DataFrame without executing it. They produce a new DataFrame (DataFrames are immutable).

Simple Explanation: Transformations are instructions you stack up. Nothing actually runs until you call an action (like .show(), .count(), .collect()). Spark waits so it can optimize the entire chain at once.

Real-world Analogy: Writing a recipe (transformations) vs actually cooking it (actions). You write all the steps first, then the chef (Catalyst optimizer) rearranges them for efficiency before cooking.

select

Definition: Projects a set of columns or expressions from a DataFrame, equivalent to SELECT in SQL.

python β€” editable
df.select("id", "name", "amount")                    # by name
df.select(col("id"), col("amount") * 1.1)            # with expression
df.select("*", (col("amount") * 1.1).alias("new_amount"))  # all + new column

filter / where

Definition: Returns rows that satisfy a given condition. filter() and where() are identical β€” aliases of each other.

python β€” editable
df.filter(col("age") > 30)
df.filter("age > 30")                                 # SQL string form
df.where((col("country") == "IN") & (col("amount") > 100))
df.filter(col("status").isin("CONFIRMED", "PENDING"))
df.filter(col("name").startswith("A"))
df.filter(col("name").like("A%"))
df.filter(~col("is_cancelled"))                       # NOT condition
βœ… Pro Tip
Interview Tip: Mention that filter() and where() are the same β€” interviewers sometimes test this. Also mention that Spark pushes filter predicates down to the data source (predicate pushdown) for formats like Parquet and JDBC.

withColumn

Definition: Returns a new DataFrame with a column added or replaced. If the column name already exists, it replaces the column.

python β€” editable
df.withColumn("tax", col("amount") * 0.18)
df.withColumn("amount", col("amount").cast("double"))  # modify existing column
df.withColumn("status", when(col("amount") > 1000, "high")
                        .when(col("amount") > 100, "medium")
                        .otherwise("low"))
βœ… Pro Tip
What NOT to say: "I chain 20 withColumn calls to add columns." Chaining many withColumn calls creates a deep logical plan that slows Catalyst. Use select() with multiple expressions instead for better performance.

drop

Definition: Returns a new DataFrame with specified columns removed.

python β€” editable
df.drop("col1", "col2")
df.drop(col("temporary_column"))

distinct / dropDuplicates

Definition: distinct() removes duplicate rows considering ALL columns. dropDuplicates() removes duplicates based on a SUBSET of columns.

python β€” editable
df.distinct()                                     # all columns must match
df.dropDuplicates(["customer_id", "booking_date"])  # subset columns
βœ… Pro Tip
Interview Tip: If asked "how do you deduplicate?", mention dropDuplicates for subset-based dedup. For "keep the latest record per key", use window function with row_number() β€” not dropDuplicates, because dropDuplicates gives you an arbitrary row, not the most recent.

What NOT to say: "I use distinct() to deduplicate by key." distinct() considers ALL columns. If you only want unique keys, use dropDuplicates(["key_col"]).

withColumnRenamed / sort

python β€” editable
# --- RENAME ---
df.withColumnRenamed("old_name", "new_name")

# --- SORT ---
df.orderBy("amount")                              # ascending
df.orderBy(col("amount").desc())                  # descending
df.orderBy(col("date").asc(), col("amount").desc())  # multi-column

Answer First: Build transformations from Spark column expressions, not Python row logic, so types remain explicit and Catalyst can inspect the expression tree.

Memory Map: col/lit -> condition -> cast -> alias -> projected column.

SECTION 6: Column Operations (col, lit, when/otherwise, cast)

Definition: Column operations are functions that create, transform, or reference individual columns within a DataFrame expression.

Simple Explanation: col() points to an existing column. lit() creates a constant value column. when().otherwise() is your IF-ELSE logic. cast() converts data types.

col() and lit()

python β€” editable
from pyspark.sql.functions import col, lit

# col() β€” reference an existing column
df.select(col("name"), col("amount") * 2)

# lit() β€” create a constant value column
df.withColumn("country", lit("India"))
df.withColumn("multiplier", lit(1.18))

# Common mistake: trying to use a Python variable directly
tax_rate = 0.18
# WRONG: df.withColumn("tax", col("amount") * tax_rate)  # works but unclear
# RIGHT: df.withColumn("tax", col("amount") * lit(tax_rate))  # explicit
βœ… Pro Tip
Interview Tip: lit() is needed when you want to add a constant column or use a Python variable as a column value. Without lit(), Python values sometimes work due to implicit conversion, but lit() makes intent explicit.

when / otherwise (Conditional Logic)

Definition: when().otherwise() is the PySpark equivalent of SQL's CASE WHEN. It evaluates conditions in order and returns the first matching result.

python β€” editable
from pyspark.sql.functions import when

# Simple condition
df.withColumn("category",
    when(col("amount") > 1000, "high")
    .when(col("amount") > 100, "medium")
    .otherwise("low")
)

# Multiple conditions combined
df.withColumn("flag",
    when((col("status") == "CANCELLED") & (col("amount") > 500), "refund_priority")
    .when(col("status") == "CANCELLED", "standard_refund")
    .otherwise("no_action")
)

# Nested when for complex logic
df.withColumn("tier",
    when(col("total_spend") > 10000, "platinum")
    .when((col("total_spend") > 5000) & (col("years") > 3), "gold")
    .when(col("total_spend") > 1000, "silver")
    .otherwise("bronze")
)

cast (Type Conversion)

Definition: Converts a column from one data type to another.

python β€” editable
df.withColumn("amount", col("amount").cast("double"))
df.withColumn("booking_date", col("booking_date").cast("date"))
df.withColumn("amount_int", col("amount").cast(IntegerType()))

# Common cast operations:
# "string" -> "integer", "long", "double", "float", "date", "timestamp", "boolean"

What NOT to say: "I use int() or float() to convert column types." Those are Python functions, not Spark functions. Always use .cast() for DataFrame column type conversion.

Answer First: Aggregations collapse rows by key and usually shuffle; reduce data early, group on intentional keys, and use bounded aggregate state.

Memory Map: filter -> group keys -> partial aggregate -> shuffle -> final aggregate.

SECTION 7: Aggregations (groupBy, agg, sum, avg, count, min, max)

Definition: Aggregation operations group rows by one or more columns and compute summary statistics (count, sum, average, etc.) for each group.

Simple Explanation: Aggregation is like creating a pivot table in Excel β€” you pick the grouping columns and then calculate totals, averages, or counts for each group.

Real-world Analogy: Counting how many passengers boarded each flight and the total revenue per flight β€” that is a groupBy("flight_code") with count and sum aggregations.

python β€” editable
from pyspark.sql.functions import count, sum, avg, max, min, countDistinct, collect_list, collect_set

# --- BASIC GROUPBY + AGG ---
df.groupBy("country") \
  .agg(
      count("*").alias("booking_count"),
      sum("amount").alias("total_amount"),
      avg("amount").alias("avg_amount"),
      max("amount").alias("max_amount"),
      min("amount").alias("min_amount"),
      countDistinct("customer_id").alias("unique_customers")
  )

# --- MULTIPLE GROUPBY COLUMNS ---
df.groupBy("country", "booking_year") \
  .agg(
      count("*").alias("bookings"),
      sum("amount").alias("revenue")
  )

# --- COLLECT VALUES INTO A LIST/SET ---
df.groupBy("customer_id") \
  .agg(
      collect_list("product").alias("all_products"),     # allows duplicates
      collect_set("product").alias("unique_products")    # no duplicates
  )

# --- SHORTCUT METHODS (less flexible but quick) ---
df.groupBy("country").count()
df.groupBy("country").sum("amount")
df.groupBy("country").avg("amount")
df.groupBy("country").max("amount")
FunctionWhat it doesNull behavior
count("*")Counts all rows including nullsCounts everything
count("col")Counts non-null values in columnIgnores nulls
countDistinct()Counts unique non-null valuesIgnores nulls
sum()Sum of valuesIgnores nulls
avg() / mean()Average of valuesIgnores nulls
max() / min()Maximum / minimum valueIgnores nulls
collect_list()Collects into array (with duplicates)Includes nulls
collect_set()Collects into array (unique only)Excludes nulls
first() / last()First / last value in groupDepends on ignorenulls
βœ… Pro Tip
Interview Tip: Always mention that count("*") counts all rows including nulls, while count("column_name") skips nulls. This is a common trick question. Also mention that collect_list and collect_set can cause OOM if groups are very large.

What NOT to say: "count() and count(column) are the same." They are not. count("*") includes null rows; count("col") excludes them.

Recall Stack: joins and windows

Answer First: A join combines rows and may redistribute data; a window keeps row cardinality while adding group context. Choose a join strategy from data size and distribution, then verify the physical plan.

Memory Map: keys -> sizes -> distribution -> strategy -> exchange; for windows: partition -> order -> frame -> function.

SECTION 8: Joins

Definition: A join combines rows from two DataFrames based on a related column (join key), similar to SQL joins.

Simple Explanation: Joins connect two tables using a shared column. Think of matching employee IDs in a "people" table with the same IDs in a "salary" table to get each person's salary.

Real-world Analogy: You have a guest list (names + invite codes) and a seating chart (invite codes + table numbers). Joining them on invite code gives you name + table number.

python β€” editable
from pyspark.sql.functions import broadcast, col

# --- INNER JOIN (only matching rows from both sides) ---
result = df1.join(df2, on="customer_id", how="inner")

# --- LEFT JOIN (all rows from left, matching from right, null if no match) ---
result = df1.join(df2, on=["customer_id", "date"], how="left")

# --- RIGHT JOIN (all rows from right, matching from left) ---
result = df1.join(df2, on="customer_id", how="right")

# --- FULL OUTER JOIN (all rows from both, nulls where no match) ---
result = df1.join(df2, on="customer_id", how="full")

# --- CROSS JOIN (cartesian product β€” every row with every row) ---
result = df1.crossJoin(df2)
# WARNING: N x M rows β€” use only when intentional (e.g., generating combinations)

# --- LEFT SEMI JOIN (rows in df1 that HAVE a match in df2, NO df2 columns) ---
result = df1.join(df2, on="customer_id", how="left_semi")
# Equivalent to: WHERE customer_id IN (SELECT customer_id FROM df2)

# --- LEFT ANTI JOIN (rows in df1 that do NOT have a match in df2) ---
result = df1.join(df2, on="customer_id", how="left_anti")
# Equivalent to: WHERE customer_id NOT IN (SELECT customer_id FROM df2)

# --- JOIN WITH DIFFERENT COLUMN NAMES ---
result = df1.join(df2, df1["cust_id"] == df2["customer_id"], how="inner")
# After join, drop the duplicate column:
result = result.drop(df2["customer_id"])

# --- BROADCAST JOIN (force small table broadcast β€” no shuffle) ---
result = large_df.join(broadcast(small_df), on="airport_code")
# Spark sends the small table to every executor (avoids shuffle of large table)
# Default broadcast threshold: 10 MB (spark.sql.autoBroadcastJoinThreshold)
Join TypeLeft rowsRight rowsWhen no matchUse case
innerMatchedMatchedRow droppedOnly common records
leftALLMatchedRight cols = NULLKeep all from primary table
rightMatchedALLLeft cols = NULLKeep all from lookup table
fullALLALLOpposite side = NULLMerge two full datasets
crossALL x ALLALL x ALLN/A (cartesian)Generate all combinations
left_semiMatchedNONERow droppedEXISTS / IN subquery
left_antiUnmatchedNONERow keptNOT EXISTS / NOT IN subquery
βœ… Pro Tip
Interview Tip: Semi and anti joins are interview favorites. Explain them as "filter joins" β€” they filter the left table based on existence in the right table but never add right-side columns. Always mention broadcast joins for small-large table joins.

What NOT to say: "Semi join returns columns from both tables." It only returns columns from the LEFT table. Also never say "I always use inner join" β€” show awareness of when to use left/anti/semi.

βœ… Pro Tip
Follow-up they will ask: "What happens with duplicate keys in a join?" Answer: rows multiply. If key "A" appears 3 times in left and 2 times in right, inner join produces 6 rows for key "A".

Answer First: A window retains each input row while adding ordered group context. Define partition, ordering, and frame explicitly because each controls both meaning and shuffle/sort cost.

Memory Map: partitionBy -> orderBy -> frame -> rank/lag/aggregate.

SECTION 9: Window Functions

Definition: Window functions perform calculations across a set of rows (a "window") that are related to the current row, without collapsing rows like GROUP BY does.

Simple Explanation: GROUP BY gives you one row per group. Window functions give you one result per ROW, but that result is computed from a group of related rows. You keep all your original rows.

Real-world Analogy: In a race, GROUP BY tells you "the fastest time per age group." A window function tells you "each runner's rank within their age group" β€” every runner keeps their row, but now has a rank attached.

Defining a Window

python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import (
    row_number, rank, dense_rank, lag, lead,
    sum, avg, max, min, count,
    ntile, percent_rank, cume_dist,
    first, last
)

# --- DEFINE A WINDOW ---
windowSpec = Window \
    .partitionBy("department") \
    .orderBy(col("salary").desc())

Ranking Functions (row_number, rank, dense_rank)

Definition: Ranking functions assign a position number to each row within a partition based on the ordering.

python β€” editable
df = df.withColumn("row_num", row_number().over(windowSpec))  # 1,2,3,4,5 (no ties)
df = df.withColumn("rank",    rank().over(windowSpec))         # 1,2,2,4,5 (ties skip)
df = df.withColumn("drank",   dense_rank().over(windowSpec))   # 1,2,2,3,4 (ties no skip)
Salaryrow_numberrankdense_rank
100111
90222
90322
80443
70554
βœ… Pro Tip
Interview Tip: The most common interview question is "get top N per group." Always use row_number() or dense_rank() with a window function, filter by rank, then drop the rank column. Know the difference: row_number breaks ties arbitrarily, rank skips numbers after ties, dense_rank never skips.

What NOT to say: "I use GROUP BY with LIMIT to get top N per group." That does not work β€” LIMIT applies globally, not per group. You MUST use a window function.

TOP N Per Group Pattern

python β€” editable
# Top 2 highest-paid employees per department
window = Window.partitionBy("department").orderBy(col("salary").desc())

top2 = df.withColumn("rank", dense_rank().over(window)) \
         .filter(col("rank") <= 2) \
         .drop("rank")

lag / lead (Access Previous/Next Row)

Definition: lag() accesses a value from a previous row; lead() accesses a value from the next row, within the window partition.

python β€” editable
lag_spec = Window.partitionBy("customer_id").orderBy("purchase_date")

df = df.withColumn("prev_purchase", lag("amount", 1, 0).over(lag_spec))
#                                   column, offset, default_if_null

df = df.withColumn("next_purchase", lead("amount", 1).over(lag_spec))

# Month-over-month change
df = df.withColumn("mom_change",
    round((col("revenue") - lag("revenue", 1).over(lag_spec))
          / lag("revenue", 1).over(lag_spec) * 100, 2))

Running Totals and Rolling Averages

Definition: A running total accumulates values from the beginning of the partition to the current row. A rolling average computes the average over a fixed sliding window of rows.

python β€” editable
# --- RUNNING TOTAL ---
running_spec = Window.partitionBy("region") \
    .orderBy("sale_date") \
    .rowsBetween(Window.unboundedPreceding, Window.currentRow)

df = df.withColumn("running_total", sum("revenue").over(running_spec))

# --- ROLLING 7-DAY AVERAGE ---
rolling_spec = Window.partitionBy("user_id") \
    .orderBy("event_date") \
    .rowsBetween(-6, 0)  # current + 6 before = 7 rows

df = df.withColumn("rolling_7day_avg", avg("daily_count").over(rolling_spec))
Frame TypeSyntaxBased on
ROWSrowsBetween(-2, 0)Physical row positions
RANGErangeBetween(-7, 0)Logical value range
UnboundedWindow.unboundedPrecedingFrom start of partition
CurrentWindow.currentRowCurrent row

ntile / percentile / first / last

python β€” editable
# --- NTILE (divide into N equal buckets) ---
quartile_spec = Window.orderBy("spend")
df = df.withColumn("quartile", ntile(4).over(quartile_spec))
df = df.withColumn("percentile", percent_rank().over(quartile_spec))

# --- FIRST / LAST ---
first_spec = Window.partitionBy("customer_id").orderBy("purchase_date")
df = df.withColumn("first_purchase_amount",
    first("amount", ignorenulls=True).over(first_spec))

What NOT to say: "Window functions are the same as GROUP BY." They are fundamentally different β€” GROUP BY collapses rows, window functions preserve every row. Also do not say "rowsBetween and rangeBetween are the same" β€” rows is physical position, range is logical value.

Recall Stack: SQL and the UDF boundary

Answer First: DataFrame and SQL expressions reach the same optimizer. Built-ins are the default; use a Pandas UDF only when no suitable built-in exists, and a row-wise Python UDF as the last resort.

Memory Map: built-in SQL expression -> higher-order function -> Pandas UDF -> Python UDF.

SECTION 10: Temp Views and Running SQL

Definition: A temporary view registers a DataFrame as a named table that can be queried with SQL. Session-scoped views are visible only within the current SparkSession; global views are visible across all sessions in the application.

Simple Explanation: Creating a temp view is like giving your DataFrame a table name so you can write SQL queries against it. The data is not copied β€” it is just a reference.

python β€” editable
# --- SESSION-SCOPED VIEW (most common) ---
df.createOrReplaceTempView("bookings")

# --- GLOBAL VIEW (visible across sessions) ---
df.createOrReplaceGlobalTempView("bookings")
# Access global views with: global_temp.bookings

# --- RUNNING SQL ---
result = spark.sql("""
    SELECT
        country,
        COUNT(*) as booking_count,
        SUM(amount) as total_revenue,
        ROUND(AVG(amount), 2) as avg_booking
    FROM bookings
    WHERE booking_date >= '2024-01-01'
      AND is_cancelled = false
    GROUP BY country
    HAVING COUNT(*) > 100
    ORDER BY total_revenue DESC
""")

# --- SQL WITH WINDOW FUNCTIONS ---
spark.sql("""
    SELECT *,
        ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) as rank
    FROM employees
""")

# --- MIXING SQL AND DATAFRAME API ---
spark.sql("SELECT * FROM bookings WHERE amount > 1000").groupBy("country").count()
βœ… Pro Tip
Interview Tip: Mention that createOrReplaceTempView is session-scoped and disappears when the session ends. For shared views across notebooks in Databricks, use createOrReplaceGlobalTempView accessed via the global_temp database.

What NOT to say: "Temp views persist after the application ends." They do not β€” they live only as long as the SparkSession. Also do not confuse temp views with managed/external tables which persist in the metastore.

Answer First: Prefer built-in expressions; use a Python UDF only when no equivalent exists, and consider a vectorized Pandas UDF when Arrow-compatible batch execution fits.

Memory Map: built-in first -> SQL/JVM plan -> Pandas UDF -> scalar Python UDF last.

SECTION 11: UDFs (User-Defined Functions)

Definition: A UDF is a custom function that extends Spark's built-in functions, allowing you to apply arbitrary Python/Scala logic to DataFrame columns.

Simple Explanation: When Spark's built-in functions cannot do what you need, you write your own function and register it as a UDF. But UDFs are slow because data must be serialized between the JVM and Python.

Real-world Analogy: Built-in Spark functions are like the kitchen tools already in a restaurant. A UDF is bringing your own tool from home β€” it works, but it slows everything down because the kitchen was not designed for it.

Regular Python UDF

python β€” editable
from pyspark.sql.functions import udf
from pyspark.sql.types import StringType, DoubleType, ArrayType

# --- REGULAR PYTHON UDF ---
def clean_phone(phone):
    """Remove non-digit chars from phone number"""
    import re
    return re.sub(r'\D', '', phone) if phone else None

# Register as UDF
clean_phone_udf = udf(clean_phone, StringType())

# Use with DataFrame API:
df = df.withColumn("clean_phone", clean_phone_udf(col("phone")))

# Register for SQL use:
spark.udf.register("clean_phone", clean_phone, StringType())
spark.sql("SELECT clean_phone(phone) FROM customers")

UDF with Decorator

python β€” editable
@udf(returnType=DoubleType())
def calculate_tax(amount, rate):
    return amount * rate if amount and rate else 0.0

df = df.withColumn("tax", calculate_tax(col("amount"), col("tax_rate")))

Pandas UDF (Vectorized β€” Much Faster)

python β€” editable
# Uses Apache Arrow for zero-copy data transfer
# Processes data in COLUMNAR BATCHES (not row by row)
# 3x to 100x faster than regular Python UDF!

from pyspark.sql.functions import pandas_udf
import pandas as pd

@pandas_udf(StringType())
def normalize_name(names: pd.Series) -> pd.Series:
    """Vectorized: processes entire column as pandas Series"""
    return names.str.strip().str.title().fillna("Unknown")

df = df.withColumn("clean_name", normalize_name(col("name")))

# Pandas UDF with multiple columns:
@pandas_udf(DoubleType())
def calculate_discount(amount: pd.Series, tier: pd.Series) -> pd.Series:
    discount_map = {"gold": 0.15, "silver": 0.10, "bronze": 0.05}
    discount = tier.map(discount_map).fillna(0)
    return amount * discount

df = df.withColumn("discount", calculate_discount(col("amount"), col("tier")))
TypeSpeedWhy
Built-in Spark functionsFastestNo serialization, Catalyst optimized, codegen
Pandas UDF (vectorized)FastArrow-based batch transfer, columnar
Regular Python UDFSlowestRow-by-row serialization, Python<->JVM overhead
βœ… Pro Tip
Interview Tip: When asked about UDFs, immediately mention the performance penalty and say "I always try to use built-in Spark SQL functions first. If I must use a UDF, I use Pandas UDF for vectorized processing." Explain WHY they are slow: data serialization between JVM and Python interpreter.

What NOT to say: "UDFs are fine for production at scale." They should be a last resort. Also do not say "Pandas UDFs and regular UDFs have the same performance" β€” Pandas UDFs are 3x-100x faster due to Apache Arrow.

Recall Stack: storage and nested data

Answer First: Preserve types with columnar formats, partition only by useful low/moderate-cardinality predicates, and size output files from measured data volume. Flatten arrays/maps explicitly when downstream rows require it.

Memory Map: format -> mode -> partition columns -> file count -> schema evolution; nested data is struct / array / map -> select / explode.

SECTION 12: Writing Files

Definition: df.write is the DataFrameWriter API that saves a DataFrame to external storage in various formats with configurable write modes and partitioning.

python β€” editable
# --- WRITE MODES ---
# overwrite: replace existing data entirely
# append: add to existing data
# ignore: don't write if destination already exists
# error / errorIfExists: fail if destination exists (default)

df.write.mode("overwrite").parquet("/output/bookings/")

# --- WRITE PARTITIONED (Hive-style partitioning) ---
df.write \
  .mode("overwrite") \
  .partitionBy("booking_year", "booking_month") \
  .parquet("/output/bookings_partitioned/")
# Creates: /output/bookings_partitioned/booking_year=2024/booking_month=01/part-00000.parquet

# --- WRITE BUCKETED (for joins -- avoids shuffle!) ---
df.write \
  .mode("overwrite") \
  .bucketBy(32, "customer_id") \
  .sortBy("customer_id") \
  .saveAsTable("default.bookings_bucketed")
# When two tables bucketed by same column + same N buckets: JOIN has NO SHUFFLE!

# --- WRITE AS DELTA ---
df.write \
  .format("delta") \
  .mode("overwrite") \
  .save("/delta/bookings/")

# --- WRITE CSV WITH OPTIONS ---
df.write \
  .option("header", "true") \
  .option("delimiter", "|") \
  .mode("overwrite") \
  .csv("/output/bookings.csv")

# --- WRITE TO JDBC ---
df.write \
  .format("jdbc") \
  .option("url", "jdbc:postgresql://db:5432/mydb") \
  .option("dbtable", "bookings") \
  .option("user", "user") \
  .option("password", "pass") \
  .mode("append") \
  .save()

# --- CONTROL NUMBER OF OUTPUT FILES ---
df.repartition(10).write.parquet("/output/")     # exactly 10 files
df.coalesce(1).write.csv("/output/single_file/") # single file (avoid for large data)
ModeIf path existsIf path does NOT exist
overwriteReplaces all dataCreates new
appendAdds to existing dataCreates new
ignoreDoes nothing (no error)Creates new
error (default)Throws errorCreates new
βœ… Pro Tip
Interview Tip: Always mention partitionBy for write optimization β€” it enables partition pruning on reads. Mention that bucketBy only works with saveAsTable, not save(). For controlling output file count, use repartition() before write.

What NOT to say: "I use coalesce(1) to write a single file in production." This forces all data through one partition/core and is extremely slow for large datasets. Only acceptable for small lookup files.

Answer First: Null is unknown, not an ordinary value. Choose whether to preserve, drop, fill, or compare null-safely based on business semantics.

Memory Map: detect -> decide semantics -> drop/fill/coalesce -> validate counts.

SECTION 13: Handling NULL Values

Definition: NULL represents a missing or unknown value. PySpark provides isNull(), isNotNull(), na.fill(), na.drop(), and coalesce() for null handling.

Simple Explanation: NULLs are empty cells in your data. You need to decide: drop the row, fill with a default value, or handle them in your logic. Ignoring nulls causes silent bugs.

Real-world Analogy: A survey form with blank answers. You can throw away incomplete forms (dropna), write "N/A" in blank fields (fillna), or use the first available answer from a backup list (coalesce).

python β€” editable
from pyspark.sql.functions import col, coalesce, lit, isnull

# --- FINDING NULLS ---
df.filter(col("amount").isNull())                  # rows where amount is null
df.filter(col("amount").isNotNull())               # rows where amount is not null

# --- DROPPING ROWS WITH NULLS ---
df.na.drop()                                       # drop rows with ANY null
df.na.drop(how="all")                              # drop only if ALL columns null
df.na.drop(subset=["customer_id", "amount"])       # only check these columns
df.na.drop(how="any", thresh=3)                    # keep rows with at least 3 non-null values

# --- FILLING NULLS ---
df.na.fill(0)                                      # fill all numeric nulls with 0
df.na.fill("")                                     # fill all string nulls with empty string
df.na.fill({"amount": 0, "country": "UNKNOWN"})    # per-column fill

# --- COALESCE (first non-null value from multiple columns) ---
df.withColumn("phone",
    coalesce(col("mobile_phone"), col("home_phone"), col("work_phone"), lit("N/A"))
)

# --- SAFE DIVISION (avoid null/zero errors) ---
from pyspark.sql.functions import when
df.withColumn("rate",
    when(col("impressions") != 0,
         col("clicks") / col("impressions"))
    .otherwise(None))

# --- NULL-SAFE EQUALITY (<=>) ---
# Regular ==: NULL == NULL returns NULL (not True!)
# Null-safe <=>: NULL <=> NULL returns True
df.filter(col("a").eqNullSafe(col("b")))
NULL BEHAVIOR IN OPERATIONS
Operation | NULL behavior
--------------------|-------------------------------------------
NULL + 5 | NULL (any arithmetic with NULL = NULL)
NULL→ NULL | NULL (not True!)
NULL <=> NULL | True (null-safe equality)
count("*") | Counts rows including nulls
count("col") | Skips null values
sum/avg/max/min | Ignores nulls
ORDER BY | NULLs are LAST by default (ascending)
GROUP BY | NULL is treated as a group
JOIN on NULL keys | NULL keys do NOT match (NULL != NULL)
πŸ’‘ Insight
Interview Tip: The key insight is that NULL == NULL returns NULL, not True. This means NULL join keys never match. Always mention eqNullSafe or <=> for null-safe comparisons. In aggregations, nulls are silently ignored β€” know this for accurate counts.

What NOT to say: "NULL equals NULL in Spark." It does not. NULL == NULL evaluates to NULL, which is falsy. This is standard SQL three-valued logic.

Answer First: Normalize strings with built-in expressions so cleanup stays vectorized and visible in the logical plan.

Memory Map: trim/case -> split/extract -> replace -> concatenate -> validate.

SECTION 14: String Functions

Definition: PySpark provides built-in string functions for text manipulation including concatenation, trimming, pattern matching, and extraction.

python β€” editable
from pyspark.sql.functions import (
    concat, concat_ws, substring, trim, ltrim, rtrim,
    lower, upper, initcap, length, lpad, rpad,
    regexp_replace, regexp_extract, split,
    instr, locate, translate, reverse,
    col, lit
)

# --- CONCATENATION ---
df.withColumn("full_name", concat(col("first_name"), lit(" "), col("last_name")))
df.withColumn("full_name", concat_ws(" ", col("first_name"), col("middle"), col("last_name")))
# concat_ws skips NULLs; concat returns NULL if any input is NULL

# --- CASE CONVERSION ---
df.withColumn("upper_name", upper(col("name")))
df.withColumn("lower_name", lower(col("name")))
df.withColumn("title_name", initcap(col("name")))  # "alice smith" -> "Alice Smith"

# --- TRIMMING ---
df.withColumn("clean", trim(col("name")))           # both sides
df.withColumn("clean", ltrim(col("name")))          # left only
df.withColumn("clean", rtrim(col("name")))          # right only

# --- SUBSTRING ---
df.withColumn("area_code", substring(col("phone"), 1, 3))  # first 3 chars (1-indexed)

# --- REGEX REPLACE ---
df.withColumn("clean_phone", regexp_replace(col("phone"), r"[^0-9]", ""))
df.withColumn("no_special", regexp_replace(col("text"), r"[^a-zA-Z0-9 ]", ""))

# --- REGEX EXTRACT ---
df.withColumn("domain", regexp_extract(col("email"), r"@(.+)", 1))
# Group 1 extracts the domain from "user@example.com" -> "example.com"

# --- SPLIT ---
df.withColumn("parts", split(col("full_name"), " "))       # returns array
df.withColumn("first", split(col("full_name"), " ")[0])    # first element
df.withColumn("last", split(col("full_name"), " ")[1])     # second element

# --- PADDING ---
df.withColumn("padded_id", lpad(col("id"), 10, "0"))   # "42" -> "0000000042"

# --- LENGTH ---
df.withColumn("name_len", length(col("name")))
FunctionExample InputOutput
upper("hello")"hello""HELLO"
lower("HELLO")"HELLO""hello"
initcap("hello")"hello world""Hello World"
trim(" hi ")" hi ""hi"
substring(s,1,3)"abcdef""abc"
length("hello")"hello"5
lpad("42",5,"0")"42""00042"
concat_wsNULL handlingSkips nulls
concatNULL handlingReturns NULL if any null
βœ… Pro Tip
Interview Tip: Know the difference between concat and concat_ws: concat returns NULL if ANY input is NULL, while concat_ws (with separator) skips NULLs. This is frequently asked.

What NOT to say: "I use Python string operations in a UDF for text manipulation." Always use built-in Spark string functions β€” they are optimized by Catalyst and avoid serialization overhead.

Answer First: Parse timestamps with an explicit format and timezone policy before deriving calendar fields or intervals.

Memory Map: raw string -> parse -> normalize timezone -> derive/diff -> format.

SECTION 15: Date Functions

Definition: PySpark provides built-in functions for date/timestamp creation, extraction, arithmetic, and formatting.

python β€” editable
from pyspark.sql.functions import (
    current_date, current_timestamp,
    datediff, months_between, date_add, date_sub,
    date_format, to_date, to_timestamp,
    year, month, dayofmonth, dayofweek, dayofyear,
    hour, minute, second, weekofyear, quarter,
    last_day, next_day, trunc, date_trunc,
    col, lit
)

# --- CURRENT DATE/TIMESTAMP ---
df.withColumn("today", current_date())
df.withColumn("now", current_timestamp())

# --- STRING TO DATE/TIMESTAMP ---
df.withColumn("parsed_date", to_date(col("date_str"), "yyyy-MM-dd"))
df.withColumn("parsed_ts", to_timestamp(col("ts_str"), "yyyy-MM-dd HH:mm:ss"))
# Common formats: "yyyy-MM-dd", "MM/dd/yyyy", "dd-MMM-yyyy", "yyyyMMdd"

# --- DATE FORMATTING ---
df.withColumn("formatted", date_format(col("booking_date"), "MMM dd, yyyy"))
# "2024-03-15" -> "Mar 15, 2024"
df.withColumn("year_month", date_format(col("booking_date"), "yyyy-MM"))

# --- DATE ARITHMETIC ---
df.withColumn("next_week", date_add(col("booking_date"), 7))
df.withColumn("last_week", date_sub(col("booking_date"), 7))
df.withColumn("days_diff", datediff(col("end_date"), col("start_date")))
df.withColumn("months_diff", months_between(col("end_date"), col("start_date")))

# --- EXTRACTING PARTS ---
df.withColumn("year", year(col("booking_date")))
df.withColumn("month", month(col("booking_date")))
df.withColumn("day", dayofmonth(col("booking_date")))
df.withColumn("dow", dayofweek(col("booking_date")))     # 1=Sunday, 7=Saturday
df.withColumn("quarter", quarter(col("booking_date")))
df.withColumn("week", weekofyear(col("booking_date")))

# --- TRUNCATION ---
df.withColumn("month_start", trunc(col("booking_date"), "month"))   # first day of month
df.withColumn("year_start", trunc(col("booking_date"), "year"))     # first day of year
df.withColumn("hour_start", date_trunc("hour", col("timestamp_col")))

# --- LAST DAY OF MONTH ---
df.withColumn("month_end", last_day(col("booking_date")))

# --- NEXT SPECIFIC DAY ---
df.withColumn("next_monday", next_day(col("booking_date"), "Monday"))
FunctionExampleOutput
current_date()-2024-03-15
datediff(end, start)(Mar 15, Mar 10)5
date_add(date, 7)(Mar 15, 7)Mar 22
months_between(end, start)(Jun 15, Mar 15)3.0
year(date)Mar 15, 20242024
date_format(date, "MMM yyyy")2024-03-15"Mar 2024"
to_date(str, "yyyy-MM-dd")"2024-03-15"Date object
trunc(date, "month")2024-03-152024-03-01
last_day(date)2024-03-152024-03-31
βœ… Pro Tip
Interview Tip: Know that datediff(end, start) takes the end date FIRST, which is counterintuitive. Also know that dayofweek returns 1 for Sunday (not Monday), which is a common gotcha.

What NOT to say: "I use Python datetime in a UDF for date calculations." Always use built-in Spark date functions. They are Catalyst-optimized and handle distributed data correctly.

Answer First: Navigate structs by field, arrays by element operations, and maps by key; explode only when row multiplication is intentional and project both map key and value aliases.

Memory Map: inspect schema -> select field/key -> transform -> explode if needed -> flatten.

SECTION 16: Nested Data β€” Struct, Array, Map

Definition: PySpark supports complex nested data types: StructType (nested objects), ArrayType (lists), and MapType (key-value pairs), commonly found in JSON data.

Simple Explanation: Real-world data is not always flat tables. JSON from APIs has nested objects (structs), lists (arrays), and dictionaries (maps). PySpark can read, query, and flatten all of these.

python β€” editable
from pyspark.sql.functions import explode, explode_outer, col, flatten, map_keys, map_values

# --- NESTED STRUCT ---
# Schema: address STRUCT<city STRING, country STRING, zip STRING>
df.select(
    col("id"),
    col("address.city").alias("city"),
    col("address.country").alias("country"),
    col("address.zip").alias("zip")
)

# --- ARRAY COLUMN ---
# Schema: tags ARRAY<STRING>
# explode: creates one row per element (NULL arrays -> zero rows)
df.withColumn("tag", explode(col("tags"))) \
  .select("id", "tag")

# explode_outer: creates one row per element (NULL arrays -> one row with NULL)
df.withColumn("tag", explode_outer(col("tags"))) \
  .select("id", "tag")

# size: number of elements in array
from pyspark.sql.functions import size
df.withColumn("tag_count", size(col("tags")))

# contains: check if array contains value
from pyspark.sql.functions import array_contains
df.filter(array_contains(col("tags"), "business"))

# flatten: flatten nested arrays
from pyspark.sql.functions import flatten
df.withColumn("flat_tags", flatten(col("nested_tags")))

# --- MAP COLUMN ---
# Schema: metadata MAP<STRING, STRING>
# Get value by key:
df.withColumn("status", col("metadata")["status"])
df.withColumn("status", col("metadata").getItem("status"))

# Explode map into key-value rows:
df.select("id", explode("metadata").alias("key", "value"))

# --- READING NESTED JSON ---
json_data = """
{"booking_id": "B001", "customer": {"name": "Alice", "email": "a@b.com"}, "tags": ["biz", "premium"]}
"""
df = spark.read.json(sc.parallelize([json_data]))

# Flatten it:
df_flat = df.select(
    col("booking_id"),
    col("customer.name").alias("customer_name"),
    col("customer.email").alias("customer_email"),
    explode(col("tags")).alias("tag")
)
FunctionNULL array inputEmpty array inputOutput
explodeDrops rowDrops rowOne row per element
explode_outerKeeps row (NULL)Drops rowOne row per element
posexplodeDrops rowDrops rowRow + position index
βœ… Pro Tip
Interview Tip: Always mention the difference between explode and explode_outer β€” explode drops null arrays silently, which can cause data loss. Use explode_outer when you want to preserve rows with null arrays.

What NOT to say: "I use UDFs to parse nested JSON." Spark handles nested JSON natively with dot notation for structs and explode for arrays. UDFs are unnecessary and slow for this.

Scenario ownership

CDC deduplication, self-joins, anti-joins, running totals, forward fill, nested JSON, and multi-source reads are practiced in Scenarios and Labs.

Quick Reference: DataFrame API vs SQL Equivalents

DataFrame API | SQL Equivalent
---------------------------------------|----------------------------------
df.select("a", "b") | SELECT a, b FROM table
df.filter(col("a") > 10) | WHERE a > 10
df.groupBy("a").agg(count("*")) | GROUP BY a ... COUNT(*)
df.orderBy(col("a").desc()) | ORDER BY a DESC
df.limit(10) | LIMIT 10
df.distinct() | SELECT DISTINCT *
df.join(df2, on="key", how="left") | LEFT JOIN df2 ON key = key
df.withColumn("b", col("a") * 2) | SELECT *, a * 2 AS b
df.drop("col") | (no direct equivalent, use SELECT)
df.union(df2) | UNION ALL
df.na.fill(0) | COALESCE(col, 0)
df.filter(col("a").isNull()) | WHERE a IS NULL
Advanced

Optimization and Performance

#

Optimization and Performance

Direct answer: Optimize from evidence: reduce scanned data, reduce shuffle bytes, balance partitions, select the right join, reuse only expensive repeated work, and write healthy files. Validate every change in the physical plan and Spark UI; configuration is the last lever, not the first.

Memory map: plan -> scan -> shuffle -> skew -> memory -> files. Compare input bytes, shuffle read/write, task-duration distribution, spill, GC time, and output-file sizes before and after.

THE SENIOR ENGINEER MINDSET

🧠 Memory Map
When someone says "the Spark job is slow", I don't guess.
I open Spark UI:
1. Stages tab→skewed tasks (one task 10x slower than median?)
2. SQL tab→physical plan (Sort-Merge Joins that should be Broadcast?)
3. Executors tab→GC time > 10%? → memory pressure
4. Storage tab→missing cache that's recomputed?
5. Environment tab→shuffle.partitions = 200 for 10 GB data?
The answer is ALWAYS one of:
Data skew β†’ salting or AQE skew handling
Wrong join strategy β†’ broadcast hint or AQE
Partitions wrong β†’ repartition / coalesce / shuffle.partitions
DataFrame recomputed multiple times β†’ cache()
GC pressure β†’ G1GC + memory config tuning

Catalyst handoff

Catalyst's canonical planning phases, pushdown/pruning examples, and plan-inspection commands live in DataFrames and Spark SQL. Optimization starts from that physical plan and measures runtime behavior rather than duplicating query-planning theory.

Recall Stack: physical execution

Answer First: Once Spark selects a physical plan, binary processing, code generation, and managed execution memory reduce CPU and object overhead for supported operators.

Memory Map: physical operators -> code generation -> binary rows -> CPU/memory metrics.

PART 2: TUNGSTEN ENGINE

3 Key Features

1. OFF-HEAP MEMORY MANAGEMENT
Custom memory allocator (bypasses Java heap/GC)
Stores data in binary format (not Java objects)
No GC pressure for data storage
spark.memory.offHeap.enabled = true
spark.memory.offHeap.size = 4g
2. CACHE-AWARE COMPUTATION
Organizes data access patterns for CPU cache efficiency
Sort and hash operations optimized for L1/L2 cache
Column-based in-memory format
3. WHOLE-STAGE CODE GENERATION (CodeGen)
Collapses multiple operators into one compiled JVM function
Eliminates virtual function calls per row
~10x improvement in CPU efficiency
Applied automatically by Catalyst

Recall Stack: partitions and shuffle

Answer First: Partition count controls task parallelism and per-task data size; a shuffle redistributes records and creates a stage boundary. Size partitions from observed bytes, then let AQE coalesce where appropriate.

Memory Map: bytes / target size -> partitions -> tasks -> shuffle read/write -> output files.

PART 3: REPARTITION vs COALESCE

python β€” editable
# repartition(N) β€” WIDE transformation (full shuffle)
df.repartition(200)           # increase OR decrease partitions
df.repartition(200, "col")    # hash partition by column (for joins!)
df.repartition(200, "col1", "col2")  # hash by multiple columns

# coalesce(N) β€” NARROW transformation (no shuffle)
df.coalesce(10)               # only DECREASE partitions (merge adjacent)
                              # cannot increase partition count

Use repartition() when increasing or rebalancing partitions, partitioning by a join key, or preparing for a large shuffle. Use coalesce() to decrease partitions cheaply after a major filter when the remaining partitions are already reasonably balanced.

Trap: coalesce(1) creates one partition and one output file, which is a bottleneck for large data. Choose a measured target file count instead.

spark.sql.shuffle.partitions Tuning

python β€” editable
# The static default is commonly 200, but size it from observed shuffle bytes.
# At 100-200 MB per partition, 200 partitions covers roughly 20-40 GB.

spark.conf.set("spark.sql.shuffle.partitions", "400")

# TOO FEW partitions (e.g., 10 for 1 TB data):
#   β†’ Each partition too large β†’ OOM, slow, no parallelism

# TOO MANY partitions (e.g., 200 for 1 MB data):
#   β†’ Up to 200 tiny tasks/files, plus scheduler and file-system overhead

# AQE automatically adjusts this! (see Part 5)

# Check current setting:
spark.conf.get("spark.sql.shuffle.partitions")

Recall Stack: joins, AQE, and skew

Answer First: Pick joins from side sizes and key distribution, then use AQE to adapt from runtime statistics. Skew is a distribution problem: detect long-tail tasks before choosing broadcast, splitting, salting, or two-phase aggregation.

Memory Map: sizes -> keys -> candidate join -> runtime stats -> skew treatment -> verify plan/UI.

PART 4: JOIN STRATEGIES

The 5 Join Strategies

1. BROADCAST HASH JOIN (BHJ) β€” FASTEST
─────────────────────────────────────────────────────────
When: One table fits in memory (< autoBroadcastJoinThreshold)
How: Driver broadcasts small table to ALL Executors
Each Executor does local hash join β€” NO SHUFFLE
Best for: Fact-dimension joins (orders JOIN countries)
Trigger:
β€’ Auto: spark.sql.autoBroadcastJoinThreshold = 10 MB (default)
β€’ Manual hint: df1.join(broadcast(df2), "key")
2. SHUFFLE HASH JOIN (SHJ)
─────────────────────────────────────────────────────────
When: One side is small enough to build hash map in memory
(but too big to broadcast)
How: Shuffle both sides by join key
Build hash map from smaller side
Probe hash map with larger side (no sort needed)
Best for: Medium-sized tables
3. SORT-MERGE JOIN (SMJ) β€” DEFAULT for large tables
─────────────────────────────────────────────────────────
When: Both tables are large (neither fits in memory for hash)
How: Shuffle both sides by join key
Sort both sides by join key
Merge (like merge sort) β€” no hash map needed
Best for: Large-large joins, equi-joins
Note: Most robust, but most expensive (shuffle + sort + merge)
4. BROADCAST NESTED LOOP JOIN (BNLJ) β€” SLOW
─────────────────────────────────────────────────────────
When: Non-equi joins (>, <, >=, <=, !=, BETWEEN, LIKE)
How: Broadcasts smaller table, nested loops for each row pair
Very slow O(nΓ—m) β€” avoid when possible
Use case: "Find all orders within 30 days of each event"
5. CARTESIAN JOIN β€” SLOWEST
─────────────────────────────────────────────────────────
When: CROSS JOIN with no join condition
Result: M Γ— N rows (every combination)
Requires: spark.sql.crossJoin.enabled = true
Use case: Generating all combinations (usually intentional)

Broadcast Join β€” Full Details

python β€” editable
from pyspark.sql.functions import broadcast

# Method 1: Hint in code (override Catalyst's decision)
result = large_df.join(broadcast(small_df), "customer_id")

# Method 2: SQL hint
spark.sql("""
    SELECT /*+ BROADCAST(countries) */ *
    FROM orders o
    JOIN countries c ON o.country_code = c.code
""")

# Threshold: auto-broadcast tables smaller than this
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "50m")  # 50 MB
# Set to -1 to DISABLE auto-broadcast (for testing SMJ)

# When to manually broadcast:
# 1. Table is >10 MB but <driver_memory (auto-threshold is too conservative)
# 2. AQE hasn't kicked in yet (early in job)
# 3. You know table is small but stats aren't computed (no ANALYZE TABLE)

# ⚠️ DANGER: Broadcasting a 10 GB table β†’ Driver/Executor OOM

Join Strategy Decision Tree

🧠 Memory Map
Question: How big are the tables?
Small + Any→BROADCAST HASH JOIN (always prefer!)
↓ (both large)
Equi-join?
Yes→SORT-MERGE JOIN (shuffle + sort + merge)
No→BROADCAST NESTED LOOP JOIN (slow, avoid if possible)
Non-equi but small side→BROADCAST NESTED LOOP (more tolerable)

Answer First: AQE revises eligible physical decisions from runtime shuffle statistics, including coalescing partitions, changing join strategies, and splitting skewed partitions.

Memory Map: initial plan -> shuffle statistics -> adaptive rewrite -> final plan -> metrics.

PART 5: AQE β€” ADAPTIVE QUERY EXECUTION

What is AQE?

AQE is Spark 3.0+ feature that re-optimizes query plans at runtime using actual runtime statistics (not estimated). Default enabled in Spark 3.2+.

python β€” editable
# Enable AQE (default ON in Spark 3.2+)
spark.conf.set("spark.sql.adaptive.enabled", "true")

AQE Feature 1: Coalescing Shuffle Partitions

PROBLEM
shuffle.partitions = 200 (default)
But your data is small→200 tiny partitions → scheduler overhead
AQE SOLUTION
After each shuffle stage, AQE looks at actual partition sizes
Merges small partitions into larger ones
Result: Fewer, more optimal partitions (no manual tuning!)
CONFIG
spark.sql.adaptive.coalescePartitions.enabled = true (default: true with AQE)
spark.sql.adaptive.advisoryPartitionSizeInBytes = 64m # target size per partition
spark.sql.adaptive.coalescePartitions.minPartitionNum = 1 # minimum after coalescing

AQE Feature 2: Dynamic Join Strategy Switching

PROBLEM
At plan time, Catalyst estimates table A is 500 MB (no broadcast)
But at runtime, after filtering, table A is actually 5 MB
Original plan: Sort-Merge Join (expensive)
AQE SOLUTION
At runtime, after filter executes, AQE rechecks actual table size
If now small enough→switches to Broadcast Hash Join on the fly!
No code change needed.
CONFIG
spark.sql.adaptive.localShuffleReader.enabled = true
# AQE can avoid network shuffle entirely when possible

AQE Feature 3: Skew Join Optimization

PROBLEM
One join key (e.g., "US") appears in 80% of rows
One task processes 80% of data→1 task runs for hours while others finish
AQE SOLUTION
Detects skewed partitions at runtime (using actual sizes)
Splits skewed partitions into smaller sub-partitions
Duplicates the matching data from the other side
Processes sub-partitions in parallel
CONFIG
spark.sql.adaptive.skewJoin.enabled = true (default: true with AQE)
spark.sql.adaptive.skewJoin.skewedPartitionFactor = 5 # 5x median = skewed
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes = 256m # and > 256 MB

AQE Full Config Block

python β€” editable
spark = SparkSession.builder \
    .config("spark.sql.adaptive.enabled", "true") \
    .config("spark.sql.adaptive.coalescePartitions.enabled", "true") \
    .config("spark.sql.adaptive.advisoryPartitionSizeInBytes", "64m") \
    .config("spark.sql.adaptive.skewJoin.enabled", "true") \
    .config("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "5") \
    .config("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "256m") \
    .getOrCreate()

Answer First: Skew means a few keys or partitions dominate work. Prove it from task distributions, then choose AQE splitting, broadcast, salting, or two-phase aggregation to match the operator.

Memory Map: long-tail task -> hot key/partition -> operator -> targeted split -> remeasure.

PART 6: DATA SKEW β€” DETECTION + SOLUTIONS

Detecting Skew

python β€” editable
# Method 1: Spark UI β†’ Stages β†’ look for one task MUCH slower than median
# "Task Duration: median=5s, max=500s" β†’ SKEW

# Method 2: Check key distribution
df.groupBy("country_code").count().orderBy(col("count").desc()).show(20)
# "US: 50M rows, UK: 2M, FR: 1M" β†’ US is skewed key

# Method 3: Check partition sizes
from pyspark.sql.functions import spark_partition_id, count
df.withColumn("pid", spark_partition_id()) \
  .groupBy("pid").count() \
  .orderBy(col("count").desc()) \
  .show(20)
# Large variance in count β†’ skew

Solution 1: Salting (Manual Fix)

python β€” editable
from pyspark.sql.functions import col, concat, lit, monotonically_increasing_id, rand
import math

# Step 1: Add salt to the large table's skewed key
SALT_FACTOR = 10
large_df_salted = large_df.withColumn(
    "salted_key",
    concat(col("country_code"), lit("_"), (rand() * SALT_FACTOR).cast("int").cast("string"))
)

# Step 2: Explode the small table to match all salt values
from pyspark.sql.functions import array, explode

small_df_exploded = small_df.withColumn(
    "salt", array([lit(i) for i in range(SALT_FACTOR)])
).withColumn("salt", explode("salt")) \
 .withColumn("salted_key",
     concat(col("country_code"), lit("_"), col("salt").cast("string")))

# Step 3: Join on salted key
result = large_df_salted.join(small_df_exploded, "salted_key", "inner")

# Step 4: Drop salt columns
result = result.drop("salted_key", "salt")

Solution 2: Broadcast the Small Side (If Possible)

python β€” editable
# If the skewed join is small-large, just broadcast the small side
result = large_df.join(broadcast(small_df), "country_code")

Solution 3: Let AQE Handle It (Spark 3.0+)

python β€” editable
# Enable AQE skew join handling (splits hot partitions automatically)
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
# No code change! AQE detects and handles at runtime.

Solution 4: Two-Phase Aggregation (for groupBy skew)

python β€” editable
# Phase 1: Group by (key, salt) to partially aggregate within partitions
import math
from pyspark.sql.functions import rand, col, sum

SALT = 10
partial = df.withColumn("salt", (rand() * SALT).cast("int")) \
            .groupBy("country_code", "salt") \
            .agg(sum("amount").alias("partial_sum"))

# Phase 2: Final aggregation drops salt
final = partial.groupBy("country_code") \
               .agg(sum("partial_sum").alias("total_amount"))

Recall Stack: reuse, memory, and files

Answer First: Cache only expensive results reused by multiple actions, unpersist promptly, and treat spill as a pressure signal rather than a correctness failure. Output file count follows partition count at write time.

Memory Map: reuse count -> storage level -> materialize -> observe eviction/spill -> unpersist -> compact files.

PART 7: CACHE vs PERSIST β€” DEEP DIVE

Storage Levels Comparison

LevelMemoryDiskSerializedReplicated
MEMORY_ONLYβœ“βœ—βœ—βœ—
MEMORY_ONLY_2βœ“βœ—βœ—βœ“
MEMORY_AND_DISKβœ“βœ“βœ—βœ—
MEMORY_AND_DISK_2βœ“βœ“βœ—βœ“
MEMORY_ONLY_SERβœ“βœ—βœ“βœ—
MEMORY_AND_DISK_SERβœ“βœ“βœ“βœ—
DISK_ONLYβœ—βœ“βœ“βœ—
OFF_HEAPoff-heap Tungsten memoryβœ—
python β€” editable
from pyspark.storagelevel import StorageLevel

df.cache()                                              # MEMORY_AND_DISK
df.persist(StorageLevel.MEMORY_ONLY)                    # fastest, may recompute
df.persist(StorageLevel.MEMORY_AND_DISK)                # safest
df.persist(StorageLevel.DISK_ONLY)                      # slowest, reliable
df.persist(StorageLevel.MEMORY_ONLY_SER)                # smaller memory, slower
df.persist(StorageLevel.OFF_HEAP)                       # Tungsten managed
df.unpersist()                                          # release cache

# For RDD:
rdd.cache()
rdd.persist(StorageLevel.MEMORY_AND_DISK)
rdd.unpersist()

When to Cache

CACHE when:
βœ“ DataFrame used in 2+ actions (count + show + write)
βœ“ Iterative algorithms (MLlib PageRank-like)
βœ“ Interactive queries in notebooks (avoid recompute)
βœ“ Joins where one side is reused multiple times
DO NOT CACHE when:
βœ— DataFrame used only once
βœ— Data is too large for executor memory (causes cache thrashing)
βœ— Data freshness required (cache can serve stale data)
βœ— Simple linear pipeline with no branching

Answer First: Pre-aggregate by key before shuffle whenever the operation permits it; use aggregateByKey when accumulator and input types differ, and avoid groupByKey for reducible results.

Memory Map: map-side combine -> shuffle partials -> reducer combine -> result.

PART 8: GROUPBY AGGREGATION β€” REDUCEBYKEY vs AGGREGATEBYKEY vs GROUPBYKEY

METHODPre-aggregationOutput = InputUse when
reduceByKeyYES (local)YES (same type)sum, max, min, product
aggregateByKeyYES (local)NO (any type)average, collect set
combineByKeyYES (local)NO (any type)most flexible, complex
groupByKeyNOYESneed ALL values as list
python β€” editable
# Average per key using aggregateByKey (output type β‰  input type)
rdd = sc.parallelize([("a", 10), ("b", 20), ("a", 30), ("b", 40)])

seqOp  = lambda acc, val: (acc[0] + val, acc[1] + 1)   # (sum, count)
combOp = lambda a, b: (a[0] + b[0], a[1] + b[1])
result = rdd.aggregateByKey((0, 0), seqOp, combOp) \
            .mapValues(lambda x: x[0] / x[1])
# β†’ [("a", 20.0), ("b", 30.0)]

# vs DataFrame way (preferred):
df.groupBy("key").agg(avg("value"))  # Let Catalyst handle it

Answer First: Small files inflate listing, planning, task, and metadata overhead. Measure bytes and file counts, then compact or size output partitions to a deliberate target.

Memory Map: bytes + files -> target file size -> partition count -> write -> compact/verify.

PART 9: SMALL FILES PROBLEM

Detection

Signs:
Job takes long to start (many tasks scheduled for tiny files)
Executor logs: "Task completed in 10 ms" β†’ overhead dominates
Output: ls shows 10,000 small files (< 10 MB each)

Causes

1. Streaming writes (each micro-batch writes many files)
2. Partitioned writes with many partition values (partitionBy with high cardinality)
3. After filter that produces tiny result sets per partition
4. coalesce(1) then repartition creates small outputs

Solutions

python β€” editable
# Solution 1: Coalesce before writing
df.coalesce(10).write.parquet("output/")  # reduce to 10 output files

# Solution 2: Repartition by actual data volume
target_size_mb = 128
data_size_mb = df.count() * schema.avg_row_size_mb  # estimate
n_partitions = max(1, int(data_size_mb / target_size_mb))
df.repartition(n_partitions).write.parquet("output/")

# Solution 3: Delta OPTIMIZE (compact small files into larger ones)
# OPTIMIZE delta.`/path/to/delta` ZORDER BY (col)
spark.sql("OPTIMIZE delta.`/path/` ZORDER BY (customer_id)")

# Solution 4: Hive/Spark Config (auto merge small files)
spark.conf.set("spark.sql.files.maxPartitionBytes", "134217728")  # 128 MB per partition
spark.conf.set("spark.sql.files.openCostInBytes", "4194304")      # 4 MB open cost estimate

Answer First: Configuration should encode measured resource and concurrency needs, not replace diagnosis. Tune one bounded resource at a time and compare the same workload.

Memory Map: workload -> cores/memory -> parallelism -> shuffle -> serialization/GC -> verify.

PART 10: SPARK CONFIGURATIONS β€” COMPLETE REFERENCE

Memory Configuration

python β€” editable
# --- EXECUTOR MEMORY ---
spark.executor.memory = "4g"             # JVM heap for executors
spark.executor.memoryOverhead = "512m"   # Off-heap overhead (Python, native)
                                         # Default: max(executor.memory * 0.1, 384 MB)
spark.memory.fraction = 0.6              # fraction of heap for Spark (default: 0.6)
spark.memory.storageFraction = 0.5       # within Spark memory: storage vs execution

# Memory layout (4g executor):
# Total heap = 4g
# User memory = 4g Γ— (1 - 0.6) = 1.6g  (UDFs, user data structures)
# Spark memory = 4g Γ— 0.6 = 2.4g
#   Storage (cache) = 2.4g Γ— 0.5 = 1.2g
#   Execution (joins, shuffles) = 2.4g Γ— 0.5 = 1.2g (both can borrow from each other)

# --- DRIVER MEMORY ---
spark.driver.memory = "4g"               # Driver JVM heap
spark.driver.maxResultSize = "2g"        # max result from collect() etc.

# --- OFF-HEAP (Tungsten) ---
spark.memory.offHeap.enabled = "true"
spark.memory.offHeap.size = "4g"

Executor Configuration

python β€” editable
spark.executor.cores = "4"               # cores per executor (2-5 recommended)
spark.executor.instances = "10"          # number of executors (static allocation)
# Dynamic allocation config below

# Rule of thumb:
# cores: 4-5 per executor (too many β†’ memory contention, too few β†’ underutilized)
# memory: 4g per core (so 4 cores β†’ 16g executor)

Shuffle Configuration

python β€” editable
spark.sql.shuffle.partitions = "200"     # default (tune: 1 partition per 100-200 MB)
spark.shuffle.compress = "true"          # compress shuffle data (default: true)
spark.shuffle.spill.compress = "true"    # compress spilled shuffle data
spark.io.compression.codec = "snappy"   # snappy (fast), lz4, zstd (best ratio)

Parallelism Configuration

python β€” editable
spark.default.parallelism = "200"        # default for RDD operations (= shuffle partitions)
spark.sql.shuffle.partitions = "200"     # for DataFrame/SQL operations
spark.sql.files.maxPartitionBytes = "134217728"  # 128 MB max per input partition
spark.sql.files.openCostInBytes = "4194304"      # 4 MB estimated open cost

Dynamic Allocation

python β€” editable
spark.dynamicAllocation.enabled = "true"          # auto scale executors
spark.dynamicAllocation.minExecutors = "1"
spark.dynamicAllocation.maxExecutors = "50"
spark.dynamicAllocation.initialExecutors = "5"
spark.dynamicAllocation.executorIdleTimeout = "60s"   # remove idle after 60s
spark.dynamicAllocation.schedulerBacklogTimeout = "1s" # add executor after 1s backlog
spark.shuffle.service.enabled = "true"                # required for dynamic allocation

Serialization Configuration

python β€” editable
spark.serializer = "org.apache.spark.serializer.KryoSerializer"  # faster than Java
spark.kryo.registrationRequired = "false"
spark.kryo.registrator = "com.myapp.MyKryoRegistrator"  # custom for custom classes

# Kryo vs Java serialization:
# Java: safe, works with any class, 3-4x larger output
# Kryo: faster, ~3-10x smaller, needs class registration for best performance

JVM GC Configuration

python β€” editable
# G1GC is recommended (better for large heaps)
spark.executor.extraJavaOptions = "-XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=35 -XX:ConcGCThreads=4"
spark.driver.extraJavaOptions = "-XX:+UseG1GC"

Production SparkSession Template

python β€” editable
spark = SparkSession.builder \
    .appName("ProductionJob") \
    .config("spark.executor.memory", "8g") \
    .config("spark.executor.cores", "4") \
    .config("spark.executor.memoryOverhead", "1g") \
    .config("spark.driver.memory", "4g") \
    .config("spark.driver.maxResultSize", "2g") \
    .config("spark.sql.shuffle.partitions", "400") \
    .config("spark.sql.adaptive.enabled", "true") \
    .config("spark.sql.adaptive.coalescePartitions.enabled", "true") \
    .config("spark.sql.adaptive.skewJoin.enabled", "true") \
    .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") \
    .config("spark.sql.autoBroadcastJoinThreshold", "50m") \
    .config("spark.dynamicAllocation.enabled", "true") \
    .config("spark.dynamicAllocation.minExecutors", "2") \
    .config("spark.dynamicAllocation.maxExecutors", "50") \
    .config("spark.shuffle.service.enabled", "true") \
    .config("spark.executor.extraJavaOptions", "-XX:+UseG1GC") \
    .getOrCreate()

Answer First: Start at the slow or failed job, drill into its stage and long-tail tasks, then connect scan, shuffle, spill, GC, and executor loss back to the physical operator.

Memory Map: job -> stage -> task distribution -> SQL operator -> executor logs.

PART 11: SPARK UI β€” HOW TO READ IT

Tabs and What to Look For

🧠 Memory Map
JOBS TAB
β†’ Each action = 1 job
β†’ Click job to see stages
STAGES TAB
β†’ Each stage has: Tasks completed, Duration, Input/Output/Shuffle R/W
→ "Task Duration" distribution: if max >> median→SKEW
→ "GC Time" > 10% of task time→memory pressure
→ Failed tasks→OOM or node failure
SQL TAB (most important!)
β†’ Click "Details" on a query
β†’ See Physical Plan with metrics
β†’ Look for:
β€’ "FileScan"β†’"PushedFilters" β†’ is predicate pushed down?
β€’ "BroadcastHashJoin" vs "SortMergeJoin"
β€’ "Exchange" = shuffle (wide transformation)
β€’ "Sort" = sort happening
β†’ Numbers on each node: rows, bytes in/out
EXECUTORS TAB
→ GC Time column→if high → reduce executor memory fraction or use G1GC
→ Storage Memory Used→is cache working?
→ Failed Tasks→which executor is problematic?
STORAGE TAB
β†’ Cached RDDs/DataFrames
β†’ Fraction cached, memory used
β†’ If cached fraction < 1.0β†’data doesn't fit in cache
ENVIRONMENT TAB
β†’ All Spark configs active in this session
β†’ Verify your config changes took effect

Reading the SQL Physical Plan

FileScan parquet [col1, col2, col3] ← column pruning working
PushedFilters: [EqualTo(year, 2024)] ← predicate pushdown working
PartitionCount: 100 ← how many partitions scanned
HashAggregate(keys=[dept], functions=[sum]) ← partial aggregation (local)
Exchange hashpartitioning(dept, 200) ← SHUFFLE (200 = shuffle.partitions)
HashAggregate(keys=[dept], functions=[partial_sum]) ← pre-aggregation
BroadcastHashJoin [id = id] ← BROADCAST JOIN (fast!)
BroadcastExchange HashedRelationBroadcastMode ← small table broadcasted
SortMergeJoin [id = id] ← SORT-MERGE JOIN (shuffle + sort)
Sort [id ASC]
Exchange hashpartitioning(id, 200)
Sort [id ASC]
Exchange hashpartitioning(id, 200)

Answer First: Cache preserves computed partitions for reuse; checkpoint materializes reliable storage and truncates lineage. They solve reuse and recovery problems respectively.

Memory Map: reuse -> cache/persist; long lineage -> checkpoint; done -> unpersist.

PART 12: CHECKPOINTING vs CACHING

CACHING
Purpose: Reuse data in same application (avoid recomputation)
Storage: Executor memory/disk (not persistent)
Lineage: Preserved (can recompute from lineage if lost)
Speed: Fast to read (memory), slower (disk)
Scope: Current SparkSession lifetime
df.cache()
df.persist(StorageLevel.MEMORY_AND_DISK)
CHECKPOINTING
Purpose: Cut DAG lineage for very long iterative jobs
Save state that survives application restart
Storage: HDFS / S3 (reliable, persistent)
Lineage: CUT (checkpoint IS the source β€” no upstream lineage)
Speed: Slower (HDFS write, but saves re-traversing long DAG)
Scope: Survives application restarts
spark.sparkContext.setCheckpointDir("hdfs:///checkpoints/")
df.checkpoint() # triggers action, writes to HDFS, returns new DF
WHEN TO CHECKPOINT (vs cache)
βœ“ Iterative ML-like jobs (PageRank, k-means): after each iteration
βœ“ Streaming: checkpoint state for fault tolerance
βœ“ Very long lineage chains (DAG > 50 stages) to avoid recomputation cost
βœ“ When data must survive Driver restart
PATTERN: Cache first, then checkpoint
df.cache()
df.checkpoint() # triggers action (reads from cache β†’ writes to HDFS)
df.unpersist() # release cache after checkpoint

Debugging scenario ownership

The six-hour-job, executor-OOM, Monday-volume, small-file, and related infrastructure playbooks live in Scenarios and Labs.

Answer First: Dynamic allocation changes executor count with backlog and idleness. Bound its range and preserve shuffle availability so elasticity does not invalidate downstream reads.

Memory Map: backlog -> request executors -> run tasks -> idle timeout -> release safely.

PART 14: DYNAMIC ALLOCATION

python β€” editable
# Dynamic Allocation = Spark requests/releases Executors based on workload

# ENABLE:
spark.dynamicAllocation.enabled = true
spark.shuffle.service.enabled = true          # REQUIRED: keeps shuffle files after executor released

# SCALING UP:
spark.dynamicAllocation.schedulerBacklogTimeout = "1s"   # add exec if backlog > 1s
spark.dynamicAllocation.sustainedSchedulerBacklogTimeout = "5s"  # keep adding every 5s

# SCALING DOWN:
spark.dynamicAllocation.executorIdleTimeout = "60s"   # release executor idle >60s

# LIMITS:
spark.dynamicAllocation.minExecutors = "2"   # always keep at least 2
spark.dynamicAllocation.maxExecutors = "100" # never exceed 100

# WHY shuffle service is required:
# When executor is released, shuffle files it wrote are LOST
# External shuffle service (running on YARN NM) keeps those files available
# Without it: releasing executors causes downstream stages to fail

# WHEN TO USE dynamic allocation:
# βœ“ Shared clusters (don't hog resources when idle)
# βœ“ Jobs with varying workload stages (small start, large shuffle middle)
# βœ“ Interactive workloads (notebooks)

# WHEN TO PREFER STATIC:
# βœ“ Consistent workload (same data size every run)
# βœ“ SLA-critical jobs (dynamic scaling adds latency)

Answer First: Optimize in evidence order: reduce input, inspect the plan, correct skew and joins, size partitions, control reuse, then tune resources.

Memory Map: scan -> plan -> shuffle/skew -> partitions -> cache/files -> resources -> regression check.

PART 15: FULL OPTIMIZATION CHECKLIST

BEFORE WRITING SPARK CODE
β–‘ Is data partitioned correctly for this query? (partition pruning)
β–‘ Which join strategy should I use? (size of tables)
β–‘ Should I broadcast the small table?
β–‘ What's my target partition size? (100-200 MB per partition)
WHILE WRITING
β–‘ Filters applied early (before joins/aggregations)
░ No groupByKey→use reduceByKey/aggregateByKey
β–‘ UDFs avoided where built-ins suffice
β–‘ DataFrame cached before reuse (2+ actions)
β–‘ Explicit schema defined (no inferSchema=True)
β–‘ JDBC reads have numPartitions configured
CONFIG CHECKLIST
β–‘ spark.sql.adaptive.enabled = true
β–‘ spark.sql.shuffle.partitions tuned (not default 200 for large data)
β–‘ spark.executor.memory adequate (no OOM)
β–‘ spark.sql.autoBroadcastJoinThreshold tuned (> 10 MB for modern clusters)
β–‘ Kryo serializer enabled (for RDD-heavy jobs)
β–‘ Dynamic allocation configured (for shared clusters)
AFTER JOB RUNS
β–‘ Spark UI: any skewed stages? (max task >> median task)
β–‘ Spark UI: SortMergeJoin that could be BroadcastHashJoin?
β–‘ GC Time on Executors tab > 10%?β†’reduce memory fraction or tune GC
β–‘ Output file count reasonable? (not 10,000 tiny files)
β–‘ AQE actually triggered? (check Physical Plan for AQE annotations)

QUICK COMPARISON TABLES

Repartition vs Coalesce

Propertyrepartition(N)coalesce(N)
TransformationWIDE (full shuffle)NARROW (no shuffle)
DirectionIncrease OR decreaseDECREASE ONLY
BalancePerfectly balancedMay be uneven (skipped)
SpeedSlower (shuffle)Faster (no shuffle)
When to useBefore joins, onAfter filter, before
skewed datawrite to reduce files
Column-basedYES (repartition byNO

Join Strategy Comparison

StrategyWhenShuffleSortSpeed
BroadcastHashJoinSmall + Any sizeNONOFASTEST
ShuffleHashJoinMedium + MediumYESNOFast
SortMergeJoinLarge + Large (equi-join)YESYESSlow
BroadcastNLJNon-equi joinPartialNOSlowest
CartesianJoinNo condition (CROSS)YESNOSlowest

AQE Features

FeatureProblem SolvedConfig
Coalesce Shuffle PartitionsToo many small partitionscoalescePartitions.enabled
Dynamic Join StrategySMJ β†’ BHJ at runtime(enabled with AQE)
Skew Join OptimizationHot key tasksskewJoin.enabled
Advanced

Scenarios and Labs

#

Scenarios and Labs

Direct answer: Diagnose before tuning. Establish the failing job/stage/task, compare task and partition distributions, inspect scan/shuffle/spill/GC metrics and the physical plan, form one hypothesis, change one variable, and measure again.

Scenario memory map: symptom -> evidence -> root cause -> smallest safe fix -> verification -> rollback. The labs below are the canonical home for runnable examples, gotchas, coding drills, and mock-interview answers.

Confusion quick table

ConfusionDeciding question
RDD vs DataFrameDoes the work need low-level control, or can Catalyst optimize a schema-aware plan? Prefer DataFrame for normal ETL.
Transformation vs actionDoes the call only extend a plan, or trigger/read/write a result?
Narrow vs wideCan each output partition be computed from one input partition, or must records move between partitions?
cache vs persistIs default storage enough, or must the storage level be explicit? Both are lazy and require reuse to pay off.
repartition vs coalesceAre you increasing/rebalancing (shuffle) or only decreasing cheaply (possibly imbalanced)?
broadcast vs sort-mergeIs one side safely small on every executor, or should both large sides shuffle/sort?
select/selectExpr/withColumnMany expressions, SQL syntax, or one replacement/addition? Avoid long withColumn chains.
groupBy vs windowCollapse each group, or retain every row while adding group context?

Scenario: executor OOM

Answer First: First locate whether memory is exhausted in the JVM heap, Python worker, execution/shuffle, cache, or overhead. Common causes are oversized or skewed partitions, unbounded per-key collections, an unsafe broadcast, excessive caching, or too many concurrent tasksβ€”not simply β€œtoo little memory.”

Memory Map: failed executor -> stage/task -> partition distribution -> spill/GC -> operator -> smallest fix.

Check the failed stage and executor logs, compare maximum task input and shuffle size with the median, and inspect the physical operator. Reduce data early; split or salt skew; replace groupByKey or unbounded collect_list; stop broadcasting an unsafe side; and unpersist unused data. Tune partition count or memory overhead only with evidence, then verify lower peak task size, spill, and GC time on the same stage.

Scenario: slow Spark job

Answer First: Compare the slow run with a known-good run, find the first regressed stage, and diagnose from plan and metrics before changing configuration.

Memory Map: input change -> plan change -> stage -> long-tail tasks -> shuffle/spill/GC -> measured fix.

Test scan growth, lost partition pruning, a changed join strategy, skew, repeated recomputation, and tiny-file overhead. Change one cause at a time and compare wall time plus scan, shuffle, and task-distribution metrics.

APPENDIX: LEARN BY DOING

LAB 1 β€” See Lazy Evaluation In Action

python β€” editable
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("LazyLab").getOrCreate()

import time

# STEP 1: create a DataFrame
print("Creating DataFrame...")
t0 = time.time()
df = spark.range(1, 10_000_000)           # ← transformation: LAZY
print(f"Create time: {time.time() - t0:.3f}s")
# EXPECTED: Create time: 0.050s   ← ALMOST ZERO! Nothing ran.
python β€” editable
# STEP 2: add transformations
print("Adding transformations...")
t0 = time.time()
df2 = (
    df.filter("id % 2 = 0")  # ← LAZY
      .withColumn("doubled", df.id * 2)  # ← LAZY
      .filter("doubled > 100")  # ← LAZY
)
print(f"Transform time: {time.time() - t0:.3f}s")
# EXPECTED: Transform time: 0.020s   ← STILL nothing executed!
python β€” editable
# STEP 3: trigger an action
print("Calling action (count)...")
t0 = time.time()
result = df2.count()                     # ← ACTION β€” NOW everything runs
print(f"Action time: {time.time() - t0:.3f}s")
print(f"Result: {result}")
# EXPECTED:
# Action time: 2.500s    ← THIS is where the work happened
# Result: 4999950
python β€” editable
# STEP 4: see the plan that Spark built
df2.explain(True)
# You'll see:
# == Parsed Logical Plan ==
# == Analyzed Logical Plan ==
# == Optimized Logical Plan ==       ← Catalyst OPTIMIZED here
# == Physical Plan ==                ← This is what actually runs
#
# πŸ’‘ Catalyst COMBINES both filters into one and pushes it down!

🎯 Lesson: Transformations build a plan. Only actions run it. Catalyst optimizes everything BEFORE execution.

LAB 2 β€” See Shuffle Happen (Narrow vs Wide)

python β€” editable
# SETUP
df = spark.range(1, 1_000_000) \
          .selectExpr("id", "id % 10 AS bucket")

# STEP 1: narrow transformation β€” check partition count
print("Original partitions:", df.rdd.getNumPartitions())
# EXPECTED: 8  (depends on cluster config)

df_filtered = df.filter("bucket > 5")
print("After filter:", df_filtered.rdd.getNumPartitions())
# EXPECTED: 8  ← same partitions (narrow, no shuffle)
python β€” editable
# STEP 2: wide transformation β€” shuffle happens
df_grouped = df.groupBy("bucket").count()
print("After groupBy:", df_grouped.rdd.getNumPartitions())
# EXPECTED: 200  ← jumped to 200! (default shuffle partitions)
#                  This number comes from spark.sql.shuffle.partitions

df_grouped.show()
# EXPECTED:
# +------+------+
# |bucket| count|
# +------+------+
# |     1|100000|
# |     2|100000|
# ...
python β€” editable
# STEP 3: see the stage boundary in the plan
df_grouped.explain()
# EXPECTED: Look for "Exchange hashpartitioning(bucket, 200)"
# THAT is the shuffle.

# Visit Spark UI (http://localhost:4040) and look at DAG:
# Stage 0: read + filter (NARROW)
#   β”‚
#   β–Ό Exchange (SHUFFLE β€” disk+network)
#   β”‚
# Stage 1: groupBy + count (NARROW again, on shuffled data)

🎯 Lesson: Every "Exchange" in the plan = one shuffle = one stage boundary.

LAB 3 β€” Watch Broadcast Join vs Sort-Merge

python β€” editable
# SETUP: one large table, one small table
big   = spark.range(1, 10_000_000).selectExpr("id AS big_id", "id % 1000 AS key")
small = spark.range(1, 100).selectExpr("id AS key", "id * 10 AS multiplier")

# STEP 1: regular join (may auto-broadcast since small is tiny)
joined = big.join(small, "key")
joined.explain()
# Look for "BroadcastHashJoin" in the plan
# β†’ Spark auto-broadcast the small side (under 10 MB threshold)
python β€” editable
# STEP 2: force sort-merge join (disable auto-broadcast)
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)

joined = big.join(small, "key")
joined.explain()
# Look for "SortMergeJoin" in the plan
# β†’ Both sides get SHUFFLED and SORTED β€” expensive even though "small" is tiny!
python β€” editable
# STEP 3: force broadcast with hint
from pyspark.sql.functions import broadcast

joined = big.join(broadcast(small), "key")
joined.explain()
# Look for "BroadcastHashJoin" again β€” hint overrides threshold
# β†’ Small table sent to every executor. NO shuffle of big table.
python β€” editable
# STEP 4: time the difference
import time

spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)  # force sort-merge
t0 = time.time()
big.join(small, "key").count()
print(f"Sort-merge: {time.time() - t0:.2f}s")

t0 = time.time()
big.join(broadcast(small), "key").count()
print(f"Broadcast:  {time.time() - t0:.2f}s")

# EXPECTED: Broadcast is 2-5x faster when one side is small.

🎯 Lesson: Always broadcast if one side fits. Check physical plan with .explain() to verify.

VISUAL ANIMATION 1 β€” Spark Execution Model

πŸ“ Architecture Diagram
Your code:
─────────
df = spark.read.parquet(...)
df = df.filter(...)
df = df.join(other, ...)
df.count()    ← action triggers execution

What Spark builds:
─────────────────
          β”Œβ”€ Logical Plan ──┐
          β”‚  Read           β”‚
          β”‚   β”‚             β”‚
          β”‚  Filter         β”‚
          β”‚   β”‚             β”‚
          β”‚  Join           β”‚  ← Spark sees the FULL pipeline first
          β”‚   β”‚             β”‚
          β”‚  Count          β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
                 β–Ό
          β”Œβ”€ Catalyst ──────┐
          β”‚ Optimizations:  β”‚
          β”‚ β€’ Predicate pushβ”‚  ← pushes filter BELOW join
          β”‚ β€’ Column pruningβ”‚  ← drops unused columns
          β”‚ β€’ Join reorder  β”‚  ← smaller tables first
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
                 β–Ό
          β”Œβ”€ Physical Plan ─┐
          β”‚ Scan Parquet    β”‚
          β”‚   (with filter) β”‚
          β”‚   β”‚             β”‚
          β”‚ BroadcastHash   β”‚
          β”‚   Join          β”‚
          β”‚   β”‚             β”‚
          β”‚ Count           β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
                 β–Ό
              DAG of
              STAGES
                 β”‚
                 β–Ό
          EXECUTORS run
          tasks in parallel

🧠 KEY: code β†’ logical plan β†’ optimized plan β†’ physical plan β†’ DAG β†’ tasks

VISUAL ANIMATION 2 β€” Narrow vs Wide (Stage Boundary)

πŸ“ Architecture Diagram
Job: df.filter(...).select(...).groupBy("x").count()

       filter         select         groupBy            count
         β”‚              β”‚              β”‚                  β”‚
Stage 0 (NARROW):       β”‚              β”‚                  β”‚
  P1 ──→ filter ──→ select ──┐         β”‚                  β”‚
  P2 ──→ filter ──→ select ───         β”‚                  β”‚
  P3 ──→ filter ──→ select ───         β”‚                  β”‚
  P4 ──→ filter ──→ select ───         β”‚                  β”‚
                             β”‚         β”‚                  β”‚
                             β–Ό         β–Ό                  β”‚
                          ╔═══════════════╗               β”‚
                          β•‘    SHUFFLE    β•‘   ← wide op   β”‚
                          β•‘  (network I/O)β•‘               β”‚
                          β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•               β”‚
                             β”‚         β”‚                  β”‚
Stage 1 (NARROW again):      β–Ό         β–Ό                  β”‚
                  P1' ─→ groupBy+count  ───────────────────
                  P2' ─→ groupBy+count  ───────────────────
                  P3' ─→ groupBy+count  β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ’‘ Every stage is a pipeline of NARROW transformations.
   Shuffles always happen at STAGE BOUNDARIES.
   Fewer stages = fewer shuffles = faster job.

VISUAL ANIMATION 3 β€” Broadcast Join vs Sort-Merge Join

πŸ“ Architecture Diagram
BROADCAST JOIN (small table fits in memory):
────────────────────────────────────────────
                  small_df
                  (copies)
                    β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β–Ό           β–Ό           β–Ό
     Exec-1      Exec-2      Exec-3
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ big P1 β”‚  β”‚ big P2 β”‚  β”‚ big P3 β”‚  ← big stays where it is
  β”‚   +    β”‚  β”‚   +    β”‚  β”‚   +    β”‚     NO SHUFFLE of big table!
  β”‚ small  β”‚  β”‚ small  β”‚  β”‚ small  β”‚
  β”‚ (copy) β”‚  β”‚ (copy) β”‚  β”‚ (copy) β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜
      β”‚            β”‚            β”‚
      β–Ό            β–Ό            β–Ό
   local joins (parallel, fast)

SORT-MERGE JOIN (both large):
──────────────────────────────
  big_df        small_df
    β”‚              β”‚
    β–Ό              β–Ό
 [SHUFFLE]     [SHUFFLE]     ← both sides shuffled by join key
    β”‚              β”‚
    β–Ό              β–Ό
 [SORT]         [SORT]        ← both sides sorted on key
    β”‚              β”‚
    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
           β–Ό
        MERGE (walk both sides in order)

πŸ’‘ Broadcast wins when small side < ~100 MB.
   Sort-merge is required when both are huge.
   Shuffle-hash is in between (rare default).

GOTCHAS β€” The PySpark Bugs That Get You

Gotcha 1: .collect() pulls ALL data to the driver (OOM risk)

python β€” editable
# ❌ DANGER: on a 1B row DataFrame, this will OOM the driver
results = df.collect()              # brings ENTIRE df to driver JVM
for row in results:
    print(row)

# βœ… FIX: use show() or write or toLocalIterator
df.show(10)                         # only 10 rows to driver
df.take(100)                        # first 100 rows to driver
for row in df.toLocalIterator():    # streams rows one-by-one
    print(row)

Interview trap: "What's the difference between show() and collect()?" Answer: "show() only pulls N rows (default 20) to the driver for printing. collect() materializes the entire DataFrame in driver memory β€” dangerous on large data."

Gotcha 2: Chained withColumn() creates plan explosion

python β€” editable
# ❌ BAD: 50 withColumn calls β†’ huge logical plan, slow analyzer
for col_name in columns_to_process:
    df = df.withColumn(col_name, f.upper(f.col(col_name)))

# βœ… GOOD: single select with a list of expressions
df = df.select(*[f.upper(f.col(c)).alias(c) if c in columns_to_process
                 else f.col(c) for c in df.columns])

Gotcha 3: Python UDFs are SLOW (serialization + no Catalyst)

python β€” editable
# ❌ SLOW: row-at-a-time Python UDF, serialization per row
from pyspark.sql.functions import udf
upper_udf = udf(lambda s: s.upper())
df.withColumn("name_upper", upper_udf("name"))

# βœ… FAST: use built-in SQL functions (run in JVM, optimized)
from pyspark.sql.functions import upper
df.withColumn("name_upper", upper("name"))

# βœ… ALSO FAST: Pandas UDF (vectorized, batch-based)
import pandas as pd
from pyspark.sql.functions import pandas_udf
@pandas_udf("string")
def upper_pandas(s: pd.Series) -> pd.Series:
    return s.str.upper()

Rule: ALWAYS check if a built-in function exists before writing a UDF.

Gotcha 4: Small-file problem kills streaming performance

Symptom: 10,000 files of 50 KB each→slow listing, slow scans
Fix: compact output after writes
python β€” editable
# After streaming writes, compact hourly:
df.repartition(10).write.mode("overwrite").parquet(path)

# Or use Delta Lake OPTIMIZE for Delta tables
spark.sql(f"OPTIMIZE delta.`{path}`")

Gotcha 5: Data skew in joins (one key has 99% of rows)

Symptom: 199 tasks finish in 2 min, 1 task runs for 2 hours
Cause: one join key value dominates (e.g., customer_id=0 for guest checkout)

Fix β€” Salting:

python β€” editable
from pyspark.sql.functions import rand, concat, lit
from pyspark.sql.types import IntegerType

# Add random salt to skewed side
big = big.withColumn("salt", (rand() * 10).cast(IntegerType()))
big = big.withColumn("key_salted", concat("key", lit("_"), "salt"))

# Explode small side with all possible salts
small_exploded = small.crossJoin(
    spark.range(10).toDF("salt")
).withColumn("key_salted", concat("key", lit("_"), "salt"))

# Now join on key_salted (distributes work evenly)
big.join(small_exploded, "key_salted")

Or enable AQE skew join:

python β€” editable
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")

Gotcha 6: df.count() after .cache() β€” the "cache warmup" trick

python β€” editable
df_expensive = df.filter(...).join(...).withColumn(...)
df_expensive.cache()                     # mark for caching (still lazy!)

# ❌ If you immediately do:
df_expensive.groupBy("a").count().show()  # triggers cache + computes
df_expensive.groupBy("b").count().show()  # reuses cache βœ…
# But the first .show() had to BOTH compute AND materialize cache β€” slow

# βœ… GOOD: force cache warmup first
df_expensive.count()                     # materializes cache quickly
df_expensive.groupBy("a").count().show() # fast (from cache)
df_expensive.groupBy("b").count().show() # fast (from cache)

MOCK INTERVIEW

Q1: "Your Spark job used to finish in 10 min, now it takes 3 hours. Debug it."

❌ BAD ANSWER: "Add more executors."

βœ… GOOD ANSWER:

"Five-step investigation. First, Spark UI β€” check the stage durations to find the bottleneck stage. Second, look for skew: if one task takes 100x longer than the median, it's skew. Third, check shuffle read/write sizes β€” if they jumped, the data volume or partitioning changed. Fourth, check the physical plan with explain() β€” did a broadcast join become a sort-merge join because the 'small' side grew past the threshold? Fifth, verify caches β€” if we added more steps and cache hits dropped, we may be recomputing expensive DAGs. Each of these has a specific fix."

Q2: "Explain lazy evaluation and why it matters."

βœ… GOOD ANSWER:

"Transformations build a DAG of operations without executing. Actions trigger execution. The reason this matters: Catalyst optimizer sees the ENTIRE pipeline before running, so it can do predicate pushdown, column pruning, and join reordering. If operations ran eagerly like pandas, we'd lose all those optimizations. It also means if you call a transformation but no action, nothing runs β€” a common debugging pitfall for newcomers."

Q3: "When do you broadcast vs sort-merge join?"

βœ… GOOD ANSWER:

"Broadcast when one side is small enough to fit in executor memory β€” default threshold is 10 MB but I often raise it to ~100 MB if executors have enough RAM. Broadcast avoids shuffling the big side entirely, which is huge for performance. Sort-merge when both sides are large β€” Spark shuffles both by the join key, sorts, and merges. It's the default for large-large joins and scales well. I always check the physical plan to verify which strategy was chosen, and use the broadcast() hint to force it when Spark guesses wrong."

Q4: "How do you handle data skew?"

βœ… GOOD ANSWER:

"First, confirm it IS skew β€” look at Spark UI for one task taking 10x+ longer than others. Then three options. Option 1: enable AQE skew join β€” Spark auto-splits the skewed partition at runtime (modern, easiest). Option 2: salting β€” append a random integer suffix to the skewed key, explode the other side to match all salts, join on the salted key. This distributes work evenly. Option 3: separate the hot key β€” filter out the top-1 key, join it separately with a broadcast, and union the results back. The right fix depends on how concentrated the skew is."

Q5: "When does cache() make things slower?"

βœ… GOOD ANSWER:

"Three situations. First, if you cache but only read the data ONCE, you paid the cache serialization cost for no benefit. Second, if the cached data doesn't fit in memory, it spills to disk, and reading from Parquet might actually be faster than reading spilled cache. Third, under memory pressure, Spark evicts cached partitions β€” if that happens between two reads, you pay the cost twice. Rule of thumb: only cache DataFrames that will be used 2+ times, that fit comfortably in memory, and that come AFTER expensive operations like joins or UDFs."

FINAL READINESS CHECKLIST

If you can do all of these, you're PySpark-interview ready:

  • Explain RDD vs DataFrame and why DataFrame is preferred
  • Define lazy evaluation and list 5 transformations + 5 actions
  • Draw narrow vs wide transformations on a whiteboard
  • Explain what causes a shuffle and 3 ways to avoid one
  • Decide broadcast vs sort-merge join given table sizes
  • Write the salting pattern from memory
  • Explain cache() vs persist() and when each is slow
  • Read a physical plan (.explain()) and spot the shuffles
  • List 3 things to check when a job suddenly gets slow
  • Explain why UDFs are slow and the 2 alternatives

If yes β†’ you're ready. No videos needed.

Coding and infrastructure question bank

These are runnable solution patterns. The compact question-only recall index is in Interview Questions.

SECTION 1: BASIC QUESTIONS (B)

Q01 β€” Filter employees earning > 50k

Problem: Given an employees table with (emp_id, name, dept, salary),
return all employees with salary > 50000, sorted by salary desc.
python β€” editable
from pyspark.sql.functions import col

employees = spark.read.parquet("employees/")

result = employees \
    .filter(col("salary") > 50000) \
    .select("emp_id", "name", "dept", "salary") \
    .orderBy(col("salary").desc())

result.show()

Q02 β€” Count orders per customer

Problem: Given orders(order_id, customer_id, amount, order_date),
find total orders and total amount spent per customer.
python β€” editable
from pyspark.sql.functions import count, sum, col

result = orders.groupBy("customer_id") \
    .agg(
        count("order_id").alias("total_orders"),
        sum("amount").alias("total_spent")
    ) \
    .orderBy(col("total_spent").desc())

result.show()

Q03 β€” Find duplicate rows by email

Problem: Given users(user_id, email, created_at),
find all emails that appear more than once.
python β€” editable
from pyspark.sql.functions import count, col

# Method 1: groupBy + filter
duplicates = users.groupBy("email") \
    .agg(count("*").alias("cnt")) \
    .filter(col("cnt") > 1)

# Method 2: join back to get full rows
result = users.join(duplicates, "email", "inner") \
    .select("user_id", "email", "created_at", "cnt") \
    .orderBy("email")

result.show()

Q04 β€” Total sales by date

Problem: Given transactions(txn_id, date, store_id, amount),
compute daily total sales, ordered by date.
python β€” editable
from pyspark.sql.functions import sum, col, to_date

result = transactions \
    .withColumn("date", to_date(col("date"))) \
    .groupBy("date") \
    .agg(sum("amount").alias("daily_sales")) \
    .orderBy("date")

result.show()

Q05 β€” Add derived column (categorize salary)

Problem: Given employees(emp_id, name, salary),
add a "salary_band" column: High(>100k), Mid(50k-100k), Low(<50k)
python β€” editable
from pyspark.sql.functions import col, when

result = employees.withColumn(
    "salary_band",
    when(col("salary") > 100000, "High")
    .when(col("salary") >= 50000, "Mid")
    .otherwise("Low")
)

result.show()

Q06 β€” Read CSV + handle nulls

Problem: Read a CSV file with nulls. Fill numeric nulls with 0,
string nulls with "Unknown". Drop rows where emp_id is null.
python β€” editable
from pyspark.sql.types import StructType, StructField, IntegerType, StringType, DoubleType

schema = StructType([
    StructField("emp_id",  IntegerType(), nullable=True),
    StructField("name",    StringType(),  nullable=True),
    StructField("dept",    StringType(),  nullable=True),
    StructField("salary",  DoubleType(),  nullable=True)
])

df = spark.read \
    .schema(schema) \
    .option("header", True) \
    .csv("employees.csv")

result = (
    df.na.drop(subset=["emp_id"])  # drop rows where emp_id is null
      .na.fill({"salary": 0.0, "dept": "Unknown"})  # fill remaining nulls
)

result.show()

Q07 β€” Word count (RDD style)

Problem: Given a text file, count the frequency of each word.
Return top 10 most frequent words.
python β€” editable
# RDD approach (classic interview question)
rdd = sc.textFile("data/text_file.txt")

word_counts = rdd \
    .flatMap(lambda line: line.lower().split()) \
    .map(lambda word: (word, 1)) \
    .reduceByKey(lambda a, b: a + b) \
    .sortBy(lambda x: x[1], ascending=False)

word_counts.take(10)

# DataFrame approach (prefer in practice)
from pyspark.sql.functions import explode, split, lower, col

df = spark.read.text("data/text_file.txt")
result = df \
    .withColumn("word", explode(split(lower(col("value")), "\\s+"))) \
    .groupBy("word") \
    .count() \
    .orderBy(col("count").desc()) \
    .limit(10)

result.show()

SECTION 2: MEDIUM QUESTIONS (M)

Q08 β€” Max salary per department

Problem: Given employees(emp_id, name, dept, salary),
find the highest salary in each department.
python β€” editable
from pyspark.sql.functions import max, col

result = employees.groupBy("dept") \
    .agg(max("salary").alias("max_salary")) \
    .orderBy(col("max_salary").desc())

result.show()

Q09 β€” Rank employees by salary per department

Problem: Rank employees within each department by salary (highest = rank 1).
Return emp_id, name, dept, salary, rank.
python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import dense_rank, col

w = Window.partitionBy("dept").orderBy(col("salary").desc())

result = employees.withColumn("rank", dense_rank().over(w)) \
    .select("emp_id", "name", "dept", "salary", "rank") \
    .orderBy("dept", "rank")

result.show()

Q10 β€” Top 3 salaries per department

Problem: Find the top 3 distinct salary levels per department.
If multiple employees share the 3rd highest salary, include all.
python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import dense_rank, col

w = Window.partitionBy("dept").orderBy(col("salary").desc())

result = employees \
    .withColumn("rnk", dense_rank().over(w)) \
    .filter(col("rnk") <= 3) \
    .select("dept", "name", "salary", "rnk") \
    .orderBy("dept", "rnk")

result.show()

# KEY INSIGHT: dense_rank β†’ tied salaries get same rank, all appear
# If you used rank() β†’ gaps: ranks 1,1,3 (skips 2)
# If you used row_number() β†’ only 3 rows max, misses ties

Q11 β€” Second highest salary

Problem: Find the second highest salary overall.
If no second highest exists, return null.
python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import dense_rank, col

w = Window.orderBy(col("salary").desc())

result = employees \
    .withColumn("rnk", dense_rank().over(w)) \
    .filter(col("rnk") == 2) \
    .select("salary") \
    .distinct()

# Handle case where no second salary exists
if result.count() == 0:
    result = spark.createDataFrame([(None,)], ["salary"])

result.show()

Q12 β€” Running total of revenue

Problem: Given revenue(date, amount), compute cumulative revenue
over time (running total from earliest date).
python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import sum, col

w = Window.orderBy("date").rowsBetween(Window.unboundedPreceding, Window.currentRow)

result = revenue \
    .withColumn("running_total", sum("amount").over(w)) \
    .select("date", "amount", "running_total") \
    .orderBy("date")

result.show()

Q13 β€” 7-day rolling average

Problem: Given daily_sales(date, sales), compute 7-day rolling
average (current day + 6 previous days).
python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import avg, col, round

w = Window.orderBy("date").rowsBetween(-6, Window.currentRow)  # 6 prior + current

result = daily_sales \
    .withColumn("rolling_7d_avg", round(avg("sales").over(w), 2)) \
    .select("date", "sales", "rolling_7d_avg") \
    .orderBy("date")

result.show()

Q14 β€” Employees earning more than their manager

Problem: Given employees(emp_id, name, salary, manager_id),
find all employees who earn more than their direct manager.
python β€” editable
from pyspark.sql.functions import col

emp = employees.alias("e")
mgr = employees.alias("m")

result = emp.join(mgr, col("e.manager_id") == col("m.emp_id"), "inner") \
    .filter(col("e.salary") > col("m.salary")) \
    .select(
        col("e.emp_id"),
        col("e.name").alias("employee"),
        col("e.salary").alias("emp_salary"),
        col("m.name").alias("manager"),
        col("m.salary").alias("mgr_salary")
    )

result.show()

Q15 β€” Customers with no orders

Problem: Given customers(customer_id, name) and orders(order_id, customer_id),
find all customers who have never placed an order.
python β€” editable
# Method 1: left_anti join (cleanest, most efficient)
result = customers.join(orders, "customer_id", "left_anti") \
    .select("customer_id", "name")

# Method 2: left join + filter null
result = customers.join(orders, "customer_id", "left") \
    .filter(col("order_id").isNull()) \
    .select(customers.customer_id, customers.name)

result.show()

# ⚑ Prefer left_anti β€” cleaner, Catalyst handles it well

Q16 β€” Deduplicate β€” keep latest record

Problem: Given events(event_id, user_id, event_type, created_at) with
duplicate rows (same user_id + event_type), keep only the latest.
python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import row_number, col

w = Window.partitionBy("user_id", "event_type").orderBy(col("created_at").desc())

result = events \
    .withColumn("rn", row_number().over(w)) \
    .filter(col("rn") == 1) \
    .drop("rn")

result.show()

# ⚑ Use row_number (not rank/dense_rank) β€” guarantees exactly 1 row per group

Q17 β€” Month-over-Month revenue change

Problem: Given monthly_revenue(year_month, revenue), compute MoM %
change: (current - previous) / previous Γ— 100
python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import lag, col, round

w = Window.orderBy("year_month")

result = monthly_revenue \
    .withColumn("prev_revenue", lag("revenue", 1).over(w)) \
    .withColumn(
        "mom_pct_change",
        round(
            (col("revenue") - col("prev_revenue")) / col("prev_revenue") * 100,
            2
        )
    ) \
    .select("year_month", "revenue", "prev_revenue", "mom_pct_change") \
    .orderBy("year_month")

result.show()

# ⚠️ First row: prev_revenue = null β†’ mom_pct_change = null (expected)
# ⚠️ If prev_revenue can be 0: wrap denominator in nullif logic manually

Q18 β€” Pivot: rows to columns (unpivot long β†’ wide)

Problem: Given sales(product, month, revenue) in long format,
pivot to wide format with each month as a column.
python β€” editable
from pyspark.sql.functions import sum

result = sales.groupBy("product").pivot("month").agg(sum("revenue"))
result.show()

# Output:
# product | Jan  | Feb  | Mar
# --------|------|------|-----
# iPhone  | 1000 | 1200 | 900

# Optimization: provide list of pivot values to avoid extra scan
months = ["Jan", "Feb", "Mar", "Apr", "May"]
result = sales.groupBy("product").pivot("month", months).agg(sum("revenue"))

Q19 β€” Explode array column + count tags

Problem: Given posts(post_id, user_id, tags) where tags is an array
like ["sports","tech","news"], find the top 5 most used tags.
python β€” editable
from pyspark.sql.functions import explode, col, count

result = (
    posts.withColumn("tag", explode(col("tags")))  # one row per tag
         .groupBy("tag")
         .agg(count("*").alias("usage_count"))
         .orderBy(col("usage_count").desc())
         .limit(5)
)

result.show()

SECTION 3: HARD QUESTIONS (H)

Q20 β€” Find consecutive purchase days

Problem: Given orders(order_id, customer_id, order_date),
find customers who placed orders on at least 3 consecutive days.
python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import lag, col, datediff, count
from pyspark.sql.functions import row_number

# Step 1: Deduplicate (1 row per customer per date)
deduped = orders.select("customer_id", "order_date").distinct()

# Step 2: Assign row number per customer ordered by date
w = Window.partitionBy("customer_id").orderBy("order_date")
numbered = deduped.withColumn("rn", row_number().over(w))

# Step 3: Island key = date - rn (constant for consecutive dates)
from pyspark.sql.functions import date_sub, expr
islands = numbered.withColumn(
    "island_key",
    expr("date_sub(order_date, rn)")   # Spark SQL: date - rn = island constant
)

# Step 4: Group by island, find streaks >= 3
result = islands.groupBy("customer_id", "island_key") \
    .agg(count("*").alias("streak_len")) \
    .filter(col("streak_len") >= 3) \
    .select("customer_id") \
    .distinct()

result.show()

Q21 β€” Session ID assignment (gap > 30 minutes = new session)

Problem: Given events(user_id, event_time), assign a session_id to
each event. A new session starts when gap > 30 minutes.
python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import lag, col, sum as _sum, unix_timestamp, when

w_order = Window.partitionBy("user_id").orderBy("event_time")

# Step 1: Flag new session start (gap > 30 min or first event)
flagged = events.withColumn(
    "prev_time", lag("event_time", 1).over(w_order)
).withColumn(
    "new_session",
    when(
        col("prev_time").isNull() |
        ((unix_timestamp("event_time") - unix_timestamp("prev_time")) > 1800),
        1
    ).otherwise(0)
)

# Step 2: Cumulative sum of new_session flags = session_id
w_cum = Window.partitionBy("user_id").orderBy("event_time") \
              .rowsBetween(Window.unboundedPreceding, Window.currentRow)

result = flagged.withColumn(
    "session_id",
    _sum("new_session").over(w_cum)
).select("user_id", "event_time", "session_id")

result.show()

Q22 β€” Days with temperature higher than previous day

Problem: Given weather(id, record_date, temperature), find all dates
where temperature was higher than the previous day.
python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import lag, col, datediff

w = Window.orderBy("record_date")

result = weather \
    .withColumn("prev_temp", lag("temperature", 1).over(w)) \
    .withColumn("prev_date", lag("record_date", 1).over(w)) \
    .filter(
        (col("temperature") > col("prev_temp")) &
        (datediff(col("record_date"), col("prev_date")) == 1)  # must be consecutive day
    ) \
    .select("id", "record_date", "temperature")

result.show()

# ⚠️ KEY: must check datediff == 1 β€” data may have gaps (missing dates)
#         Without this check, you'd compare non-adjacent days

Q23 β€” Longest streak of consecutive active days per user

Problem: Given logins(user_id, login_date), find the longest
consecutive daily login streak for each user.
python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import row_number, col, count, max as _max
from pyspark.sql.functions import expr

# Step 1: Deduplicate (1 row per user per date)
deduped = logins.select("user_id", "login_date").distinct()

# Step 2: Row number per user ordered by date
w = Window.partitionBy("user_id").orderBy("login_date")
numbered = deduped.withColumn("rn", row_number().over(w))

# Step 3: island_key = date - rn (constant for consecutive sequences)
islands = numbered.withColumn(
    "island_key",
    expr("date_sub(login_date, rn)")
)

# Step 4: Count streak length per island
streak_lengths = islands.groupBy("user_id", "island_key") \
    .agg(count("*").alias("streak_len"))

# Step 5: Max streak per user
result = streak_lengths.groupBy("user_id") \
    .agg(_max("streak_len").alias("longest_streak")) \
    .orderBy(col("longest_streak").desc())

result.show()

Q24 β€” Products bought together (market basket)

Problem: Given order_items(order_id, product_id), find all pairs of
products that appear in the same order. Show top 5 co-purchased pairs.
python β€” editable
from pyspark.sql.functions import col, count

o1 = order_items.alias("o1")
o2 = order_items.alias("o2")

result = o1.join(
    o2,
    (col("o1.order_id") == col("o2.order_id")) &
    (col("o1.product_id") < col("o2.product_id")),   # avoid duplicates + self-pairs
    "inner"
) \
.groupBy(col("o1.product_id").alias("product_a"),
         col("o2.product_id").alias("product_b")) \
.agg(count("*").alias("co_purchase_count")) \
.orderBy(col("co_purchase_count").desc()) \
.limit(5)

result.show()

# ⚑ KEY: o1.product_id < o2.product_id ensures each pair appears once
#         Without this: (A,B) and (B,A) both appear = duplicates

Q25 β€” Funnel conversion rates

Problem: Given events(user_id, event_type) where event_type is one of:
'view', 'add_to_cart', 'purchase'
Compute conversion rate at each funnel stage.
python β€” editable
from pyspark.sql.functions import countDistinct, col, round, when, max as _max

# Method: MAX(CASE WHEN) per user β€” count users, not events
funnel = events.groupBy("user_id").agg(
    _max(when(col("event_type") == "view",         1).otherwise(0)).alias("viewed"),
    _max(when(col("event_type") == "add_to_cart",  1).otherwise(0)).alias("carted"),
    _max(when(col("event_type") == "purchase",     1).otherwise(0)).alias("purchased")
)

from pyspark.sql.functions import sum as _sum, lit

summary = funnel.agg(
    _sum("viewed").alias("total_views"),
    _sum("carted").alias("total_carted"),
    _sum("purchased").alias("total_purchased")
)

# Compute conversion rates
total_views  = summary.collect()[0]["total_views"]
total_carted = summary.collect()[0]["total_carted"]
total_purch  = summary.collect()[0]["total_purchased"]

print(f"View β†’ Cart:     {total_carted/total_views*100:.1f}%")
print(f"Cart β†’ Purchase: {total_purch/total_carted*100:.1f}%")
print(f"Overall:         {total_purch/total_views*100:.1f}%")

# ⚑ KEY: MAX(CASE WHEN) per user ensures each user counted once per stage
#         Even if user viewed 10 times, they count as 1 viewer

Q26 β€” Dedup with composite key (keep most recent per key combo)

Problem: Given cdc_events(id, name, dept, salary, updated_at) representing
CDC stream with multiple updates per employee, keep the latest
record per (id, dept) combination.
python β€” editable
from pyspark.sql.window import Window
from pyspark.sql.functions import row_number, col

w = Window.partitionBy("id", "dept").orderBy(col("updated_at").desc())

result = cdc_events \
    .withColumn("rn", row_number().over(w)) \
    .filter(col("rn") == 1) \
    .drop("rn")

result.show()

# ⚑ row_number guarantees exactly 1 row per partition (unlike rank/dense_rank)
# Always use row_number for deduplication, not rank

SECTION 4: SCENARIO QUESTIONS (S)

Q27 β€” Handle data skew with salting

Problem: You have orders(order_id, country_code, amount) and a countries
lookup table. 80% of orders are from "US". The join on country_code
is very slow. How do you fix it?
python β€” editable
from pyspark.sql.functions import rand, concat, lit, col, explode, array

SALT_FACTOR = 10

# APPROACH 1: Broadcast (if countries table is small β€” BEST approach)
from pyspark.sql.functions import broadcast
result = orders.join(broadcast(countries), "country_code")

# APPROACH 2: Salting (when both tables are large and skewed)

# Step 1: Salt the large orders table
orders_salted = orders.withColumn(
    "salted_key",
    concat(col("country_code"), lit("_"),
           (rand() * SALT_FACTOR).cast("int").cast("string"))
)

# Step 2: Explode countries to match all salt values (0 to SALT_FACTOR-1)
countries_exploded = countries.withColumn(
    "salt", explode(array([lit(i) for i in range(SALT_FACTOR)]))
).withColumn(
    "salted_key",
    concat(col("country_code"), lit("_"), col("salt").cast("string"))
)

# Step 3: Join on salted key
result = orders_salted.join(countries_exploded, "salted_key", "inner") \
    .drop("salted_key", "salt")

result.show()

# APPROACH 3: Enable AQE (automatic, no code change)
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
# AQE detects hot partitions at runtime and splits them

Q28 β€” Optimize slow join with broadcast hint

Problem: An ETL job joining transactions(100M rows) with
store_metadata(500 rows) is taking 2 hours due to Sort-Merge Join.
How do you fix it?
python β€” editable
from pyspark.sql.functions import broadcast

# Problem: Catalyst doesn't auto-broadcast (store_metadata stats not computed)
# Default autoBroadcastJoinThreshold = 10 MB

# SOLUTION 1: Manual broadcast hint (works immediately)
result = transactions.join(
    broadcast(store_metadata),  # forces BroadcastHashJoin, no shuffle on store_metadata
    "store_id"
)

# SOLUTION 2: Increase threshold (if you want auto-broadcast for similar tables)
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "52428800")  # 50 MB

# SOLUTION 3: Cache small table + broadcast
store_metadata.cache()
store_metadata.count()  # trigger cache
result = transactions.join(broadcast(store_metadata), "store_id")

# VERIFICATION: Check join type in plan
result.explain("formatted")
# Look for: BroadcastHashJoin (not SortMergeJoin)

# IMPACT: Sort-Merge Join = 2 shuffles + 2 sorts + merge per 100M rows
#         Broadcast Hash Join = 0 shuffles + hash lookup per 100M rows
#         Speedup: often 10-50x for large fact + small dim joins

Q29 β€” Read multiple sources + track file origin

Problem: You have daily Parquet files in S3 for Jan, Feb, Mar 2024.
Read them all, add a "source_file" column, and combine.
Some files have extra columns not present in others.
python β€” editable
from pyspark.sql.functions import input_file_name, col, lit

# APPROACH 1: Glob pattern (all files match same schema)
df = spark.read.parquet("s3://bucket/data/year=2024/month=*/") \
    .withColumn("source_file", input_file_name())

# APPROACH 2: Read separately + unionByName (different schemas)
jan = spark.read.parquet("s3://bucket/data/year=2024/month=01/") \
    .withColumn("source_month", lit("Jan"))
feb = spark.read.parquet("s3://bucket/data/year=2024/month=02/") \
    .withColumn("source_month", lit("Feb"))
mar = spark.read.parquet("s3://bucket/data/year=2024/month=03/") \
    .withColumn("source_month", lit("Mar"))

# allowMissingColumns=True fills missing cols with null instead of failing
combined = jan.unionByName(feb, allowMissingColumns=True) \
              .unionByName(mar, allowMissingColumns=True)

combined.show()

# APPROACH 3: Python list of paths
paths = [
    "s3://bucket/2024-01/",
    "s3://bucket/2024-02/",
    "s3://bucket/2024-03/"
]
df = spark.read.parquet(*paths).withColumn("source_file", input_file_name())

Q30 β€” Flatten nested JSON to flat table

Problem: You receive JSON with nested structure:
{
"order_id": 1,
"customer": {"id": 101, "name": "Alice", "city": "NYC"},
"items": [
{"sku": "A1", "qty": 2, "price": 10.0},
{"sku": "B2", "qty": 1, "price": 25.0}
]
}
Flatten to: order_id, customer_id, customer_name, city, sku, qty, price
python β€” editable
from pyspark.sql.functions import col, explode_outer

# Read raw JSON (schema inferred or explicit)
raw = spark.read.option("multiLine", True).json("orders/*.json")

# raw schema:
# order_id: long
# customer: struct<id: long, name: string, city: string>
# items: array<struct<sku: string, qty: int, price: double>>

# Step 1: Explode items array (one row per item)
exploded = raw.withColumn("item", explode_outer("items"))  # outer = keep null items

# Step 2: Select and flatten nested fields with dot notation
result = exploded.select(
    col("order_id"),
    col("customer.id").alias("customer_id"),
    col("customer.name").alias("customer_name"),
    col("customer.city").alias("city"),
    col("item.sku").alias("sku"),
    col("item.qty").alias("qty"),
    col("item.price").alias("price")
)

result.show()

# ⚑ explode_outer vs explode:
#   explode β†’ drops orders with null/empty items array
#   explode_outer β†’ keeps the order row with null item fields (safer)

SECTION 5: LARGE DATA + MEMORY + INFRASTRUCTURE SCENARIOS

Q31 β€” Process 200 GB data with only 16 GB executor memory

Problem: You have 200 GB of data to process but each executor has only
16 GB of memory. The job keeps crashing with OOM errors.
Walk through how you would handle this end to end.
ROOT CAUSE ANALYSIS
The mistake is trying to hold too much data in memory at once.
Spark does NOT need to load 200 GB into RAM simultaneously.
Spark processes data in PARTITIONS β€” one partition per task per core.
Goal: keep each partition small enough to fit in one executor core's memory share.
python β€” editable
# ── STEP 1: ESTIMATE RIGHT PARTITION COUNT ──────────────────────────────────
# Rule: each partition should be ~100-200 MB in memory
# 200 GB data β†’ 200,000 MB / 128 MB per partition = ~1,600 partitions

data_size_gb = 200
target_partition_mb = 128
n_partitions = int((data_size_gb * 1024) / target_partition_mb)  # = 1600

spark.conf.set("spark.sql.shuffle.partitions", str(n_partitions))

# ── STEP 2: TUNE EXECUTOR MEMORY ─────────────────────────────────────────────
# With 16 GB executor, 4 cores β†’ each core gets ~4 GB working memory
# Memory layout:
#   spark.executor.memory = 14g  (leave 2g for OS + overhead)
#   spark.executor.memoryOverhead = 2g  (native/Python overhead)
#   spark.memory.fraction = 0.6  β†’ 8.4g for Spark execution + storage
#   Each core β†’ ~2g execution memory for shuffle/joins

# ── STEP 3: AVOID CACHING 200 GB ──────────────────────────────────────────────
# DO NOT: df.cache()  ← 200 GB won't fit, causes eviction thrashing
# DO: Process in a streaming fashion (pipeline partitions through)

# ── STEP 4: FILTER EARLY β€” reduce data before joins ──────────────────────────
df = (
    spark.read.parquet("s3://bucket/data/")
         .filter(col("date") >= "2024-01-01")  # push filter to Parquet reader
         .filter(col("region") == "US")  # reduce data ASAP
         .select("id", "amount", "date")  # column pruning
)

# ── STEP 5: AVOID WIDE OPERATIONS ON FULL 200 GB ──────────────────────────────
# Bad: join 200 GB table with another 200 GB table (400 GB shuffle)
# Good: pre-aggregate one side first, then join reduced result
pre_agg = large_df.groupBy("customer_id") \
    .agg(sum("amount").alias("total"))   # 200 GB β†’ maybe 10 GB after aggregation
result = pre_agg.join(customers, "customer_id")  # join 10 GB, not 200 GB

# ── STEP 6: ENABLE AQE ───────────────────────────────────────────────────────
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
# AQE will auto-split/merge partitions based on actual sizes

# ── STEP 7: WRITE IN PARTITIONS, NOT COLLECT ─────────────────────────────────
# NEVER: result.collect()  ← pulls 200 GB to Driver β†’ Driver OOM
# DO: result.write.parquet("s3://output/")  ← each executor writes its partitions

# ── STEP 8: USE DISK SPILL (not OOM) ─────────────────────────────────────────
# If a shuffle really must exceed memory, configure spill to disk:
spark.conf.set("spark.executor.memory", "14g")
spark.conf.set("spark.memory.fraction", "0.6")
# Execution pool will spill to disk automatically (slowdown vs OOM)

# ── FULL CONFIG FOR THIS SCENARIO ────────────────────────────────────────────
spark = (
    SparkSession.builder
        .config("spark.executor.memory", "14g")
        .config("spark.executor.cores", "4")
        .config("spark.executor.memoryOverhead", "2g")
        .config("spark.sql.shuffle.partitions", "1600")
        .config("spark.sql.adaptive.enabled", "true")
        .config("spark.sql.adaptive.coalescePartitions.enabled", "true")
        # 128 MB advisory shuffle and input partition sizes
        .config("spark.sql.adaptive.advisoryPartitionSizeInBytes", "134217728")
        .config("spark.sql.files.maxPartitionBytes", "134217728")
        .getOrCreate()
)
INTERVIEW ANSWER FRAMEWORK
1. "Spark doesn't load 200 GB at once β€” it processes in partitions"
2. "Target 100-200 MB per partition→~1600 partitions for 200 GB"
3. "Filter + column prune early to reduce data volume"
4. "Pre-aggregate before joins to reduce shuffle size"
5. "Enable AQE for runtime adaptation"
6. "Write to disk, never collect() large results"
7. "Configure disk spill as safety net (not OOM, just slower)"

Q32 β€” Reading bad/corrupt data β€” 3 modes + handling

Problem: You are reading a CSV file from an external vendor. Some rows
have corrupt data (wrong column count, bad types, malformed JSON).
How do you handle bad records without failing the whole job?
python β€” editable
# ── THE 3 READ MODES ─────────────────────────────────────────────────────────
#
# PERMISSIVE  (default): Parse what you can, set corrupt record to null
#                        Adds _corrupt_record column with the bad raw line
# DROPMALFORMED:         Silently drop bad rows, keep good ones
# FAILFAST:             Throw exception on first bad record (fail the job)

# ── MODE 1: PERMISSIVE β€” capture bad rows for inspection ─────────────────────
from pyspark.sql.types import StructType, StructField, IntegerType, StringType, DoubleType

schema = StructType([
    StructField("id",     IntegerType(),  nullable=True),
    StructField("name",   StringType(),   nullable=True),
    StructField("amount", DoubleType(),   nullable=True),
    StructField("_corrupt_record", StringType(), nullable=True)  # captures bad rows
])

df = spark.read \
    .schema(schema) \
    .option("mode", "PERMISSIVE") \
    .option("columnNameOfCorruptRecord", "_corrupt_record") \
    .option("header", True) \
    .csv("data/vendor_feed.csv")

# Separate good and bad records
good_records = df.filter(col("_corrupt_record").isNull()).drop("_corrupt_record")
bad_records  = df.filter(col("_corrupt_record").isNotNull())

print(f"Good rows: {good_records.count()}")
print(f"Bad rows:  {bad_records.count()}")

# Save bad records for investigation / reprocessing
bad_records.write.mode("overwrite").json("s3://bucket/bad-records/")

# ── MODE 2: DROPMALFORMED β€” silently drop bad rows ────────────────────────────
df_clean = spark.read \
    .schema(schema) \
    .option("mode", "DROPMALFORMED") \
    .option("header", True) \
    .csv("data/vendor_feed.csv")
# Good for: non-critical data where a few bad rows are acceptable

# ── MODE 3: FAILFAST β€” crash on any bad row ───────────────────────────────────
df_strict = spark.read \
    .schema(schema) \
    .option("mode", "FAILFAST") \
    .option("header", True) \
    .csv("data/vendor_feed.csv")
# Good for: financial/compliance data where ANY bad row = stop everything

# ── BADRECORDSPATH β€” Spark 2.2+ auto-save bad records ────────────────────────
df = spark.read \
    .schema(schema) \
    .option("badRecordsPath", "s3://bucket/bad-records/") \
    .option("header", True) \
    .csv("data/vendor_feed.csv")
# Spark automatically writes all bad records + reasons to badRecordsPath
# Each bad record file: { "path": "...", "reason": "...", "record": "..." }

# ── JSON-SPECIFIC: multiline + corrupt handling ───────────────────────────────
df_json = (
    spark.read
         .option("mode", "PERMISSIVE")
         .option("columnNameOfCorruptRecord", "_corrupt_record")
         .option("multiLine", True)  # JSON arrays/objects spanning multiple lines
         .json("data/*.json")
)

# ── PARQUET corrupt file handling ─────────────────────────────────────────────
spark.conf.set("spark.sql.files.ignoreCorruptFiles", "true")   # skip corrupt files
spark.conf.set("spark.sql.files.ignoreMissingFiles", "true")   # skip missing files
df_parquet = spark.read.parquet("s3://bucket/data/")
# Great for: reading historical S3 data where some files may be deleted/corrupt

# ── NULL HANDLING AFTER READ ──────────────────────────────────────────────────
# After PERMISSIVE read: types that failed cast β†’ null
df_clean = (
    df.filter(col("id").isNotNull())  # drop rows where id failed to parse
      .na.fill({"amount": 0.0, "name": "UNKNOWN"})  # fill other nulls
)
🧠 PERMISSIVE β†’ default, investigate bad data, save separately
MODE DECISION
PERMISSIVEdefault, investigate bad data, save separately
DROPMALFORMEDdata quality issues are expected, non-critical pipeline
FAILFASTfinancial/audit pipelines where bad data β†’ stop everything
badRecordsPath→production pipelines (auto-quarantine bad records)
ignoreCorruptFiles→reading from S3/HDFS where files may disappear

Q33 β€” Recursively read all files from a directory tree

Problem: You have files organized like:
/data/2024/01/01/part-001.parquet
/data/2024/01/02/part-001.parquet
/data/2024/02/01/part-001.parquet
How do you read ALL files recursively in one DataFrame?
python β€” editable
# ── METHOD 1: recursiveFileLookup (Spark 3.0+) ────────────────────────────────
# Reads ALL files recursively under the root path β€” ignores partition structure
df = spark.read \
    .option("recursiveFileLookup", "true") \
    .parquet("s3://bucket/data/")
# Reads every .parquet file in the entire /data/ tree recursively

# ── METHOD 2: Glob patterns (most flexible) ───────────────────────────────────
# All files 2 levels deep:
df = spark.read.parquet("s3://bucket/data/*/*/")

# All files any depth (Hadoop glob):
df = spark.read.parquet("s3://bucket/data/")  # Spark auto-recurses for Parquet dirs

# Specific year range:
df = spark.read.parquet("s3://bucket/data/2024/*/")

# Multiple specific months:
df = spark.read.parquet(
    "s3://bucket/data/2024/01/",
    "s3://bucket/data/2024/02/",
    "s3://bucket/data/2024/03/"
)

# ── METHOD 3: pathGlobFilter β€” read only specific file types ──────────────────
# In a mixed directory (CSVs + Parquets + JSONs), read only CSVs:
df = spark.read \
    .option("pathGlobFilter", "*.csv") \
    .option("recursiveFileLookup", "true") \
    .csv("s3://bucket/mixed-data/")

# Read only files matching a date pattern:
df = spark.read \
    .option("pathGlobFilter", "2024-01-*.parquet") \
    .parquet("s3://bucket/data/")

# ── METHOD 4: modifiedAfter / modifiedBefore β€” time-based filtering ───────────
# Read only files modified in the last 24 hours (Spark 3.4+):
df = spark.read \
    .option("modifiedAfter", "2024-01-01T00:00:00") \
    .option("modifiedBefore", "2024-01-02T00:00:00") \
    .option("recursiveFileLookup", "true") \
    .parquet("s3://bucket/data/")

# ── METHOD 5: Python glob β†’ collect paths β†’ pass to Spark ────────────────────
import boto3, os

# For S3: list all parquet files recursively
s3 = boto3.client("s3")
bucket, prefix = "my-bucket", "data/2024/"
all_paths = [
    f"s3://{bucket}/{obj['Key']}"
    for obj in s3.list_objects_v2(Bucket=bucket, Prefix=prefix)["Contents"]
    if obj["Key"].endswith(".parquet")
]

df = spark.read.parquet(*all_paths)

# For local filesystem:
import glob
all_paths = glob.glob("/data/**/*.parquet", recursive=True)  # recursive=True is key!
df = spark.read.parquet(*all_paths)

# ── ADD SOURCE FILE TRACKING ──────────────────────────────────────────────────
from pyspark.sql.functions import input_file_name

df = spark.read \
    .option("recursiveFileLookup", "true") \
    .parquet("s3://bucket/data/") \
    .withColumn("source_file", input_file_name())

df.show(truncate=False)
# Each row knows exactly which file it came from

# ── VALIDATE WHAT WAS READ ────────────────────────────────────────────────────
df.select("source_file").distinct().show(100, truncate=False)
# Shows all unique file paths that contributed to the DataFrame
🧠 Memory Map
DECISION TABLE
Scenario→Method
────────────────────────────────────────────────────────────────────
All files under root (same schema) β†’ recursiveFileLookup=true
All files matching date pattern→glob: "path/2024/*/"
Only .csv files in mixed directory→pathGlobFilter="*.csv"
Files from last N hours only→modifiedAfter option
Know exact file paths (dynamic list) β†’ spark.read.parquet(*paths)
Track which file each row came from→input_file_name()

Q34 β€” OOM during groupBy on 500M rows

Problem: groupBy("country").agg(collect_list("event")) on 500M rows
causes executor OOM. How do you fix it?
python β€” editable
# ── DIAGNOSE THE PROBLEM ──────────────────────────────────────────────────────
# collect_list() = collects ALL values per key into memory on ONE executor
# If "US" has 400M events β†’ collect_list creates 400M element list in 1 executor
# That's the OOM culprit, not groupBy itself

# ── SOLUTION 1: Don't use collect_list β€” aggregate instead ────────────────────
# Bad (OOM):
df.groupBy("country").agg(collect_list("event"))

# Good (if you need count/sum/stats, not the full list):
from pyspark.sql.functions import count, countDistinct, sum
df.groupBy("country").agg(
    count("event").alias("event_count"),
    countDistinct("event").alias("unique_events")
)

# ── SOLUTION 2: Use collect_set with limit ────────────────────────────────────
from pyspark.sql.functions import slice, collect_list, col

# Sample first N values per key (avoid unbounded collection)
from pyspark.sql.window import Window
from pyspark.sql.functions import row_number

w = Window.partitionBy("country").orderBy("event_time")
df_limited = df.withColumn("rn", row_number().over(w)) \
               .filter(col("rn") <= 1000)   # max 1000 events per country

result = df_limited.groupBy("country").agg(collect_list("event").alias("top_events"))

# ── SOLUTION 3: Increase shuffle partitions to reduce per-partition size ───────
spark.conf.set("spark.sql.shuffle.partitions", "2000")  # more, smaller partitions
spark.conf.set("spark.executor.memory", "16g")
# More partitions = less data per task = less memory per task

# ── SOLUTION 4: Pre-filter to reduce data volume ─────────────────────────────
# Before the groupBy, eliminate data you don't need
df_filtered = df.filter(col("date") >= "2024-01-01") \
                .filter(col("is_valid") == True) \
                .select("country", "event", "event_time")   # column pruning
result = df_filtered.groupBy("country").agg(count("event"))

# ── SOLUTION 5: Use aggregateByKey (RDD) for full control ─────────────────────
# When DataFrame API can't express what you need
rdd = df.select("country", "event").rdd.map(lambda r: (r.country, r.event))

# Pre-aggregate locally: build set of unique events per partition
from pyspark.sql.functions import col

result_rdd = rdd.aggregateByKey(
    set(),                                           # initial accumulator = empty set
    lambda acc, val: acc | {val},                    # within partition: union set
    lambda acc1, acc2: acc1 | acc2                   # cross partition: union sets
)
result = spark.createDataFrame(
    result_rdd.map(lambda x: (x[0], list(x[1]))),
    ["country", "unique_events"]
)

# ── SOLUTION 6: Enable AQE skew handling ──────────────────────────────────────
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "3")
# AQE splits the "US" partition into smaller sub-partitions automatically

Q35 β€” Job works on 10 GB weekdays, fails on 500 GB Mondays

Problem: Daily ETL runs fine Mon-Fri on ~10 GB. Every Monday after the
weekend accumulation it processes ~500 GB and crashes.
How do you design this to handle both cases?
python β€” editable
# ── ROOT CAUSE: Static configs tuned for 10 GB, fail at 500 GB ─────────────────

# ── SOLUTION 1: Enable AQE (auto-adapts at runtime) ───────────────────────────
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
# AQE merges tiny partitions on small days, splits large ones on big days
# shuffle.partitions can be set to 2000 and AQE will coalesce on small days

# ── SOLUTION 2: Dynamic Allocation (scale executors to data) ───────────────────
spark.conf.set("spark.dynamicAllocation.enabled", "true")
spark.conf.set("spark.dynamicAllocation.minExecutors", "5")
spark.conf.set("spark.dynamicAllocation.maxExecutors", "200")   # scale up for Monday
spark.conf.set("spark.shuffle.service.enabled", "true")
# On 10 GB day: ~5 executors. On 500 GB Monday: auto-scales to 100+ executors

# ── SOLUTION 3: Data-driven shuffle partition calculation ─────────────────────
# Before running the main job, estimate data size and set partitions
def estimate_partitions(spark, path, target_mb=128):
    """Estimate optimal partition count from input data size."""
    file_sizes = spark.sparkContext._jvm.org.apache.hadoop.fs \
        .FileSystem.get(spark.sparkContext._jvm.java.net.URI.create(path),
                        spark.sparkContext._jsc.hadoopConfiguration()) \
        .getContentSummary(spark.sparkContext._jvm.org.apache.hadoop.fs.Path(path)) \
        .getLength()
    size_mb = file_sizes / (1024 * 1024)
    partitions = max(200, int(size_mb / target_mb))
    return partitions

n_parts = estimate_partitions(spark, "s3://bucket/data/")
spark.conf.set("spark.sql.shuffle.partitions", str(n_parts))

# ── SOLUTION 4: Add checkpoint mid-job (survive retries on large runs) ─────────
spark.sparkContext.setCheckpointDir("s3://bucket/checkpoints/")

df = spark.read.parquet("s3://bucket/data/")

# After expensive operation: checkpoint to cut lineage + save progress
after_join = df.join(reference, "id").filter(...)
after_join.cache()
after_join.checkpoint()  # writes to S3 β€” if job fails, restart from here
after_join.unpersist()

# ── SOLUTION 5: Partition the input table by date for pruning ─────────────────
# Write data partitioned by date so Monday read only reads weekend partition
df.write \
    .partitionBy("year", "month", "day") \
    .mode("append") \
    .parquet("s3://bucket/data-partitioned/")

# Monday read: only reads Sat+Sun partitions (not entire history)
df_weekend = spark.read.parquet("s3://bucket/data-partitioned/") \
    .filter(col("year") == 2024) \
    .filter(col("month") == 1) \
    .filter(col("day").isin(6, 7))   # Saturday + Sunday only

# ── COMPLETE ROBUST ETL PATTERN ───────────────────────────────────────────────
from datetime import datetime, timedelta

def run_etl(spark, date_str):
    spark.conf.set("spark.sql.adaptive.enabled", "true")
    spark.conf.set("spark.dynamicAllocation.enabled", "true")
    spark.conf.set("spark.dynamicAllocation.maxExecutors", "200")
    spark.conf.set("spark.sql.shuffle.partitions", "2000")  # AQE coalesces down

    df = spark.read.parquet(f"s3://bucket/data/date={date_str}/") \
        .filter(col("is_valid") == True) \
        .select("id", "amount", "category")

    result = df.groupBy("category") \
               .agg(sum("amount").alias("total"))

    result.write.mode("overwrite") \
          .parquet(f"s3://bucket/output/date={date_str}/")

Q36 β€” Schema evolution β€” new column added to source

Problem: Your daily Parquet data source added a new column "discount_pct"
starting 2024-03-01. Older files don't have it. Reading all
historical data fails with schema mismatch. How to handle?
python β€” editable
# ── PROBLEM: Reading old + new files fails with schema mismatch ───────────────
# old files: (id, amount, category)
# new files: (id, amount, category, discount_pct)   ← new column
# spark.read.parquet("all/") β†’ AnalysisException: schema mismatch

# ── SOLUTION 1: mergeSchema option (Parquet + Delta native) ───────────────────
df = (
    spark.read
         .option("mergeSchema", "true")  # union schemas; missing columns become null
         .parquet("s3://bucket/data/")
)
# Old file rows: discount_pct = null
# New file rows: discount_pct = actual value

# ── SOLUTION 2: unionByName with allowMissingColumns ──────────────────────────
old_df = spark.read.parquet("s3://bucket/data/before-2024-03-01/")
new_df = spark.read.parquet("s3://bucket/data/from-2024-03-01/")

combined = old_df.unionByName(new_df, allowMissingColumns=True)
# old_df rows: discount_pct = null (added automatically)
# new_df rows: discount_pct = actual value

# ── SOLUTION 3: Define explicit schema that includes all columns ───────────────
from pyspark.sql.types import StructType, StructField, LongType, DoubleType, StringType

full_schema = StructType([
    StructField("id",            LongType(),   nullable=True),
    StructField("amount",        DoubleType(), nullable=True),
    StructField("category",      StringType(), nullable=True),
    StructField("discount_pct",  DoubleType(), nullable=True)  # new col, nullable
])

# Read old files with explicit schema β†’ missing columns filled with null
old_df = spark.read.schema(full_schema).parquet("s3://bucket/data/before-2024-03-01/")
# No error β€” schema is applied, discount_pct = null for all old rows

# ── SOLUTION 4: Delta Lake (best for production schema evolution) ─────────────
# When writing:
(
    new_df.write
          .format("delta")
          .option("mergeSchema", "true")  # allow adding new columns
          .mode("append")
          .save("s3://bucket/delta-table/")
)

# Or set globally:
spark.conf.set("spark.databricks.delta.schema.autoMerge.enabled", "true")

# ── HANDLE NULL in new column after merge ─────────────────────────────────────
from pyspark.sql.functions import coalesce, lit

combined = combined.withColumn(
    "discount_pct",
    coalesce(col("discount_pct"), lit(0.0))   # treat missing as 0% discount
)

Q37 β€” How to estimate the right number of partitions for a job

Problem: You're given a 500 GB dataset to aggregate. How do you decide
how many partitions to use? Walk through the math.
python β€” editable
# ── THE FORMULA ───────────────────────────────────────────────────────────────
# Target partition size: 100-200 MB per partition (sweet spot)
# Too small: task scheduling overhead + small file problem
# Too large: OOM risk, low parallelism

# FORMULA: n_partitions = ceil(data_size_MB / target_partition_MB)
# For 500 GB: 500,000 MB / 128 MB = ~3,900 partitions β†’ set to 4000

data_size_gb    = 500
target_part_mb  = 128
n_partitions    = int((data_size_gb * 1024) / target_part_mb)   # 4000

spark.conf.set("spark.sql.shuffle.partitions", str(n_partitions))

# ── ALSO: SET INPUT PARTITION SIZE ────────────────────────────────────────────
# Controls how Spark splits input files into tasks
spark.conf.set("spark.sql.files.maxPartitionBytes", str(128 * 1024 * 1024))  # 128 MB

# ── CHECK CURRENT PARTITION COUNT ─────────────────────────────────────────────
df = spark.read.parquet("s3://bucket/500 gb-data/")
print(f"Input partitions: {df.rdd.getNumPartitions()}")
# Should be ~3900-4000 for 500 GB at 128 MB per partition

# ── VERIFY PARTITION BALANCE ──────────────────────────────────────────────────
from pyspark.sql.functions import spark_partition_id, count

(
    df.withColumn("pid", spark_partition_id())
      .groupBy("pid")
      .agg(count("*").alias("rows_in_partition"))
      .describe("rows_in_partition")  # check mean, stddev, min, max
      .show()
)
# Healthy: stddev/mean < 0.3 (relatively even)
# Skewed:  max >> mean (one partition has many more rows)

# ── CORES vs PARTITIONS RELATIONSHIP ─────────────────────────────────────────
# Rule: partitions should be a MULTIPLE of total cores
# 50 executors Γ— 4 cores = 200 total cores
# Good partition counts: 200, 400, 800, 1600, 4000 (multiples of 200)
# This ensures all cores stay busy (no idle cores waiting)

total_cores = 50 * 4   # 200
# Round partition count to nearest multiple of total_cores
import math
n_partitions_adjusted = math.ceil(n_partitions / total_cores) * total_cores  # 4000
spark.conf.set("spark.sql.shuffle.partitions", str(n_partitions_adjusted))

# ── LET AQE DO FINAL TUNING ───────────────────────────────────────────────────
# Set partitions to a safe HIGH number β†’ AQE coalesces down to right size
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "134217728")  # 128 MB
spark.conf.set("spark.sql.shuffle.partitions", "8000")  # AQE will reduce this

# INTERVIEW SUMMARY:
# 1. Formula: data_MB / 128 MB = partition count
# 2. Round to multiple of total executor cores
# 3. Set input maxPartitionBytes = same target (128 MB)
# 4. Enable AQE to auto-tune at runtime
# 5. Verify with describe() on partition sizes

Q38 β€” Avoid recomputing an expensive DataFrame 3 times

Problem: You build a DataFrame with an expensive join + aggregation.
You then use it for: (1) write to Parquet, (2) send to a report,
(3) compute anomalies. How to avoid computing it 3 times?
python β€” editable
# ── PROBLEM: Without caching ─────────────────────────────────────────────────
expensive_df = (
    raw.join(reference, "id")  # expensive
       .groupBy("region")
       .agg(sum("amount").alias("total"))  # expensive aggregation
)

# Each action below triggers a FULL recomputation of expensive_df:
expensive_df.write.parquet("s3://output/")                # compute #1
expensive_df.filter(col("total") > 1e6).show()            # compute #2
anomalies = expensive_df.filter(col("total") > avg_total) # compute #3

# ── SOLUTION: Cache before first use ──────────────────────────────────────────
expensive_df = raw.join(reference, "id") \
                  .groupBy("region") \
                  .agg(sum("amount").alias("total"))

# Cache (materialize into executor memory/disk)
expensive_df.cache()
expensive_df.count()   # TRIGGER the cache β€” forces computation NOW, stores result

# Now all 3 uses hit the cache, not recompute:
expensive_df.write.parquet("s3://output/")                # reads from cache
expensive_df.filter(col("total") > 1e6).show()            # reads from cache
anomalies = expensive_df.filter(col("total") > 1000000)   # reads from cache

# Release cache when done
expensive_df.unpersist()

# ── CHOOSE STORAGE LEVEL ──────────────────────────────────────────────────────
from pyspark.storagelevel import StorageLevel

# If expensive_df fits in memory:
expensive_df.persist(StorageLevel.MEMORY_ONLY)        # fastest reads

# If it's large or memory is limited:
expensive_df.persist(StorageLevel.MEMORY_AND_DISK)    # spills to disk if needed

# If you want to be safe on a shared cluster:
expensive_df.persist(StorageLevel.DISK_ONLY)          # always on disk (slowest)

# ── ALTERNATIVE: CHECKPOINT (if lineage is long) ──────────────────────────────
spark.sparkContext.setCheckpointDir("s3://bucket/checkpoints/")

expensive_df.cache()
expensive_df.count()       # trigger cache
expensive_df.checkpoint()  # write to S3, cut lineage (extra safety)
expensive_df.unpersist()   # release cache (checkpoint is the source now)

# Now: expensive_df reads from S3 checkpoint on reuse
# Advantage: survives executor failures (no recompute from original data)

# ── WRITE ONCE, READ MULTIPLE TIMES PATTERN (most reliable) ────────────────────
# For production: materialize to Parquet, read it back multiple times
expensive_df.write.mode("overwrite").parquet("s3://tmp/expensive-result/")
materialized = spark.read.parquet("s3://tmp/expensive-result/")

materialized.write.parquet("s3://output/")
materialized.filter(col("total") > 1e6).show()
anomalies = materialized.filter(col("total") > 1000000)
# Trade-off: extra write cost β†’ but most resilient, works across sessions

Q39 β€” Read only specific file types from a mixed directory

Problem: A directory contains .csv, .parquet, .json and .tmp files mixed.
Read ONLY the .csv files recursively without reading the others.
python β€” editable
# ── METHOD 1: pathGlobFilter (Spark 3.0+) ─────────────────────────────────────
df = (
    spark.read
         .option("pathGlobFilter", "*.csv")  # only .csv files
         .option("recursiveFileLookup", "true")
         .option("header", "true")
         .csv("s3://bucket/mixed-directory/")
)

# Multiple extensions (Hadoop glob syntax):
df = (
    spark.read
         .option("pathGlobFilter", "*.{csv,tsv}")  # csv OR tsv
         .option("recursiveFileLookup", "true")
         .csv("s3://bucket/mixed-directory/")
)

# ── METHOD 2: Glob pattern in path ────────────────────────────────────────────
# All csv files any depth:
df = spark.read.csv("s3://bucket/mixed-directory/**/*.csv",
                    header=True)

# All csvs matching date pattern:
df = spark.read.csv("s3://bucket/data/2024-01-*.csv", header=True)

# ── METHOD 3: Manually list + filter using boto3 (S3) ────────────────────────
import boto3

s3 = boto3.client("s3")
bucket, prefix = "my-bucket", "mixed-directory/"

response = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
csv_paths = [
    f"s3://{bucket}/{obj['Key']}"
    for obj in response.get("Contents", [])
    if obj["Key"].endswith(".csv") and not obj["Key"].endswith(".tmp")
]

print(f"Found {len(csv_paths)} CSV files")
df = spark.read.option("header", True).csv(*csv_paths)

# ── METHOD 4: Exclude patterns using Python filter ────────────────────────────
import glob, os

all_files = glob.glob("/data/**/*", recursive=True)
# Keep only .parquet files, exclude hidden files and .tmp
parquet_files = [
    f for f in all_files
    if f.endswith(".parquet")
    and not os.path.basename(f).startswith("_")   # exclude _SUCCESS, _metadata
    and not os.path.basename(f).startswith(".")    # exclude hidden files
]

df = spark.read.parquet(*parquet_files)

# ── VERIFY WHAT WAS READ ──────────────────────────────────────────────────────
from pyspark.sql.functions import input_file_name
df_with_source = df.withColumn("src", input_file_name())
df_with_source.select("src").distinct().show(20, truncate=False)

Q40 β€” Handle late-arriving data in daily ETL

Problem: Your daily ETL runs at midnight. Occasionally, events from
the previous day arrive 2-3 hours late (after midnight).
These late records are missed by the daily partition load.
How do you handle this in a batch ETL pipeline?
python β€” editable
# ── STRATEGY 1: Reprocess last N days (simple, reliable) ──────────────────────
# Instead of processing only "today", always reprocess last 3 days
# Late data from yesterday will be picked up in today's run of "yesterday"

from datetime import datetime, timedelta

def run_etl_with_late_data(spark, run_date, lookback_days=3):
    """Process last N days to catch late arrivals."""
    dates = [
        (run_date - timedelta(days=i)).strftime("%Y-%m-%d")
        for i in range(lookback_days)
    ]

    df = spark.read.parquet("s3://bucket/raw/") \
        .filter(col("event_date").isin(dates))   # read last 3 days

    # Write with overwrite per partition (idempotent)
    df.write \
        .partitionBy("event_date") \
        .mode("overwrite") \
        .parquet("s3://bucket/processed/")
    # Overwrites Sat+Sun+Mon partitions β€” Mon run fixes any late Sat/Sun data

# ── STRATEGY 2: Delta Lake MERGE (upsert late records) ────────────────────────
from delta.tables import DeltaTable

def upsert_late_records(spark, new_records_df):
    """Upsert new/late records into existing Delta table."""
    delta_table = DeltaTable.forPath(spark, "s3://bucket/delta/events/")

    (
        delta_table.alias("target")
                   .merge(
                       new_records_df.alias("source"),
                       "target.event_id = source.event_id",  # unique event ID
                   )
                   .whenMatchedUpdateAll()  # idempotent update
                   .whenNotMatchedInsertAll()
                   .execute()
    )

# ── STRATEGY 3: Separate late arrival table ────────────────────────────────────
# Detect late records at write time
from pyspark.sql.functions import current_timestamp, datediff, to_date, col

df = spark.read.parquet("s3://bucket/raw/today/") \
    .withColumn("processing_ts", current_timestamp()) \
    .withColumn("days_late",
        datediff(col("processing_ts"), col("event_date"))
    )

# Separate on-time vs late
on_time = df.filter(col("days_late") == 0)
late     = df.filter(col("days_late") > 0)

# Write on-time to regular partition
on_time.write.partitionBy("event_date").mode("append") \
       .parquet("s3://bucket/events/")

# Write late records to separate location for auditing + reprocessing
late.write.mode("append") \
    .parquet(f"s3://bucket/late-arrivals/{datetime.today().strftime('%Y-%m-%d')}/")

# ── STRATEGY 4: Watermark filter β€” ignore very old late data ──────────────────
MAX_LATE_DAYS = 7   # SLA: data older than 7 days is rejected

df_filtered = df.filter(
    col("event_date") >= (current_timestamp().cast("date") - MAX_LATE_DAYS)
)

# Log how much data was rejected
total       = df.count()
accepted    = df_filtered.count()
rejected    = total - accepted
print(f"Rejected {rejected} records older than {MAX_LATE_DAYS} days")

SECTION 6: AZURE CLOUD STORAGE + REAL-WORLD READING

πŸ’‘ Interview Tip
Interviewers ask this to verify you've actually used PySpark in production on Azure. They don't expect you to memorize SAS tokens β€” they want to see you know the concepts.

Q41 β€” Read from Azure Blob Storage (old storage / wasbs://)

Problem: You have data in Azure Blob Storage (classic, NOT Data Lake).
Container: "raw-data", Storage Account: "mystorageacct"
File: parquet/orders/2024/*.parquet
How do you read it in PySpark?
python β€” editable
# Azure Blob Storage uses the wasbs:// (secure) or wasb:// protocol
# Format: wasbs://<container>@<storage-account>.blob.core.windows.net/<path>

# ── METHOD 1: Account Key (simplest, not for production) ─────────────────────
storage_account = "mystorageacct"
account_key     = "your_account_key_here"   # from Azure Portal β†’ Access Keys

spark.conf.set(
    f"fs.azure.account.key.{storage_account}.blob.core.windows.net",
    account_key
)

df = spark.read.parquet(
    "wasbs://raw-data@mystorageacct.blob.core.windows.net/parquet/orders/2024/"
)
df.show()

# ── METHOD 2: SAS Token (scoped access, time-limited) ────────────────────────
sas_token = "sv=2023-01-01&ss=b&srt=sco&sp=rl&..."   # from Azure Portal

spark.conf.set(
    f"fs.azure.sas.raw-data.{storage_account}.blob.core.windows.net",
    sas_token
)

df = spark.read.csv(
    "wasbs://raw-data@mystorageacct.blob.core.windows.net/csv/customers/",
    header=True,
    inferSchema=True
)

# ── METHOD 3: Service Principal / App Registration (production standard) ──────
# Used in Databricks / ADF / Synapse pipelines
tenant_id     = "your-tenant-id"
client_id     = "your-app-client-id"
client_secret = "your-app-client-secret"

spark.conf.set(
    f"fs.azure.account.auth.type.{storage_account}.blob.core.windows.net",
    "OAuth"
)
spark.conf.set(
    f"fs.azure.account.oauth.provider.type.{storage_account}.blob.core.windows.net",
    "org.apache.hadoop.fs.azurebfs.oauth2.ClientCredsTokenProvider"
)
spark.conf.set(
    f"fs.azure.account.oauth2.client.id.{storage_account}.blob.core.windows.net",
    client_id
)
spark.conf.set(
    f"fs.azure.account.oauth2.client.secret.{storage_account}.blob.core.windows.net",
    client_secret
)
spark.conf.set(
    f"fs.azure.account.oauth2.client.endpoint.{storage_account}.blob.core.windows.net",
    f"https://login.microsoftonline.com/{tenant_id}/oauth2/token"
)

df = spark.read.parquet(
    "wasbs://raw-data@mystorageacct.blob.core.windows.net/parquet/orders/"
)
🧠 Memory Map
WASBS URL FORMAT
wasbs://<container-name>@<storage-account>.blob.core.windows.net/<path>
wasb:// = non-SSL (avoid in production)
wasbs:// = SSL encrypted (always use this)
AUTH OPTIONS (worst β†’ best for production)
Account Key→full access, leaked key = disaster
SAS Token→scoped + time-limited, better
Service Principal→App Registration in AAD, production standard
Managed Identity→no secrets at all (Databricks/Synapse only)

Q42 β€” Read from Azure Data Lake Storage Gen2 (ADLS Gen2 / abfss://)

Problem: Your company moved from Blob Storage to ADLS Gen2.
Storage Account: "datalakeprod", Container (filesystem): "silver"
Path: /processed/transactions/year=2024/
How do you read it? What changed from Blob Storage?
python β€” editable
# ADLS Gen2 uses abfss:// (hierarchical namespace enabled on storage account)
# Format: abfss://<filesystem>@<storage-account>.dfs.core.windows.net/<path>
#
# KEY DIFFERENCE from Blob Storage:
#   Blob:  wasbs://<container>@<account>.blob.core.windows.net/
#   ADLS2: abfss://<filesystem>@<account>.dfs.core.windows.net/
#   ↑ "dfs" endpoint vs "blob" endpoint
#   ↑ abfss:// vs wasbs://

storage_account = "datalakeprod"
filesystem      = "silver"          # like a container in Blob, but hierarchical

# ── METHOD 1: Account Key ─────────────────────────────────────────────────────
spark.conf.set(
    f"fs.azure.account.key.{storage_account}.dfs.core.windows.net",
    "your-account-key"
)

df = spark.read.parquet(
    f"abfss://{filesystem}@{storage_account}.dfs.core.windows.net/processed/transactions/year=2024/"
)
df.show()

# ── METHOD 2: Service Principal (production) ──────────────────────────────────
tenant_id     = "your-tenant-id"
client_id     = "your-service-principal-client-id"
client_secret = "your-service-principal-secret"

spark.conf.set(
    f"fs.azure.account.auth.type.{storage_account}.dfs.core.windows.net",
    "OAuth"
)
spark.conf.set(
    f"fs.azure.account.oauth.provider.type.{storage_account}.dfs.core.windows.net",
    "org.apache.hadoop.fs.azurebfs.oauth2.ClientCredsTokenProvider"
)
spark.conf.set(
    f"fs.azure.account.oauth2.client.id.{storage_account}.dfs.core.windows.net",
    client_id
)
spark.conf.set(
    f"fs.azure.account.oauth2.client.secret.{storage_account}.dfs.core.windows.net",
    client_secret
)
spark.conf.set(
    f"fs.azure.account.oauth2.client.endpoint.{storage_account}.dfs.core.windows.net",
    f"https://login.microsoftonline.com/{tenant_id}/oauth2/token"
)

# Read Parquet from ADLS Gen2
orders = spark.read.parquet(
    "abfss://silver@datalakeprod.dfs.core.windows.net/processed/transactions/"
)

# Read Delta table from ADLS Gen2
delta_df = spark.read.format("delta").load(
    "abfss://gold@datalakeprod.dfs.core.windows.net/curated/orders_final/"
)

# ── METHOD 3: Managed Identity (Databricks / Synapse β€” NO SECRETS) ─────────────
# In Azure Databricks with cluster-level Managed Identity configured:
# No spark.conf needed at all! Azure handles auth transparently.

df = spark.read.parquet(
    "abfss://silver@datalakeprod.dfs.core.windows.net/processed/transactions/"
)
# Works automatically when Managed Identity is assigned to Databricks workspace

# ── READ MULTIPLE FORMATS FROM ADLS GEN2 ─────────────────────────────────────
base = "abfss://silver@datalakeprod.dfs.core.windows.net"

# CSV
customers = spark.read.option("header", True).csv(f"{base}/raw/customers/")

# JSON
events = spark.read.option("multiLine", True).json(f"{base}/raw/events/")

# Parquet (partitioned)
transactions = spark.read.parquet(f"{base}/processed/transactions/year=2024/month=*/")

# Delta
gold_layer = spark.read.format("delta").load(f"{base}/gold/fact_orders/")

# ORC
hive_data = spark.read.orc(f"{base}/hive-warehouse/sales/")
FeatureBlob StorageADLS Gen2
Protocolwasbs://abfss://
Endpoint.blob.core.windows.dfs.core.windows.net
Hierarchical namespaceNO (flat)YES (folder structure)
ACLs (fine-grained)NOYES (POSIX-style ACLs)
Big data performanceLowerHigher (optimized I/O)
Use forGeneral blob storeData Lake / Delta Lake

Q43 β€” Databricks: Mount ADLS Gen2 and read without long paths

Problem: Your team keeps repeating the full abfss:// path everywhere.
How do you mount Azure storage in Databricks so notebooks can
use simple paths like /mnt/silver/processed/ instead?
python β€” editable
# ── STEP 1: MOUNT THE STORAGE (run once, persists across cluster restarts) ────
configs = {
    "fs.azure.account.auth.type": "OAuth",
    "fs.azure.account.oauth.provider.type":
        "org.apache.hadoop.fs.azurebfs.oauth2.ClientCredsTokenProvider",
    "fs.azure.account.oauth2.client.id":     dbutils.secrets.get("kv-scope", "sp-client-id"),
    "fs.azure.account.oauth2.client.secret": dbutils.secrets.get("kv-scope", "sp-secret"),
    "fs.azure.account.oauth2.client.endpoint":
        f"https://login.microsoftonline.com/{dbutils.secrets.get('kv-scope','tenant-id')}/oauth2/token"
}

# Mount the "silver" filesystem to /mnt/silver
dbutils.fs.mount(
    source      = "abfss://silver@datalakeprod.dfs.core.windows.net/",
    mount_point = "/mnt/silver",
    extra_configs = configs
)

# Mount the "gold" filesystem to /mnt/gold
dbutils.fs.mount(
    source      = "abfss://gold@datalakeprod.dfs.core.windows.net/",
    mount_point = "/mnt/gold",
    extra_configs = configs
)

# ── STEP 2: READ USING SIMPLE /mnt/ PATHS (in any notebook) ──────────────────
# Instead of: abfss://silver@datalakeprod.dfs.core.windows.net/processed/orders/
# Just use:
df = spark.read.parquet("/mnt/silver/processed/orders/")
customers = spark.read.option("header", True).csv("/mnt/silver/raw/customers/")
gold_df   = spark.read.format("delta").load("/mnt/gold/curated/fact_sales/")

# ── STEP 3: LIST AND MANAGE MOUNTS ───────────────────────────────────────────
# List all current mounts
display(dbutils.fs.mounts())

# Check if mount exists before mounting (avoid error on duplicate)
mounted = any(m.mountPoint == "/mnt/silver" for m in dbutils.fs.mounts())
if not mounted:
    dbutils.fs.mount(source="abfss://silver@...", mount_point="/mnt/silver",
                     extra_configs=configs)

# List files in mounted path
dbutils.fs.ls("/mnt/silver/processed/orders/")

# Unmount when needed
dbutils.fs.unmount("/mnt/silver")

# ── USE SECRETS (NEVER HARDCODE CREDENTIALS) ──────────────────────────────────
# In Databricks, always use dbutils.secrets β€” never paste keys in notebooks
# Create secret scope linked to Azure Key Vault:
# dbutils.secrets.get(scope="my-kv-scope", key="storage-account-key")

# ── ALTERNATIVE: Unity Catalog External Location (modern Databricks) ──────────
# Instead of mounts, Unity Catalog manages storage access centrally:
# CREATE EXTERNAL LOCATION silver_lake
#   URL 'abfss://silver@datalakeprod.dfs.core.windows.net/'
#   WITH (STORAGE CREDENTIAL my_credential);
#
# Then read directly:
df = spark.read.parquet("abfss://silver@datalakeprod.dfs.core.windows.net/data/")
# Access controlled by Unity Catalog permissions, not per-notebook config

Q44 β€” Read from Azure Synapse Analytics (SQL Pool) via PySpark

Problem: You need to join Spark data with a large table sitting in
Azure Synapse dedicated SQL pool. How do you read it into
a Spark DataFrame efficiently?
python β€” editable
# ── METHOD 1: JDBC (simple but slow β€” single thread, no parallelism) ──────────
synapse_url = (
    "jdbc:sqlserver://myworkspace.sql.azuresynapse.net:1433;"
    "database=mydb;encrypt=true;trustServerCertificate=false;"
    "hostNameInCertificate=*.sql.azuresynapse.net;loginTimeout=30"
)

df = spark.read.format("jdbc") \
    .option("url", synapse_url) \
    .option("dbtable", "dbo.FactSales") \
    .option("user", "sqladminuser") \
    .option("password", dbutils.secrets.get("scope", "synapse-password")) \
    .option("driver", "com.microsoft.sqlserver.jdbc.SQLServerDriver") \
    .load()

# ⚠️ TRAP: Default JDBC = 1 partition = 1 thread = very slow for large tables
# Fix: Add parallel read config:
df = (
    spark.read.format("jdbc")
         .option("url", synapse_url)
         .option("dbtable", "dbo.FactSales")
         .option("user", "sqladminuser")
         .option("password", dbutils.secrets.get("scope", "synapse-password"))
         .option("driver", "com.microsoft.sqlserver.jdbc.SQLServerDriver")
         .option("numPartitions", "20")  # parallel reads
         .option("partitionColumn", "SaleID")  # split by this column
         .option("lowerBound", "1")
         .option("upperBound", "10000000")  # estimated max SaleID
         .load()
)

# ── METHOD 2: Synapse Connector for Databricks (PolyBase β€” fast!) ──────────────
# Azure Synapse Analytics connector uses PolyBase/COPY INTO via ADLS staging
# Much faster than JDBC for large tables (parallel bulk export)

df = spark.read \
    .format("com.databricks.spark.sqldw") \
    .option("url", synapse_url) \
    .option("tempDir", "abfss://temp@datalakeprod.dfs.core.windows.net/staging/") \
    .option("forwardSparkAzureStorageCredentials", "true") \
    .option("dbTable", "dbo.FactSales") \
    .load()

# ── METHOD 3: Push query down (read only what you need) ───────────────────────
query = """
    (SELECT SaleID, CustomerID, Amount, SaleDate
     FROM dbo.FactSales
     WHERE SaleDate >= '2024-01-01'
     AND Region = 'APAC') AS filtered_sales
"""

df = (
    spark.read.format("jdbc")
         .option("url", synapse_url)
         .option("dbtable", query)  # push filter to Synapse, not all data
         .option("user", "sqladminuser")
         .option("password", dbutils.secrets.get("scope", "synapse-password"))
         .load()
)

Q45 β€” Read from Azure Event Hubs / Kafka into PySpark (Structured Streaming)

Problem: You need to read real-time events from Azure Event Hubs
into a PySpark DataFrame. How do you set this up?
(They ask this to check if you know Event Hubs β‰ˆ Kafka protocol)
python β€” editable
# Azure Event Hubs is Kafka-compatible β†’ use Spark Kafka connector
# Event Hubs Kafka endpoint: <namespace>.servicebus.windows.net:9093

# ── CONNECTION STRING from Azure Portal β†’ Event Hubs β†’ Shared Access Policies
connection_string = dbutils.secrets.get("scope", "eventhub-connection-string")
# Format: "Endpoint=sb://ns.servicebus.windows.net/;SharedAccessKeyName=...;SharedAccessKey=..."

# Build Kafka SASL config from Event Hubs connection string
SASL_CONFIG = (
    f'org.apache.kafka.common.security.plain.PlainLoginModule required '
    f'username="$ConnectionString" '
    f'password="{connection_string}";'
)

# ── READ AS STREAMING DATAFRAME ───────────────────────────────────────────────
stream_df = (
    spark.readStream
         .format("kafka")
         .option("kafka.bootstrap.servers", "mynamespace.servicebus.windows.net:9093")
         .option("subscribe", "my-event-hub-name")  # topic = event hub name
         .option("kafka.security.protocol", "SASL_SSL")
         .option("kafka.sasl.mechanism", "PLAIN")
         .option("kafka.sasl.jaas.config", SASL_CONFIG)
         .option("startingOffsets", "latest")  # or "earliest"
         .load()
)

# ── PARSE THE MESSAGE (value is binary β†’ deserialize) ────────────────────────
from pyspark.sql.functions import col, from_json
from pyspark.sql.types import StructType, StructField, StringType, DoubleType, TimestampType

event_schema = StructType([
    StructField("event_id",   StringType(),    True),
    StructField("user_id",    StringType(),    True),
    StructField("amount",     DoubleType(),    True),
    StructField("event_time", TimestampType(), True)
])

parsed_df = stream_df \
    .select(
        col("key").cast("string").alias("partition_key"),
        from_json(col("value").cast("string"), event_schema).alias("data"),
        col("timestamp").alias("ingest_time")
    ) \
    .select("partition_key", "data.*", "ingest_time")

# ── WRITE STREAM TO ADLS GEN2 / DELTA ────────────────────────────────────────
query = parsed_df.writeStream \
    .format("delta") \
    .outputMode("append") \
    .option("checkpointLocation",
            "abfss://checkpoints@datalake.dfs.core.windows.net/eventhub-stream/") \
    .start("abfss://silver@datalakeprod.dfs.core.windows.net/streaming/events/")

query.awaitTermination()

# ── READ AS BATCH (for one-time historical read) ──────────────────────────────
batch_df = (
    spark.read
         .format("kafka")
         .option("kafka.bootstrap.servers", "mynamespace.servicebus.windows.net:9093")
         .option("subscribe", "my-event-hub-name")
         .option("kafka.security.protocol", "SASL_SSL")
         .option("kafka.sasl.mechanism", "PLAIN")
         .option("kafka.sasl.jaas.config", SASL_CONFIG)
         .option("startingOffsets", "earliest")  # read all historical events
         .option("endingOffsets", "latest")
         .load()
)

Q46 β€” Full Azure Data Lake Architecture + PySpark read pattern (Medallion)

Problem: Describe how data flows in a typical Azure data lake setup
and how PySpark reads from each layer.
(High-level design question β€” tests real-world Azure experience)
πŸ“ Architecture Diagram
MEDALLION ARCHITECTURE ON AZURE:

  External Sources
  (Blob, RDBMS, APIs, Event Hubs)
         ↓  [ADF / Databricks ingest]
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  BRONZE Layer (raw, immutable)      β”‚  ← abfss://bronze@datalake.dfs...
  β”‚  Format: raw CSV/JSON/Parquet       β”‚  ← partition by ingest_date
  β”‚  No transformation, as-is           β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         ↓  [Databricks / Synapse Spark]
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  SILVER Layer (cleaned, conformed)  β”‚  ← abfss://silver@datalake.dfs...
  β”‚  Format: Delta Lake                 β”‚  ← partition by event_date
  β”‚  Deduped, nulls handled, typed      β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         ↓  [Databricks / Synapse Spark]
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  GOLD Layer (business aggregates)   β”‚  ← abfss://gold@datalake.dfs...
  β”‚  Format: Delta Lake                 β”‚  ← partition by region/product
  β”‚  Fact + Dimension tables, KPIs      β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         ↓  [Synapse SQL Pool / Power BI]
  Dashboards / Reports / ML Models
python β€” editable
# ── READ PATTERN FOR EACH LAYER ───────────────────────────────────────────────

# Setup (done once per session in production via cluster config or Managed Identity)
spark.conf.set(
    "fs.azure.account.auth.type.datalakeprod.dfs.core.windows.net", "OAuth"
)
spark.conf.set(
    "fs.azure.account.oauth.provider.type.datalakeprod.dfs.core.windows.net",
    "org.apache.hadoop.fs.azurebfs.oauth2.ClientCredsTokenProvider"
)
spark.conf.set(
    "fs.azure.account.oauth2.client.id.datalakeprod.dfs.core.windows.net",
    dbutils.secrets.get("kv-scope", "sp-client-id")
)
spark.conf.set(
    "fs.azure.account.oauth2.client.secret.datalakeprod.dfs.core.windows.net",
    dbutils.secrets.get("kv-scope", "sp-secret")
)
spark.conf.set(
    "fs.azure.account.oauth2.client.endpoint.datalakeprod.dfs.core.windows.net",
    "https://login.microsoftonline.com/<tenant-id>/oauth2/token"
)

BASE = "abfss://{layer}@datalakeprod.dfs.core.windows.net"

# ── BRONZE: read raw files ─────────────────────────────────────────────────────
bronze_csv = spark.read \
    .option("header", True) \
    .option("mode", "PERMISSIVE") \
    .option("columnNameOfCorruptRecord", "_corrupt") \
    .csv(f"{BASE.format(layer='bronze')}/raw/orders/ingest_date=2024-01-15/")

# ── SILVER: read cleaned Delta ─────────────────────────────────────────────────
silver_orders = spark.read \
    .format("delta") \
    .load(f"{BASE.format(layer='silver')}/orders/") \
    .filter(col("event_date") >= "2024-01-01")  # partition pruning

# Time travel: audit/debugging
silver_yesterday = spark.read.format("delta") \
    .option("versionAsOf", 10) \
    .load(f"{BASE.format(layer='silver')}/orders/")

# ── GOLD: read aggregated Delta for reporting ──────────────────────────────────
gold_kpis = spark.read \
    .format("delta") \
    .load(f"{BASE.format(layer='gold')}/fact_daily_revenue/") \
    .filter(col("region") == "APAC")

# ── WRITE BACK TO SILVER (after transformation) ────────────────────────────────
from delta.tables import DeltaTable

DeltaTable.forPath(
    spark,
    f"{BASE.format(layer='silver')}/orders/"
).alias("target").merge(
    bronze_csv.alias("source"),
    "target.order_id = source.order_id"
).whenMatchedUpdateAll() \
 .whenNotMatchedInsertAll() \
 .execute()
Storage TypeProtocolEndpoint suffix
Azure Blob (classic)wasbs://.blob.core.windows.net
ADLS Gen1adl://.azuredatalakestore.net
ADLS Gen2 (modern)abfss://.dfs.core.windows.net
Azure Files(SMB/NFS).file.core.windows.net
Intermediate

Interview Questions

#

PySpark interview question index

Use this as a prompt deck: answer aloud, then follow the owner link. It intentionally stores question wording and pointers only; canonical explanations and code are not repeated here.

How to answer

  1. Give the direct answer first.
  2. Draw or name the execution/data-flow map.
  3. State the key trade-off or trap.
  4. Cite the plan/UI metric that would verify the claim.
  5. For a scenario, structure the answer as evidence -> diagnosis -> fix -> validation.

Canonical Q-PYS index

Q-PYS-001: What Is Apache Spark?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L54.

Q-PYS-002: Explain Spark Architecture β€” the 3 Components

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L80.

Q-PYS-003: SparkContext vs SparkSession β€” What's the Difference?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L156.

Q-PYS-004: What Is a DAG? How Does Spark Execute Your Code?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L191.

Q-PYS-005: What Is Lazy Evaluation? Why Does Spark Use It?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L241.

Q-PYS-006: What Is a Shuffle? Why Is It the Most Expensive Operation?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L283.

Q-PYS-007: What Is an RDD? Explain Its 5 Properties.

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L349.

Q-PYS-008: Explain Key RDD Transformations with Code

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L405.

Q-PYS-009: What Are RDD Actions? List the Key Ones.

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L510.

Q-PYS-010: Explain RDD Persistence β€” cache() vs persist()

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L551.

Q-PYS-011: What Is RDD Lineage? What Is Checkpointing?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L604.

Q-PYS-012: What Are Broadcast Variables and Accumulators?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L653.

Q-PYS-013: Scenario 1: Executor OOM β€” what happened?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_01_Architecture_RDD.md#L729.

Q-PYS-014: What is SparkSession?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_02_DataFrame_SparkSQL.md#L106.

Q-PYS-015: What is AQE?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_03_Optimization.md#L274.

Q-PYS-016: Scenario 1: "Our Spark job takes 6 hours. How do you debug?"

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_03_Optimization.md#L783.

Q-PYS-017: Your Spark job used to finish in 10 min, now it takes 3 hours. Debug it.

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_04_Confusions_Labs_MockInterview.md#L668.

Q-PYS-018: Explain lazy evaluation and why it matters.

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_04_Confusions_Labs_MockInterview.md#L677.

Q-PYS-019: When do you broadcast vs sort-merge join?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_04_Confusions_Labs_MockInterview.md#L684.

Q-PYS-020: How do you handle data skew?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_04_Confusions_Labs_MockInterview.md#L691.

Q-PYS-021: When does cache() make things slower?

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_04_Confusions_Labs_MockInterview.md#L698.

Q-PYS-022: Filter employees earning > 50k

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L13.

Q-PYS-023: Count orders per customer

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L14.

Q-PYS-024: Find duplicate rows by email

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L15.

Q-PYS-025: Total sales by date

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L16.

Q-PYS-026: Add a new derived column

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L17.

Q-PYS-027: Read CSV + handle nulls

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L18.

Q-PYS-028: Word count in text column

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L19.

Q-PYS-029: Find max salary per department

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L20.

Q-PYS-030: Rank employees by salary per dept

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L21.

Q-PYS-031: Top 3 salaries per department

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L22.

Q-PYS-032: Second highest salary

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L23.

Q-PYS-033: Running total of revenue

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L24.

Q-PYS-034: 7-day rolling average

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L25.

Q-PYS-035: Employees earning more than their manager

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L26.

Q-PYS-036: Customers with no orders (NOT IN)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L27.

Q-PYS-037: Deduplicate β€” keep latest record

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L28.

Q-PYS-038: MoM revenue change (%)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L29.

Q-PYS-039: Pivot: rows to columns

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L30.

Q-PYS-040: Explode array column + count tags

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L31.

Q-PYS-041: Find consecutive purchase days

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L32.

Q-PYS-042: Session ID assignment (30-min gap)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L33.

Q-PYS-043: Temperature rise from previous day

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L34.

Q-PYS-044: Longest streak of active days

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L35.

Q-PYS-045: Products bought together (market basket)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L36.

Q-PYS-046: Funnel conversion rates

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L37.

Q-PYS-047: Deduplicate with complex multi-key logic

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L38.

Q-PYS-048: Handle data skew with salting

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L39.

Q-PYS-049: Optimize slow join with broadcast

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L40.

Q-PYS-050: Read multiple sources + track origin

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L41.

Q-PYS-051: Flatten nested JSON to flat table

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L42.

Q-PYS-052: Process 200 GB data with 16 GB executor memory

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L43.

Q-PYS-053: Read bad/corrupt data β€” 3 modes

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L44.

Q-PYS-054: Recursively read all files in directory tree

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L45.

Q-PYS-055: OOM during groupBy on 500M rows

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L46.

Q-PYS-056: Job succeeds on 10 GB, fails on 500 GB on Monday

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L47.

Q-PYS-057: Schema evolution β€” new column added to source

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L48.

Q-PYS-058: Estimate partitions needed for a job

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L49.

Q-PYS-059: Avoid recomputing expensive DataFrame 3 times

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L50.

Q-PYS-060: Read only specific file types from mixed directory

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L51.

Q-PYS-061: Handle late-arriving data in daily ETL

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L52.

Q-PYS-062: Add derived column (categorize salary)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L148.

Q-PYS-063: Word count (RDD style)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L201.

Q-PYS-064: Max salary per department

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L238.

Q-PYS-065: Rank employees by salary per department

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L257.

Q-PYS-066: Customers with no orders

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L409.

Q-PYS-067: Month-over-Month revenue change

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L458.

Q-PYS-068: Pivot: rows to columns (unpivot long β†’ wide)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L491.

Q-PYS-069: Session ID assignment (gap > 30 minutes = new session)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L578.

Q-PYS-070: Days with temperature higher than previous day

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L617.

Q-PYS-071: Longest streak of consecutive active days per user

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L647.

Q-PYS-072: Dedup with composite key (keep most recent per key combo)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L760.

Q-PYS-073: Optimize slow join with broadcast hint

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L836.

Q-PYS-074: Read multiple sources + track file origin

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L875.

Q-PYS-075: Process 200 GB data with only 16 GB executor memory

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L968.

Q-PYS-076: Reading bad/corrupt data β€” 3 modes + handling

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L1062.

Q-PYS-077: Recursively read all files from a directory tree

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L1161.

Q-PYS-078: Job works on 10 GB weekdays, fails on 500 GB Mondays

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L1339.

Q-PYS-079: How to estimate the right number of partitions for a job

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L1490.

Q-PYS-080: Avoid recomputing an expensive DataFrame 3 times

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L1561.

Q-PYS-081: Read only specific file types from a mixed directory

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L1633.

Q-PYS-082: Read from Azure Blob Storage (old storage / wasbs://)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L1794.

Q-PYS-083: Read from Azure Data Lake Storage Gen2 (ADLS Gen2 / abfss://)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L1882.

Q-PYS-084: Databricks: Mount ADLS Gen2 and read without long paths

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L1995.

Q-PYS-085: Read from Azure Synapse Analytics (SQL Pool) via PySpark

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L2070.

Q-PYS-086: Read from Azure Event Hubs / Kafka into PySpark (Structured Streaming)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L2138.

Q-PYS-087: Full Azure Data Lake Architecture + PySpark read pattern (Medallion)

Answer owner: Recall the canonical concept or runnable pattern.

Alternate source wording: PySpark_QUESTION_BANK.md#L2218.

Q-PYS-088: Read from Azure Blob Storage (wasbs://)

Answer owner: Recall the canonical concept or runnable pattern.

Alias of: Q-PYS-082.

Alternate source wording: PySpark_QUESTION_BANK.md#L2345.

Q-PYS-089: Read from ADLS Gen2 (abfss://)

Answer owner: Recall the canonical concept or runnable pattern.

Alias of: Q-PYS-083.

Alternate source wording: PySpark_QUESTION_BANK.md#L2346.

Q-PYS-090: Mount ADLS Gen2 in Databricks

Answer owner: Recall the canonical concept or runnable pattern.

Alias of: Q-PYS-084.

Alternate source wording: PySpark_QUESTION_BANK.md#L2347.

Q-PYS-091: Read from Azure Synapse SQL Pool

Answer owner: Recall the canonical concept or runnable pattern.

Alias of: Q-PYS-085.

Alternate source wording: PySpark_QUESTION_BANK.md#L2348.

Q-PYS-092: Read from Azure Event Hubs (Kafka)

Answer owner: Recall the canonical concept or runnable pattern.

Alias of: Q-PYS-086.

Alternate source wording: PySpark_QUESTION_BANK.md#L2349.

Q-PYS-093: Medallion architecture + full read pattern

Answer owner: Recall the canonical concept or runnable pattern.

Alias of: Q-PYS-087.

Alternate source wording: PySpark_QUESTION_BANK.md#L2350.

90 seconds

Practice sprint

Close the atlas. Rebuild the map.

Name the path from API to files, then explain where shuffle, skew, and serialization enter the system.

Open interview prompts