Spark Architecture • Shuffles & Partitions • Joins & Skew • Caching & Memory • Parquet • 2026

PySpark Interview Questions

30 questions What each one tests, an answer frame, a spoken answer 35 min read

This page is for data engineers and developers facing a PySpark round, from a first data job to a senior platform role. Most rounds start with how Spark runs your code: the driver, executors, lazy evaluation and the jobs, stages and tasks behind an action. Then they test shuffles and partitions, joins and skew, caching, window functions and file layout, and senior rounds add memory tuning and a slow-job story. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. Practise saying them, then swap in your own stories.

Search all questions by round, difficulty and level, or save the ones you want to practise.

Architecture 3 questions

Easy Technical round Fresher, Mid-level Practice question

1. Walk me through what the driver, the executors and the cluster manager each do when you run a PySpark job.

What the interviewer is really testing:
Whether you know where your code actually runs, which you need before you can make sense of driver memory errors or lost executors.
Answer frame:

Driver: runs your script, holds the SparkSession, turns your code into a plan and schedules tasks.

Cluster manager: YARN, Kubernetes or Spark's standalone manager; it hands out resources and starts executors.

Executors: JVM processes on worker nodes that run tasks, one partition per task, and hold cached and shuffle data.

PySpark layer: Python talks to the JVM through Py4J; Python workers only start on executors for UDFs and RDD lambdas.

Sample spoken answer:

"When I submit a PySpark job, the driver starts first. It runs my Python script, creates the SparkSession and turns my DataFrame code into a plan. The driver asks the cluster manager, which could be YARN, Kubernetes or Spark's own standalone manager, for resources, and the cluster manager launches executors on the worker nodes. Executors are JVM processes. Each one has a few cores, so it runs several tasks at once, and each task handles one partition of data. Executors also keep cached data and shuffle files. The driver tracks every task and retries the ones that fail. The PySpark twist is that my Python code drives the JVM through Py4J. Plain DataFrame operations run inside the JVM, and Python worker processes only start on the executors when I use Python UDFs or RDD lambdas."

Red flag to avoid:

Saying the driver processes the data itself, or not knowing that collect pulls every row back to the driver.

They may ask next:
  • What happens to the job if the driver dies, compared with losing one executor?
  • What is the difference between client and cluster deploy mode?
Say it in 60 seconds
Medium Technical round Fresher, Mid-level Practice question

2. When you call an action, how does Spark break the work into jobs, stages and tasks?

What the interviewer is really testing:
Whether you can read the Spark UI with a correct mental model, because every tuning conversation is about stages and tasks.
Answer frame:

Job: an action such as count or write starts a job.

Stages: the job is cut into stages at every shuffle boundary.

Tasks: each stage runs one task per partition, spread across executor cores.

Sample spoken answer:

"An action like count or a write triggers a job. Spark looks at the plan behind it and cuts it into stages wherever data has to be shuffled. Everything between two shuffles, like a read, a filter and a select, is pipelined into one stage, so rows flow through those steps without being written out. Each stage runs as a set of tasks, one per partition. So if I read a table that splits into 400 partitions, filter it and then group by country, the first stage has 400 tasks that read, filter and do a partial aggregation, then write shuffle files. The second stage reads those shuffle files and finishes the aggregation, with one task per shuffle partition. Tasks run in parallel on executor cores, which is why the number and size of partitions matter so much."

Red flag to avoid:

Mixing up stages and tasks, or not linking stage boundaries to shuffles.

They may ask next:
  • Why does one action sometimes show up as more than one job in the Spark UI?
  • If a stage has 2,000 tasks and the cluster has 100 cores, how does that play out?
Say it in 60 seconds
Medium Technical round Mid-level, Senior Practice question

3. What does the Catalyst optimizer do with your DataFrame code, and how do you read the output of explain()?

What the interviewer is really testing:
Whether you can check what Spark will really run instead of guessing, which is the first step in any performance fix.
Answer frame:

Plans: parsed, analysed, then optimised logical plan, then a chosen physical plan.

Rules: predicate pushdown, column pruning, constant folding, combining filters.

Reading it: read bottom up; Exchange means a shuffle; check the join type and pushed filters on the scan.

Sample spoken answer:

"Catalyst takes my DataFrame code as a logical plan. First it resolves column names and types against the schema. Then it applies rules to optimise it: it pushes filters down close to the data source, drops columns I never use, folds constants and merges steps. After that it picks a physical plan, for example which join strategy to use, and whole-stage code generation compiles chains of operators into efficient JVM code. When I call explain with the formatted or extended mode, I read the physical plan from the bottom up. At the bottom I check the file scan: PushedFilters and ReadSchema tell me whether my filter and column pruning reached the Parquet reader. Every Exchange is a shuffle. I look at the join node to see whether it's a BroadcastHashJoin or a SortMergeJoin, and a HashAggregate that appears twice is the partial and final aggregation around a shuffle."

Code:
df = (spark.read.parquet("/data/orders/")
      .filter("status = 'shipped'")
      .groupBy("country").count())
df.explain("formatted")   # look for PushedFilters, Exchange, HashAggregate
Red flag to avoid:

Treating explain as noise, or not knowing that an Exchange in the plan is a shuffle.

They may ask next:
  • Why can't a filter on a column produced by a Python UDF be pushed down to the file scan?
  • What does it mean when the plan starts with AdaptiveSparkPlan?
Say it in 60 seconds

Core Concepts 3 questions

Easy Technical round Fresher, Mid-level Practice question

4. What is the difference between an RDD and a DataFrame, and why do most teams write DataFrame code today?

What the interviewer is really testing:
Whether you know why the DataFrame API is faster in PySpark, not just that it exists.
Answer frame:

RDD: a low-level distributed collection of objects, transformed with your own functions, no schema.

DataFrame: rows with named, typed columns; Catalyst optimises the plan and execution stays in the JVM.

PySpark angle: RDD lambdas run in Python workers, so every row is serialised back and forth.

When RDD: rare now; odd unstructured data or fine control over partitioning.

Sample spoken answer:

"An RDD is Spark's original building block: a distributed collection of objects that I transform with functions like map and filter. Spark doesn't know what's inside those objects, so it can't optimise much. A DataFrame has a schema, named columns with types, and I describe what I want with column expressions. That lets the Catalyst optimizer reorder filters, prune columns and pick join strategies, and execution uses a compact binary format. In PySpark the gap is even bigger. With RDDs, my lambdas run in Python worker processes, so every row gets serialised from the JVM to Python and back. With DataFrames, built-in functions run inside the JVM and Python only sends the plan. The typed Dataset API exists only in Scala and Java, so in Python it's DataFrames. I'd only drop to RDDs for unusual unstructured data or old code."

Red flag to avoid:

Saying RDDs are faster because they are lower level, when in PySpark the opposite is usually true.

They may ask next:
  • Can you convert between the two, and what does it cost?
  • Why is a Python lambda on an RDD slower than the same logic as a DataFrame expression?
Say it in 60 seconds
Easy Technical round Fresher Practice question

5. What does lazy evaluation mean in Spark? Which operations are transformations and which are actions?

What the interviewer is really testing:
Whether you understand that nothing runs until an action, which explains when errors appear and why caching and plans work the way they do.
Answer frame:

Transformations: select, filter, withColumn, join, groupBy with agg; they only build a plan.

Actions: count, show, collect, take, write; they make Spark run a job.

Why lazy: Spark sees the whole chain first, so it can optimise and pipeline steps.

Sample spoken answer:

"Lazy evaluation means that when I call a transformation like filter, select or join, Spark doesn't touch the data. It just adds a step to a plan, which is a DAG of operations. Only when I call an action, like count, show, collect or a write, does Spark optimise that whole plan and run it on the cluster. That's useful because Spark can see everything I asked for before it starts, so it can push filters down to the file reader, skip columns I never use and run several steps in one pass. It also explains a common surprise: a typo in a column name is usually caught straight away, when Spark analyses the plan, but a bad value that breaks a function often only fails later, at the action, because that's when the data is really read."

Code:
events = spark.read.parquet("/data/events/")
active = events.filter(events.status == "active").select("user_id")  # plan only
active.count()  # action: now Spark builds a job and reads the files
Red flag to avoid:

Thinking each line of DataFrame code runs as soon as it's executed, or calling show as if it were a transformation.

They may ask next:
  • Why might reading a CSV with inferSchema start a job before you call any action?
  • If you call two actions on the same DataFrame, does Spark read the source twice?
Say it in 60 seconds
Medium Technical round Fresher, Mid-level Practice question

6. In the RDD API, why is reduceByKey usually preferred over groupByKey for aggregating by key?

What the interviewer is really testing:
Whether you understand map-side combining and the memory risk of collecting every value for a key in one place.
Answer frame:

reduceByKey: combines values inside each partition first, so far less data crosses the network.

groupByKey: ships every single value, then builds one list per key in memory.

DataFrames: groupBy with agg already does a partial aggregation before the shuffle.

Sample spoken answer:

"Both end up with one result per key, but they shuffle very different amounts of data. With reduceByKey, Spark applies my function inside each partition before the shuffle, so if a partition has a million rows for key A, it sends one partial sum instead of a million numbers. Then it combines the partial results on the other side. With groupByKey, every value crosses the network and Spark builds a full list of values for each key on one executor. On a popular key that list can be huge and cause an out-of-memory error. So for sums, counts or anything associative I use reduceByKey or aggregateByKey. In DataFrame code I don't need to think about it as much, because groupBy with agg does that partial aggregation for me, and I can see it as two HashAggregate steps in the plan."

Code:
pairs = spark.sparkContext.parallelize([("a", 1), ("b", 1), ("a", 1)])
pairs.reduceByKey(lambda x, y: x + y).collect()   # combines before the shuffle
pairs.groupByKey().mapValues(sum).collect()      # same answer, ships every value
Red flag to avoid:

Saying the two are equivalent because the output is the same, ignoring what gets shuffled.

They may ask next:
  • When is groupByKey genuinely the right choice?
  • What does aggregateByKey let you do that reduceByKey can't?
Say it in 60 seconds

Shuffles & Partitioning 3 questions

Easy Technical round Fresher, Mid-level Practice question

7. What is the difference between a narrow and a wide transformation, and why do wide ones cost more?

What the interviewer is really testing:
Whether you can spot a shuffle in your own code, since shuffles are where most Spark time and failures come from.
Answer frame:

Narrow: each output partition needs only one input partition: filter, select, withColumn, union.

Wide: output needs rows from many partitions: groupBy, most joins, distinct, orderBy, repartition.

Cost: a wide step shuffles: write to local disk, move over the network, start a new stage.

Sample spoken answer:

"In a narrow transformation, each output partition is built from exactly one input partition. Filter, select and withColumn are like that: an executor can process its partition alone, and Spark chains several of them in one stage. A wide transformation needs rows from many partitions to produce one output partition. A groupBy has to bring all rows with the same key together, and a join has to line up matching keys from both sides, unless one side is broadcast. That movement is a shuffle. Each task writes its output to local disk split by target partition, and the next stage pulls those pieces over the network. So a wide step costs disk writes, network transfer and serialisation, it's where skew shows up, and it's a stage boundary. When I tune a job, I start by counting the shuffles."

Red flag to avoid:

Calling filter or select a shuffle, or not knowing that groupBy and join move data across the network.

They may ask next:
  • Is a join always a wide transformation?
  • Where do shuffle files live, and what happens to them if an executor is lost?
Say it in 60 seconds
Easy Technical round Fresher, Mid-level Practice question

8. What's the difference between repartition and coalesce, and when would you use each one?

What the interviewer is really testing:
Whether you know which one shuffles, and the trap of coalescing too early in a job.
Answer frame:

repartition: full shuffle; can raise or lower the count, evens out sizes, can partition by column.

coalesce: merges existing partitions without a full shuffle; only lowers the count; sizes can be uneven.

Trap: coalesce has no stage boundary, so it can shrink the parallelism of the work before it.

Sample spoken answer:

"Repartition does a full shuffle. I can go up or down in partition count, the rows end up spread fairly evenly, and I can repartition by a column so all rows for one key land together. Coalesce only reduces the number of partitions, and it does it by merging neighbouring partitions on the same executors, so there's no full shuffle, which makes it cheaper. The catch is that the resulting partitions can be uneven. There's a subtler trap too. Because coalesce doesn't create a new stage, if I write coalesce(1) at the end of a heavy job, the work before it can collapse into a single task. In that case repartition(1) is often faster, because the heavy part stays parallel and only the final write runs in one task. I use coalesce to trim partition count before a write, and repartition to fix skewed or too-few partitions."

Code:
df.rdd.getNumPartitions()                          # current count
keyed = df.repartition(400, "customer_id")        # full shuffle, even, keyed
result.coalesce(50).write.parquet("/data/out/")   # trim before writing, no full shuffle
Red flag to avoid:

Saying coalesce can increase the partition count, or reaching for coalesce(1) at the end of a heavy job without knowing it can squeeze the upstream work into one task.

They may ask next:
  • How would you check how many partitions a DataFrame has right now?
  • Why can repartitioning by a column before writing reduce the number of output files?
Say it in 60 seconds
Medium Technical round Mid-level, Senior Practice question

9. What does spark.sql.shuffle.partitions control, and how do you pick a sensible value for it?

What the interviewer is really testing:
Whether you can size partitions from the data instead of leaving a default in place for every job.
Answer frame:

What it does: sets how many partitions a DataFrame shuffle produces for joins and aggregations; the default is 200.

Too few: huge partitions, spill to disk, memory errors.

Too many: tiny tasks, scheduling overhead, lots of small files on write.

Sizing: aim for partitions in the low hundreds of megabytes; let AQE coalesce the rest.

Sample spoken answer:

"It sets the number of partitions Spark creates after a shuffle in DataFrame and SQL code, so it decides how many tasks a join or a groupBy stage gets. The default is 200, which is fine for small data and wrong for most big jobs. If a shuffle moves a couple of terabytes into 200 partitions, each task handles about ten gigabytes, spills to disk and may run out of memory. If a small job shuffles a few megabytes into 200 partitions, most tasks do almost nothing and the write produces lots of tiny files. My rule of thumb is to look at the shuffle write size in the Spark UI and divide it so partitions land around a hundred to two hundred megabytes. With Adaptive Query Execution on, I usually set it on the high side and let AQE merge small partitions after it sees the real sizes."

Code:
spark.conf.set("spark.sql.shuffle.partitions", 800)
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
Red flag to avoid:

Leaving it at 200 for every job, or confusing it with the number of partitions used when reading files.

They may ask next:
  • Does this setting affect how many partitions you get when you read Parquet files?
  • Why might you set it per job rather than once for the whole cluster?
Say it in 60 seconds

Caching & Memory 5 questions

Easy Technical round Fresher, Mid-level Practice question

10. What is the difference between cache and persist, and when does caching a DataFrame actually help?

What the interviewer is really testing:
Whether you know caching is lazy and only pays off when data is reused, not a general speed switch.
Answer frame:

cache: persist with the default level; for DataFrames that keeps data in memory and spills to disk.

persist: lets you choose the storage level, such as memory only or disk only.

When: the same DataFrame feeds more than one action, or an iterative loop.

Lazy: nothing is stored until an action runs; unpersist when you're done.

Sample spoken answer:

"Cache is just persist with the default storage level. For a DataFrame, that level keeps data in memory and spills to disk if it doesn't fit. Persist lets me pick the level myself, for example disk only when memory is tight. Caching is lazy, so nothing is stored until the first action runs on that DataFrame, and that first action pays the full cost. It only helps when I reuse the same result. If a cleaned table feeds three different aggregations and a write, caching it means Spark reads and cleans the source once instead of four times. If a DataFrame is used once, caching just wastes memory and can push other data out. I check the Storage tab in the Spark UI to see how much actually got cached, and I call unpersist when the reuse is over."

Code:
from pyspark import StorageLevel

clean = raw.filter("amount IS NOT NULL").select("user_id", "amount", "day")
clean.persist(StorageLevel.MEMORY_AND_DISK)
clean.count()                      # first action fills the cache
clean.groupBy("day").sum("amount").write.parquet("/out/by_day/")
clean.groupBy("user_id").count().write.parquet("/out/by_user/")
clean.unpersist()                  # after the last reuse
Red flag to avoid:

Saying cache runs immediately, or caching every DataFrame without checking whether it's reused.

They may ask next:
  • How would you confirm that a cached DataFrame is really being read from the cache?
  • What happens when cached data doesn't fit in executor memory?
Say it in 60 seconds
Medium Technical round Mid-level, Senior Practice question

11. Your job dies with an out-of-memory error on the driver, not the executors. What usually causes that?

What the interviewer is really testing:
Whether you know which operations pull data or metadata onto the single driver process.
Answer frame:

Pulling data back: collect, toPandas or a large take bring rows to the driver.

Broadcasts: a broadcast table is gathered on the driver before it's sent out.

Metadata: huge plans from loops, or millions of files and tasks to track.

Fixes: write results out, aggregate before collecting, stop the oversized broadcast, then raise driver memory if still needed.

Sample spoken answer:

"The driver is one process, so anything that gathers data there can sink it. The most common cause is collect or toPandas on a big DataFrame: every row travels to the driver, and in PySpark it then becomes Python objects, which take even more memory. The second is a broadcast join where the small side isn't small, because Spark gathers the table on the driver before sending it to executors. The third is metadata: listing millions of small files, tracking hundreds of thousands of tasks, or a plan that grew huge because I built columns in a long loop. My fix depends on which it is. I write results to storage instead of collecting, aggregate first if I really need data locally, turn off or lower the broadcast threshold, and only then raise driver memory or the max result size setting."

Red flag to avoid:

Only suggesting a bigger driver without asking what the job is pulling onto it.

They may ask next:
  • Why does calling withColumn hundreds of times in a loop slow down or break a job?
  • What does spark.driver.maxResultSize protect you from?
Say it in 60 seconds
Hard Technical round Senior Practice question

12. Executors keep failing with memory errors or being killed by the cluster. How does Spark split executor memory, and what would you tune?

What the interviewer is really testing:
Whether you can tell a heap out-of-memory from a container kill and match the fix to the cause.
Answer frame:

Heap: executor memory minus a small reserve; a fraction (0.6 by default) is shared by execution and storage.

Overhead: memory outside the heap, for native buffers and Python workers; its own setting.

Heap OOM fixes: smaller partitions, fewer cores per executor, fix skew, cache less.

Container killed: raise memory overhead, especially with Python or pandas UDFs.

Sample spoken answer:

"Inside each executor's heap, Spark keeps a small reserve, then gives a fraction of the rest, 0.6 by default, to a unified pool that execution and storage share. Execution is memory for shuffles, joins, sorts and aggregations; storage is cached data. Execution can evict cached blocks when it needs room, down to a protected storage share. The rest of the heap is user memory for my own objects. Separately, there's memory overhead outside the heap, which covers native buffers and, importantly for PySpark, the Python worker processes. So I first read the error. A Java heap out-of-memory usually means partitions are too big or skewed, so I add partitions, fix the skew, cut cores per executor so each task gets more memory, or cache less. If the cluster manager killed the container for exceeding its limit, that's usually overhead, so I raise spark.executor.memoryOverhead, especially when Python or pandas UDFs are involved."

Code:
spark-submit \
  --executor-memory 8g \
  --executor-cores 4 \
  --conf spark.executor.memoryOverhead=2g \
  --conf spark.sql.shuffle.partitions=800 \
  job.py
Red flag to avoid:

Answering every memory error with a bigger executor memory setting, without separating heap from overhead.

They may ask next:
  • Why can giving an executor more cores make memory errors worse?
  • What would you look for in the Spark UI to tell spill from a real memory shortage?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

13. What is the difference between cache and checkpoint, and when does a long lineage become a real problem?

What the interviewer is really testing:
Whether you understand lineage as both Spark's fault-tolerance tool and a cost that grows in iterative jobs.
Answer frame:

Lineage: Spark remembers how to rebuild each partition from its source.

Problem: in loops the plan keeps growing; planning slows, and a failure recomputes from the start.

cache: stores data but keeps the full lineage.

checkpoint: writes data to reliable storage and cuts the lineage; localCheckpoint is faster but not fault tolerant.

Sample spoken answer:

"Lineage is Spark's record of how every partition was built, so if an executor dies it can recompute just the lost pieces. That's great until the plan gets very long. In an iterative job, say fifty rounds of joins and updates in a loop, every round adds to the plan. The optimiser then takes longer each round, I can even hit a stack overflow, and a late failure means recomputing all the way back. Cache doesn't fix that, because it keeps the data but also keeps the full lineage. Checkpoint writes the data to a reliable directory, like HDFS or object storage, and replaces the plan with a simple read of those files. So in a loop I checkpoint every few iterations. localCheckpoint is quicker because it uses executor storage, but losing an executor then loses data. Writing to Parquet and reading back is the manual version of the same idea."

Code:
spark.sparkContext.setCheckpointDir("/checkpoints/rank-job")

df = base
for i in range(50):
    df = step(df)                  # a join and update each round
    if i % 10 == 9:
        df = df.checkpoint()       # write data, cut the plan
Red flag to avoid:

Saying cache and checkpoint are the same thing, or not knowing why iterative jobs get slower each round.

They may ask next:
  • How would you decide how often to checkpoint inside a loop?
  • How would you spot a plan that has grown too large before it fails?
Say it in 60 seconds
Medium Situational round Mid-level, Senior Practice question

14. In code review, a teammate has added cache() after almost every step of a pipeline to make it faster. How do you respond?

What the interviewer is really testing:
Whether you can explain the real cost of caching to a peer and back it with a measurement, not an opinion.
Answer frame:

Reuse test: caching helps only when a result is used by more than one action.

Cost: each cache takes executor memory, can evict others, and adds a write on first use.

Measure: compare run time and check the Storage tab, then keep only the caches that pay off.

Sample spoken answer:

"I'd start by asking which of those DataFrames are actually used more than once, because that's the only case where cache helps. In a straight pipeline that reads, transforms and writes once, every cache is pure cost. It takes executor memory that shuffles and joins need, it can push other cached data out, and the first action has to build and store it. It can even block some optimisations, because the plan reads from the cached data instead of pushing filters into the source files. So I'd suggest we keep caches only where a DataFrame feeds several outputs, add unpersist when it's done, and prove it with numbers: run it both ways and look at total time and the Storage tab. I'd frame it as a learning point, not a rejection, since caching is a common first instinct."

Red flag to avoid:

Approving it because caching sounds faster, or rejecting it without explaining when caching does help.

They may ask next:
  • If the pipeline has three branches that all start from one cleaned DataFrame, what would you do?
  • How would you check whether cached data actually fit in memory?
Say it in 60 seconds

Joins & Skew 3 questions

Medium Technical round Fresher, Mid-level, Senior Practice question

15. What is a broadcast join, when does Spark choose one, and when does it hurt you?

What the interviewer is really testing:
Whether you know the most useful join optimisation and its failure mode, not just the function name.
Answer frame:

How: the small table is copied to every executor, so the big table joins in place with no shuffle.

When: automatically below spark.sql.autoBroadcastJoinThreshold, about 10 MB by default, or with a hint.

Risk: the table is gathered on the driver first, so a large one causes memory errors and timeouts.

Sample spoken answer:

"In a normal join of two big tables, both sides are shuffled so matching keys land together. In a broadcast join, Spark sends a full copy of the small table to every executor, and each partition of the big table joins against it locally, so the big side never moves. That can turn a slow join into a fast one. Spark does it automatically when its size estimate for one side is under the broadcast threshold, about 10 megabytes by default, and I can force it with the broadcast function or a hint when I know the table is small after filtering. It hurts when the table isn't really small. It's collected on the driver and held in memory on every executor, so an oversized broadcast gives driver memory errors or broadcast timeouts. I also check explain to confirm I got a BroadcastHashJoin."

Code:
from pyspark.sql.functions import broadcast

orders.join(broadcast(countries), "country_code", "left").explain()
# look for BroadcastHashJoin in the plan
Red flag to avoid:

Broadcasting a table without checking its size, or not knowing that the driver collects it first.

They may ask next:
  • Why might Spark not broadcast a table you know is tiny?
  • Which side of a left outer join can be broadcast?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

16. One key holds a huge share of the rows, so a join has one task that runs forever. How does salting fix that?

What the interviewer is really testing:
Whether you can diagnose skew from the task view and apply a fix that keeps the join correct.
Answer frame:

Spot it: in the stage's task summary, the max duration and shuffle read are far above the median.

Salt: add a random number 0 to N-1 to the big side, and copy each row of the other side N times.

Join: join on key plus salt, so the hot key spreads over N tasks; drop the salt after.

Alternatives: broadcast the small side, split out the hot key, or let AQE's skew join handle it.

Sample spoken answer:

"First I confirm it's skew. In the stage's task summary, most tasks finish in seconds and one reads gigabytes and runs for ages. Often it's a null or default key. If it's nulls, they can't match anyway, so I split them off before the join and add them back after if it's a left join. If it's a real hot key, I salt. On the big side I add a salt column with a random number from zero to N minus one. On the smaller side I copy every row N times, once per salt value. Then I join on the key and the salt. Every big row still finds its match, because its salt value exists on the other side, but the hot key is now split across N tasks. The cost is that the small side grows N times, so I keep N modest. Before salting by hand, I check whether a broadcast join works or whether AQE's skew join handling already splits it."

Code:
from pyspark.sql import functions as F

N = 16
big_s = big.withColumn("salt", (F.rand() * N).cast("int"))
salts = spark.range(N).select(F.col("id").cast("int").alias("salt"))
small_s = small.crossJoin(salts)

joined = big_s.join(small_s, ["key", "salt"]).drop("salt")
Red flag to avoid:

Adding more executors or memory to fix a single slow task, or salting one side without copying the other.

They may ask next:
  • How would you salt a skewed groupBy aggregation instead of a join?
  • Why does salting only the big side of the join give wrong results?
Say it in 60 seconds
Hard Technical round Mid-level, Senior Practice question

17. What join strategies can Spark pick for a DataFrame join, and how do you check or influence which one it uses?

What the interviewer is really testing:
Whether you can reason about join cost and steer the planner, rather than accept whatever it chose.
Answer frame:

Broadcast hash: small side sent to all executors; no shuffle of the big side.

Sort-merge: both sides shuffled and sorted by key; the default for large equi-joins.

Shuffle hash: both shuffled, a hash table built per partition; no sort needed.

Nested loop: for joins without an equality condition; can be very slow.

Sample spoken answer:

"For an equality join, Spark has three main options. Broadcast hash join sends the small side to every executor and is the fastest when one side fits comfortably. Sort-merge join shuffles both sides by the join key, sorts each partition and merges them, and it's the default for two large tables because it can spill to disk and still finish. Shuffle hash join also shuffles both sides but builds a hash table per partition instead of sorting, which can win when one side is much smaller but too big to broadcast. For joins with no equality condition, like a range or inequality match, Spark falls back to a broadcast nested loop join or a cartesian product, which is where jobs blow up. I check the choice with explain, and I steer it with hints like broadcast, merge or shuffle_hash, or by adding an equality key so a range join isn't a nested loop."

Code:
a.join(b.hint("shuffle_hash"), "id").explain()
a.join(b.hint("merge"), "id").explain()
spark.sql("SELECT /*+ BROADCAST(d) */ * FROM facts f JOIN dims d ON f.dim_id = d.id")
Red flag to avoid:

Not knowing that sort-merge is the usual default for big tables, or writing non-equi joins without thinking about cost.

They may ask next:
  • How would you join events to time ranges without ending up in a nested loop join?
  • Why does Adaptive Query Execution sometimes change the join type after the job starts?
Say it in 60 seconds

DataFrame API 4 questions

Medium Technical round Fresher, Mid-level, Senior Practice question

18. Why are Python UDFs slow in PySpark, and what do you reach for instead?

What the interviewer is really testing:
Whether you know the cost of crossing from the JVM into Python and the order of alternatives to try.
Answer frame:

Cost: every row is serialised from the JVM to a Python worker and back, one at a time.

Blind spot: Catalyst can't see inside a UDF, so no pushdown or code generation through it.

First choice: built-in functions in pyspark.sql.functions, which run in the JVM.

Next: a pandas UDF, which moves data in Arrow batches and runs vectorised code.

Sample spoken answer:

"A regular Python UDF runs outside the JVM. Spark has to serialise each row, send it to a Python worker process, run my function, and send the result back. That's slow on its own, and the optimiser treats the UDF as a black box, so it can't push filters through it or generate efficient code around it. So my first move is always to rewrite the logic with built-in functions: string cleanup, dates, conditions with when and otherwise, regex, arrays and maps are almost all covered, and they run in the JVM. If I really need Python logic, like a library call, I use a pandas UDF. It sends data in Arrow batches and my function works on a whole pandas Series at a time, which is much faster than row by row, though still slower than a built-in. And I remember a UDF has to handle nulls itself."

Code:
from pyspark.sql import functions as F

# Slow: row-at-a-time Python UDF
clean = F.udf(lambda s: s.strip().lower() if s else None, "string")
df1 = df.withColumn("email", clean("email"))

# Fast: built-ins stay in the JVM
df2 = df.withColumn("email", F.lower(F.trim("email")))
Red flag to avoid:

Writing a Python UDF for something a built-in function already does, like lowercasing or parsing a date.

They may ask next:
  • What does Apache Arrow change about how data moves between the JVM and Python?
  • How would you handle a null input safely inside a Python UDF?
Say it in 60 seconds
Medium Coding round Fresher, Mid-level Practice question

19. Using the DataFrame API, keep only the most recent row for each customer from a table of customer updates.

What the interviewer is really testing:
Whether you reach for a window function correctly and think about ties, instead of trusting dropDuplicates to keep the right row.
Answer frame:

Window: partition by customer, order by update time descending.

Number: row_number over the window, keep row 1.

Ties: add a tie-breaker column; rank keeps ties, row_number picks one.

Why not dropDuplicates: it keeps an arbitrary row per key.

Sample spoken answer:

"I'd use a window. I partition by customer_id so each customer is handled on their own, and order by updated_at descending so the newest row comes first. Then row_number gives each row its position, and I keep only row one and drop the helper column. Two details matter. First, if two updates share the same timestamp, row_number picks one of them in a way I shouldn't rely on, so I add a tie-breaker like an ingestion id to the ordering. If I wanted to keep all tied rows I'd use rank instead. Second, I wouldn't use dropDuplicates on customer_id, because it keeps whichever row it happens to meet first, which isn't reliably the latest one. The window needs a shuffle by customer, which is fine, but a window with no partitionBy would pull everything into one partition."

Code:
from pyspark.sql import Window, functions as F

w = Window.partitionBy("customer_id").orderBy(
    F.col("updated_at").desc(), F.col("ingest_id").desc())

latest = (updates
          .withColumn("rn", F.row_number().over(w))
          .filter(F.col("rn") == 1)
          .drop("rn"))
Red flag to avoid:

Using dropDuplicates and assuming it keeps the newest row, or writing a window with no partitionBy on a large table.

They may ask next:
  • How would you do the same with a groupBy instead of a window, and which is cheaper?
  • What happens if updated_at is null for some rows?
Say it in 60 seconds
Medium Coding round Mid-level Practice question

20. For each user, add a running total of spend and the number of days since their previous order. Write it in PySpark.

What the interviewer is really testing:
Whether you can use window frames and offset functions correctly, including the default frame that trips people up.
Answer frame:

Window: partition by user, order by order date.

Running total: sum over a frame from unbounded preceding to the current row.

Gap: lag gives the previous order date; datediff turns it into days.

Frame trap: the default frame with orderBy is range based, so tied dates share one total.

Sample spoken answer:

"I define one window partitioned by user_id and ordered by order_date. For the running total I sum amount over that window, but I set the frame explicitly with rowsBetween, from unbounded preceding to the current row. If I leave the default, Spark uses a range frame when there's an ordering, so two orders on the same date would both show the total including each other, which surprises people. For the gap, lag gives me the previous row's order date within the same user, and datediff turns the two dates into a number of days. The first order for each user has no previous row, so lag returns null and the gap is null, which I'd keep rather than fake as zero. If orders can share a date, I'd also add an order id to the ordering so the result is stable."

Code:
from pyspark.sql import Window, functions as F

w = Window.partitionBy("user_id").orderBy("order_date", "order_id")
upto_now = w.rowsBetween(Window.unboundedPreceding, Window.currentRow)

out = (orders
       .withColumn("running_spend", F.sum("amount").over(upto_now))
       .withColumn("prev_date", F.lag("order_date").over(w))
       .withColumn("days_since_prev", F.datediff("order_date", "prev_date")))
Red flag to avoid:

Relying on the default window frame without knowing it's range based, or using a window with no partition on all users.

They may ask next:
  • How would you change this to a rolling seven-day total instead of a running one?
  • What happens to performance if one user has millions of orders?
Say it in 60 seconds
Easy Coding round Fresher Practice question

21. Given a sales DataFrame, show revenue and order count per region, keep only regions above a threshold, and sort highest first.

What the interviewer is really testing:
Whether you write clean aggregation code with aliases and know where filters belong before and after a groupBy.
Answer frame:

Aggregate: groupBy region, agg with sum and a count, each with an alias.

Filter after: a filter on the aggregate works like HAVING.

Filter before: row-level filters go before groupBy so less data is shuffled.

Sample spoken answer:

"I group by region and use agg so I can compute several things in one pass: the sum of amount as revenue, and a count of distinct order ids as orders, in case an order has several line rows. I alias each one so the columns have clean names. Then I filter on revenue, which works like a HAVING clause in SQL because it runs on the aggregated result, and order by revenue descending. If there were row-level conditions, like only completed sales, I'd put that filter before the groupBy, because then fewer rows get shuffled. One thing I'd mention on big data is that an exact distinct count is expensive. If an estimate is fine for a dashboard, approx_count_distinct is much cheaper."

Code:
from pyspark.sql import functions as F

result = (sales
          .filter(F.col("status") == "completed")
          .groupBy("region")
          .agg(F.sum("amount").alias("revenue"),
               F.countDistinct("order_id").alias("orders"))
          .filter(F.col("revenue") > 100000)
          .orderBy(F.col("revenue").desc()))
Red flag to avoid:

Filtering on an aggregate before it exists, or leaving auto-generated names like sum(amount) in the output.

They may ask next:
  • Why is countDistinct more expensive than count?
  • How would you add each region's share of total revenue as a column?
Say it in 60 seconds

Files & Storage 4 questions

Medium Technical round Fresher, Mid-level Practice question

22. Why is Parquet the usual file format for Spark tables, and what does partitionBy do when you write one?

What the interviewer is really testing:
Whether you understand how file layout lets Spark skip data, and how a bad partition column can backfire.
Answer frame:

Columnar: Spark reads only the columns a query needs, and data compresses well.

Pushdown: min and max stats per row group let the reader skip blocks that can't match.

partitionBy: one folder per value, like event_date=2026-09-01; filters on it skip whole folders.

Pick carefully: low-cardinality columns you filter on; never an id column.

Sample spoken answer:

"Parquet stores data by column, not by row. So a query that needs three columns out of fifty only reads those three, and similar values sit together, which compresses well. It also stores the schema and min and max statistics for chunks of rows, so Spark can push a filter down and skip chunks that can't match. partitionBy is about folders. When I write with partitionBy on event_date, Spark creates one directory per date and doesn't store that column inside the files. When someone later filters on event_date, Spark only lists and reads the matching folders, which is partition pruning. The choice of column matters. A date or country that queries filter on is good. A high-cardinality column like user_id is terrible, because it creates millions of folders full of tiny files."

Code:
(events.write
 .mode("overwrite")
 .partitionBy("event_date")
 .parquet("/data/events/"))

spark.read.parquet("/data/events/").filter("event_date = '2026-09-01'")
Red flag to avoid:

Partitioning on a unique id, or saying Parquet is faster without explaining column pruning or pushdown.

They may ask next:
  • What is the difference between partition pruning and predicate pushdown?
  • How would you handle a table where most queries filter on a column that isn't the partition column?
Say it in 60 seconds
Medium Technical round Mid-level, Senior Practice question

23. A table has hundreds of thousands of tiny Parquet files. Why is that a problem for Spark, and how do you fix and prevent it?

What the interviewer is really testing:
Whether you know where small files come from in your own writes and how to control the file count.
Answer frame:

Why it hurts: listing, opening and reading footers per file costs more than reading the data.

Causes: many shuffle partitions times many partition folders, frequent small appends, streaming batches.

Fix: a compaction job that rewrites each folder into fewer, larger files.

Prevent: repartition by the partition column before writing, and cap file size with maxRecordsPerFile.

Sample spoken answer:

"Small files hurt because the overhead is per file. Spark has to list them, open each one and read its footer, and on object storage every one of those is a network call, so a job can spend longer on metadata than on data. On HDFS they also load up the NameNode. They usually come from how we write. If a stage has 200 tasks and the data spans 100 dates with partitionBy, each task can write a file into every date folder, so I get up to twenty thousand files. Streaming jobs and frequent small appends do the same over time. To fix an existing table, I run a compaction job that reads each partition and rewrites it as a few large files. To prevent it, I repartition by the partition column before the write, so each date is written by a single task, and cap giant files with maxRecordsPerFile."

Code:
(df.repartition("event_date")
   .write
   .option("maxRecordsPerFile", 5_000_000)
   .partitionBy("event_date")
   .mode("append")
   .parquet("/data/events/"))
Red flag to avoid:

Only blaming the file system, or suggesting coalesce(1) on a large table as the fix.

They may ask next:
  • What goes wrong if one date holds most of the data and you repartition by date?
  • How would you compact a partition that other jobs are reading at the same time?
Say it in 60 seconds
Easy Technical round Fresher, Mid-level Practice question

24. Why would you define a schema yourself instead of letting Spark infer it when reading CSV or JSON files?

What the interviewer is really testing:
Whether you treat input as a contract, and know the speed and correctness costs of inference.
Answer frame:

Speed: inference needs an extra pass over the data before the real read.

Correctness: guessed types can drift between days, and leading zeros in codes get lost as numbers.

Control: read modes like FAILFAST or PERMISSIVE decide what happens to bad rows.

Sample spoken answer:

"With inferSchema on a CSV, Spark scans the data once just to guess the types, then reads it again for real, which is wasted time on large inputs. The bigger problem is that guesses can be wrong or change. A postal code column becomes an integer and loses its leading zeros. A column that's all empty today is typed as string, and tomorrow it's different, so a downstream job breaks. With an explicit schema, the read is faster and the types are a contract I control. I also set the read mode on purpose. PERMISSIVE, the default, turns bad values into nulls, and I can capture the raw bad line in a corrupt record column. FAILFAST stops the job on the first bad row, which I prefer for inputs that should never be malformed. I can write the schema as StructType or a short DDL string."

Code:
schema = "order_id STRING, zip STRING, quantity INT, created_at TIMESTAMP"

orders = (spark.read
          .option("header", True)
          .option("mode", "FAILFAST")
          .schema(schema)
          .csv("/data/orders/"))
Red flag to avoid:

Relying on inferSchema in a production pipeline, or not knowing what Spark does with a row that doesn't match the schema.

They may ask next:
  • How would you route malformed rows to a separate location instead of dropping them?
  • What happens if the files contain a column that isn't in your schema?
Say it in 60 seconds
Hard Situational round Mid-level, Senior Practice question

25. A daily job appends to a date-partitioned Parquet table. It failed halfway, was rerun, and now some days have duplicate rows. How do you make it safe to rerun?

What the interviewer is really testing:
Whether you design pipelines to be rerun safely, and know the overwrite trap that can wipe a whole table.
Answer frame:

Cause: append is not idempotent; a rerun adds the same rows again.

Fix: overwrite only the partitions being written, using dynamic partition overwrite mode.

Trap: in the default static mode, overwrite with partitionBy replaces the whole table.

Clean up: rebuild the damaged days, and add a duplicate check after the write.

Sample spoken answer:

"Append is the cause: a rerun writes the same rows a second time, and a half-failed run can leave partial files too. I'd change the job so each run owns its partitions. I set the partition overwrite mode to dynamic and write with mode overwrite, so Spark replaces only the date folders present in today's output and leaves every other day alone. Then running it once or five times gives the same result. I'd be careful here, because in the default static mode, overwrite with partitionBy deletes the entire table, which is a far worse incident than duplicates. For the damage already done, I'd rebuild the affected days from the source rather than trying to delete duplicates in place. If the team needs atomic replaces or row-level merges, a transactional table format like Iceberg or Delta Lake is the longer-term fix."

Code:
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")

(daily_df.write
 .mode("overwrite")
 .partitionBy("event_date")
 .parquet("/data/events/"))
Red flag to avoid:

Adding a dropDuplicates at read time as the fix, or switching to overwrite without knowing it can wipe every partition.

They may ask next:
  • How would you check the output for duplicates automatically after every run?
  • What if late data for yesterday arrives in today's batch?
Say it in 60 seconds

Tuning & Debugging 2 questions

Medium Technical round Mid-level, Senior Practice question

26. What does Adaptive Query Execution change while a query runs, and what problems does it solve for you?

What the interviewer is really testing:
Whether you know what Spark already fixes on its own, so you tune the right things by hand.
Answer frame:

Idea: re-plan at each shuffle boundary using real sizes instead of estimates.

Coalesce: merge many small shuffle partitions into fewer, right-sized ones.

Join switch: turn a sort-merge join into a broadcast join when one side turns out small.

Skew: split oversized partitions in sort-merge joins into smaller tasks.

Sample spoken answer:

"Without AQE, Spark picks the whole physical plan up front from estimates, which are often wrong after filters and joins. With AQE, which is on by default in recent Spark 3 versions, Spark pauses at each shuffle boundary, looks at the real size of every shuffle partition, and re-plans the rest. It does three main things for me. It merges small shuffle partitions, so I don't get thousands of tiny tasks. It can switch a sort-merge join to a broadcast join when one side turns out to be small after filtering. And it can split a skewed partition in a sort-merge join into several tasks. It has limits, though. It only acts at shuffle boundaries, and its skew handling is aimed at joins, so a skewed groupBy still needs me. In explain, the plan shows as AdaptiveSparkPlan, and the final plan is only known once the query has run."

Red flag to avoid:

Thinking AQE makes tuning unnecessary, or not knowing it only re-plans at shuffle boundaries.

They may ask next:
  • Why do you still need to set shuffle partitions sensibly when AQE is on?
  • How would you see in the Spark UI that AQE changed a join strategy?
Say it in 60 seconds
Hard Situational round Mid-level, Senior Practice question

27. A nightly PySpark job that took twenty minutes now takes three hours, and nobody changed the code. How do you work out why?

What the interviewer is really testing:
Whether you debug from evidence in the Spark UI in a sensible order, instead of adding resources and hoping.
Answer frame:

Compare: find which stage grew by checking the old run against the new one.

Inside the stage: max versus median task time, shuffle read, spill, input size and task count.

Plan change: did a broadcast join turn into a sort-merge join because a table grew?

Cluster: fewer executors granted, lost executors or long GC time.

Sample spoken answer:

"No code change means the data, the plan or the cluster changed, so I compare the slow run with a good one in the Spark UI or history server. First I find which stage got slower, because it's rarely all of them. Inside that stage I look at the task summary. If the max task takes an hour and the median takes a minute, it's skew, maybe a new hot key. If all tasks are slower and there's lots of spill, the data grew and partitions are too big. If the task count exploded, I check for small files upstream. Then I open the SQL tab and compare plans, because a lookup table that grew past the broadcast threshold turns a fast broadcast join into a full shuffle join. Last I check the Executors tab for lost executors, high GC time, or fewer executors than usual because the cluster was busy."

Red flag to avoid:

Doubling the cluster size first without looking at which stage slowed down or why.

They may ask next:
  • What would you change if you found the broadcast join had quietly become a sort-merge join?
  • How would you stop this from surprising you next time?
Say it in 60 seconds

Real Work 3 questions

Medium Behavioral round Mid-level, Senior Practice question

28. Tell me about a PySpark job you made noticeably faster. How did you find where the time was going?

What the interviewer is really testing:
Whether you tune from measurements and can explain cause and effect, not just list settings you changed.
Answer frame:

Situation: the job, its size and why its speed mattered.

Evidence: what the Spark UI or the plan showed.

Change: the specific fix and why it addressed the cause.

Result: before and after, and what you kept as a habit.

Sample spoken answer:

"At my last company we had a nightly job joining a day of clickstream events to a product table, and it had crept up to about two hours, which pushed our morning reports late. In the Spark UI one stage took almost all the time, and in its task summary the median task finished in under a minute while one task ran for over forty. That's skew. Checking the key counts, a big chunk of events had a null product id from an old tracking bug. Nulls can't match in the join anyway, so I split them off before the join and unioned them back after. I also noticed the product table was small once I selected only the columns we used, so I broadcast it and the big shuffle went away. The job dropped to under half an hour, and I started checking task-time spread on every new job."

Red flag to avoid:

Saying you just added more executors or memory, with no evidence of what was slow.

They may ask next:
  • How did you make sure the output was identical after your change?
  • What would you have done if the product table had been too big to broadcast?
Say it in 60 seconds
Hard Behavioral round Mid-level, Senior Practice question

29. Tell me about a time a Spark job failed in production with lost executors or memory errors. What did you find, and what did you change?

What the interviewer is really testing:
Whether you can read failure evidence under pressure and fix the cause, not just rerun with bigger settings.
Answer frame:

Symptom: what failed, and what the error and logs actually said.

Diagnosis: heap error or container kill, and which stage and code caused it.

Fix: the change and why it matched the cause.

Follow-through: alerting, docs or a check so it doesn't recur.

Sample spoken answer:

"In my previous team, a scoring job started failing after the input doubled. Executors kept getting lost and the retries failed too. The first thing I did was read the executor logs rather than just the driver's stack trace, and they showed the cluster manager was killing containers for exceeding their memory limit. It wasn't a Java heap error. That pointed outside the heap. The job used a pandas UDF to run a model, so each Python worker was holding big Arrow batches plus the model in memory, and that all counts as overhead. I raised the executor memory overhead, lowered the Arrow batch size so each Python call held fewer rows, and reduced cores per executor so fewer Python workers shared a container. It ran cleanly after that. I also added a note to our runbook on telling heap errors from container kills."

Red flag to avoid:

A story where the fix was to rerun it with double the memory and hope, with no diagnosis.

They may ask next:
  • How did you tell the difference between a heap error and a container being killed?
  • Would you have made any code changes instead of config changes?
Say it in 60 seconds
Medium Behavioral round Fresher, Mid-level Practice question

30. Tell me about moving a pandas or plain Python data script to PySpark. What did you have to rethink?

What the interviewer is really testing:
Whether you understand the mindset shift to distributed, lazy code, not just the API differences.
Answer frame:

Why move: the data outgrew one machine or the run time became a problem.

Rethink: no row order or index, no row loops, lazy execution, collect only at the end.

Checks: how you proved the new output matched the old one.

Sample spoken answer:

"At my last company I moved a pandas script that built daily user features, because the input had grown past what one machine could hold in memory. The biggest change was in how I thought, not the syntax. The pandas version looped over rows and used apply with Python functions everywhere, and a straight translation into UDFs was slow. I rewrote those parts with built-in column functions and window functions. I also had to drop assumptions about row order, because a DataFrame in Spark has no index and no guaranteed order unless I sort. And I learned to keep everything lazy and only write at the end, instead of calling collect to peek at results mid-script. To prove it was right, I ran both versions on a sample and compared the outputs row by row before switching over."

Red flag to avoid:

Translating pandas code line by line into Python UDFs and calling it done.

They may ask next:
  • Which pandas habit caused the most trouble in Spark, and why?
  • When would you keep a job in pandas rather than move it to Spark?
Say it in 60 seconds
Were you asked something else? Share it A person checks every question before it goes on the site. No name is shown.
For the call itself

The questions above are the prep. The call has ten more.

ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.

Download ClapAssist with 10 free minutes
Mac and Windows · Stays out of screen share · No card