Spark is a distributed engine that can keep intermediate data in memory. MapReduce writes to disk after every map and reduce step. Spark builds a DAG of many steps in one job. That is why iterative work is usually much faster on Spark.
Interview Q&A
โก PySpark
Spark jobs, shuffles, DataFrames, and window code.
95 questions ยท 40 theory ยท 55 coding
Q-PYS-001 Spark vs MapReduce
Answer
Explanation
MapReduce is one map plus one reduce per job. Spark can pipeline many narrow steps in one stage. Spark still writes shuffle files to disk and can spill when memory is full. In production, Spark is not always 100x faster. The big win is reuse and multi-step plans. A single-pass ETL job still spends a lot of time on disk I/O.
Trap
Saying "Spark is always 100x faster than Hadoop." That number is for iterative in-memory work, not every job.
Q-PYS-002 Driver vs executor
Answer
The driver is the brain of the Spark app. It builds the plan, talks to the cluster manager, and schedules tasks. Executors are JVM workers that run tasks and hold cached or shuffle data. If the driver dies, the whole app dies.
Explanation
Your main() and SparkSession live on the driver. Each executor has cores and memory. One task uses one core at a time and processes one partition. collect() sends data back to the driver, so a huge collect can OOM the driver even when executors are fine. In production, size driver memory for metadata, plans, and small collected results, not for the full dataset.
Trap
Thinking executors plan the job, or that the driver processes every row.
Q-PYS-003 SparkContext vs SparkSession
Answer
SparkContext is the older entry point for RDD and cluster connection. SparkSession is the unified entry point since Spark 2.0. It covers DataFrames, SQL, catalog, streaming, and still exposes spark.sparkContext. Use one session with getOrCreate().
Explanation
Before Spark 2, people created SparkContext, SQLContext, and HiveContext separately. SparkSession wraps that. In PySpark you almost always start with SparkSession. RDD code still needs the context, but you get it from the session. In production, do not create many sessions in one app.
Trap
Saying you need a SparkContext to use DataFrames. The session is the entry point. The context is inside it.
Q-PYS-004 What is a DAG in Spark?
Answer
A DAG is Spark's execution graph. Nodes are RDD or DataFrame steps. Edges are dependencies. Spark does not run each line as soon as you write it. An action turns the DAG into jobs, stages, and tasks.
Explanation
The DAGScheduler splits the graph at shuffle boundaries. Each stage is a set of tasks that can pipeline without a shuffle. Each task processes one partition. In production, a long DAG is fine if stages are healthy. A long lineage after many iterations can make recovery slow, which is why checkpoint exists.
Trap
Saying Spark runs your code line by line like pandas.
Q-PYS-005 What is lazy evaluation?
Answer
Transformations only add steps to a plan. Nothing reads data until an action. Actions like count(), show(), collect(), and write() trigger the job. Lazy planning lets Catalyst optimize the full pipeline first.
Explanation
filter then select does not run twice. Spark sees both and can push the filter and prune columns. That saves shuffle bytes and memory. The trap in notebooks is calling many actions, which recomputes the same DAG unless you cache. In production, one write action is usually the real trigger.
Trap
Thinking lazy evaluation means Spark is slow to start. Waiting is how Spark makes the plan cheaper.
Q-PYS-006 Transformation vs action
Answer
A transformation returns a new DataFrame or RDD and is lazy. An action triggers work and returns a result or writes data. filter, select, join, and groupBy are transformations. count, collect, show, and write are actions.
Explanation
If you only chain transformations, the cluster is idle. That is normal. groupBy().agg() is still a transformation until you call show or write. collect() and toPandas() pull data to the driver and can OOM. In production, prefer write actions over collecting full results.
Trap
Calling groupBy an action because it "computes." It only computes when an action follows.
Q-PYS-007 RDD vs DataFrame vs Dataset
Answer
An RDD is a low-level distributed collection with no column schema and no Catalyst optimizer. A DataFrame is rows with named columns, optimized by Catalyst and Tungsten. A Dataset is a typed JVM API for Scala and Java. PySpark has RDD and DataFrame, not Dataset.
Explanation
Use DataFrames for almost all ETL. Spark can prune columns, push filters, and pick joins. RDDs are for unstructured text or custom per-partition logic that DataFrames cannot express. Python UDFs on DataFrames can fall back toward row-by-row Python work and lose those optimizations. In production, default to DataFrame.
Trap
Saying Dataset is available in PySpark. It is not.
Q-PYS-008 What is lineage?
Answer
Lineage is the recipe of transformations that built a dataset. Spark stores that graph, not a full copy of every intermediate result. If a partition is lost, Spark recomputes it from the source using lineage.
Explanation
This is why RDDs are "resilient" without 3-way replication of every temp dataset. Short lineage is cheap to replay. Very long lineage, such as many ML iterations, makes recovery expensive. Checkpoint writes data to reliable storage and cuts the chain. Cache keeps data for reuse but does not truncate lineage the same way.
Trap
Thinking cache replaces lineage. Cache can be lost. Lineage is how Spark rebuilds lost partitions.
Q-PYS-009 What is a shuffle?
Answer
A shuffle moves records across the network so the next step can group, join, or sort by a new key. Spark writes shuffle files, ships bytes, then reads them into new partitions. It is usually the most expensive part of a job.
Explanation
Shuffle cost is serialize, disk write, network, deserialize, and often sort. Wide transformations cause shuffles. Narrow ones do not. In production, look at shuffle read and write bytes in Spark UI. Reduce shuffle by filtering early, broadcasting a small side, using reduceByKey style local aggregation, and avoiding extra repartition.
Trap
Saying shuffles are always bugs. Joins and aggregations need them. The goal is fewer unnecessary shuffles.
Q-PYS-010 Wide vs narrow transformations
Answer
A narrow transformation builds each output partition from one input partition. Examples are map, filter, and select. A wide transformation needs data from many partitions, so it shuffles. Examples are groupBy, join, distinct, and repartition.
Explanation
Narrow steps can pipeline in one stage. Wide steps create a stage boundary. coalesce to fewer partitions is usually narrow. repartition is wide. In production, count shuffle stages, not just lines of code. One extra wide join can dominate runtime and memory.
Trap
Calling coalesce a shuffle. Decreasing with coalesce merges local partitions and usually avoids a full shuffle.
Q-PYS-011 What is a partition?
Answer
A partition is a chunk of a DataFrame or RDD. Spark runs one task per partition. Partition count sets parallelism and how much data each task holds.
Explanation
Too few partitions means huge tasks, less parallelism, and OOM risk. Too many partitions means tiny tasks, scheduler overhead, and many small files on write. A common target is about 100-200 MB per partition after shuffle. AQE can coalesce small shuffle partitions at runtime. In production, size from bytes, not from a lucky number.
Trap
Thinking spark.sql.shuffle.partitions = 200 is always correct. 200 is a default, not a rule.
Q-PYS-012 coalesce vs repartition
Answer
repartition(n) does a full shuffle and can increase, decrease, or rebalance partitions. coalesce(n) only decreases partitions by merging adjacent ones and usually avoids a full shuffle. Use repartition when you need even size or a hash key. Use coalesce for a cheap shrink after a big filter.
Explanation
repartition("key") hash-partitions for later joins or writes. coalesce(1) makes one task and one file, which is a bottleneck on large data. Coalesce can leave uneven partitions because it does not reshuffle. In production, pick a file count from output size, then coalesce or repartition to that count before write.
Trap
Using coalesce(1) on a large DataFrame to "make one file." That serializes the whole write.
Q-PYS-013 cache vs persist
Answer
Both store computed data so later actions do not recompute the DAG. cache() uses a default storage level. persist() lets you pick memory, disk, serialized, or off-heap. Cache is lazy. The first action after cache() materializes it.
Explanation
Trap
Caching a DataFrame used only once, or thinking cache() runs immediately.
Q-PYS-014 What is a broadcast join?
Answer
A broadcast join sends the small table to every executor. Each executor joins locally with a hash map. The large side is not shuffled. This is the usual choice for a big fact table and a small dimension.
Explanation
Spark can auto-broadcast under spark.sql.autoBroadcastJoinThreshold (often 10 MB). You can force it with F.broadcast(small_df). Broadcasting a table that does not fit in driver or executor memory causes OOM. In production, confirm BroadcastHashJoin in explain. Sort-merge is safer when both sides are large.
Trap
Broadcasting "the smaller" table when it is still many gigabytes.
Q-PYS-015 What is Adaptive Query Execution (AQE)?
Answer
AQE is a Spark 3 feature that re-optimizes the plan at runtime using real shuffle stats. It can coalesce small shuffle partitions, switch a join to broadcast, and split skewed partitions. It is on by default from Spark 3.2.
Explanation
The first plan uses estimates. After a shuffle, Spark knows real sizes. That is when AQE rewrites. Enable with spark.sql.adaptive.enabled. AQE does not fix a bad scan, a Python UDF, or a huge collect. In production, still filter early and pick a sane join. Then let AQE clean leftover partition and skew issues.
Trap
Turning AQE on and assuming no tuning is ever needed.
Q-PYS-016 What is data skew?
Answer
Skew means a few keys or partitions hold most of the data. Most tasks finish fast. One task runs for a long time. Joins and group-bys on a hot key like US or customer_id = 0 are common causes.
Explanation
Prove skew in Spark UI: max task time much larger than median, or one shuffle partition much larger than others. Fixes include AQE skew join, broadcast the small side, salting, isolating the hot key, or two-phase aggregation. Skew is a memory risk too, because one task may build a huge hash map.
Trap
Adding more executors without checking task duration. Extra machines do not split one hot key.
Q-PYS-017 What is salting?
Answer
Salting splits a hot key into many keys by appending a random salt. The large side gets a random salt. The small side is exploded for every salt value. The join then uses the salted key so work spreads across tasks.
Explanation
If 80% of rows are country = US, one join partition does most of the shuffle. Salt turns US into US_0 ... US_9. That creates more partitions and more parallelism. The small side grows by the salt factor, so it must still fit. In production, try broadcast or AQE first. Salt when both sides are large and one key dominates.
Trap
Salting only one side and joining on the original key. Both sides must share the salted key.
Q-PYS-018 What is Tungsten?
Answer
Tungsten is Spark's physical execution engine. It stores rows in a compact binary format, can use off-heap memory, and generates JVM bytecode for whole stages. It cuts Java object overhead and GC pressure.
Explanation
DataFrames and Datasets use Tungsten. Plain RDD Python objects do not. Binary rows plus whole-stage codegen make CPU-bound SQL faster. Off-heap storage still uses RAM. It just avoids some JVM GC. In production, Python UDFs can leave Tungsten and slow the job.
Trap
Saying Tungsten is a cluster manager or a storage format like Parquet.
Q-PYS-019 What is Catalyst?
Answer
Catalyst is Spark SQL's query optimizer. It analyzes DataFrame and SQL plans, applies logical rules, then picks a physical plan. You see its work in explain().
Explanation
Phases are analysis, logical optimization, physical planning, then code generation. Rules include predicate pushdown, column pruning, constant folding, and join selection. Catalyst cannot see inside a Python UDF, so filters after a UDF may not push down. In production, inspect the physical plan, not just the source code.
Trap
Thinking Catalyst optimizes RDD map and filter the same way. RDD plans skip Catalyst.
Q-PYS-020 What is predicate pushdown?
Answer
Predicate pushdown moves a filter as close to the data source as possible. For Parquet and ORC, Spark can skip files, row groups, or partitions using min/max stats. You read less data before any shuffle.
Explanation
df.filter("year = 2024").select("id", "amount") can prune partitions and columns. A Python UDF filter often blocks pushdown because Spark cannot convert it to a source predicate. In production, filter and select early, use partitioned layouts, and keep types explicit so the predicate is valid.
Trap
Filtering after a UDF or after collect and thinking the source still skipped files.
Q-PYS-021 What is whole-stage codegen?
Answer
Whole-stage codegen fuses several physical operators into one generated JVM function. Spark does not call a virtual method per row for each operator. That improves CPU efficiency for supported SQL operators.
Explanation
It is a Tungsten feature applied after Catalyst picks a physical plan. It works well for filters, projections, and many aggregations. It may not apply across some joins, Python UDFs, or unsupported operators. In Spark UI, collapsed stages and *codegen* in the plan are clues. In production, prefer built-in functions so codegen can run.
Trap
Assuming every PySpark line is codegen'd, including Python lambdas.
Q-PYS-022 Spark UI stages vs tasks
Answer
A job is created by one action. A job splits into stages at shuffle boundaries. A stage has many tasks. Each task processes one partition. Spark UI shows this on the Jobs, Stages, and Tasks views.
Explanation
If a stage has 400 partitions, you see about 400 tasks. Look at task time distribution. A long max vs median is skew. High shuffle spill or GC on a stage is a memory signal. The SQL tab shows the plan. In production, debug the slow stage first, then the slow tasks inside it.
Trap
Calling a stage a single task, or thinking more stages always means a better job.
Q-PYS-023 Why do Spark jobs OOM?
Answer
OOM happens on the driver or on an executor, and those are different bugs. Driver OOM is often collect, toPandas, or a broadcast that is too large. Executor OOM is often a huge partition, a skewed key, an unbounded collect_list, or too much cache.
Explanation
Find the failed executor and stage in the UI and logs. Compare max task input and shuffle size with the median. Spark memory is split between execution and storage. Spill is a warning. It is not always a crash. Fixes include smaller partitions, salting, not broadcasting a big table, unpersisting cache, and avoiding groupByKey of huge lists.
Trap
Only increasing executor memory without locating driver vs executor and the hot partition.
Q-PYS-024 What is dynamic allocation?
Answer
Dynamic allocation lets Spark request and release executors while the app runs. Idle executors can go back to the cluster. Busy stages can get more executors. It is useful on shared YARN or Kubernetes clusters.
Explanation
Configs include spark.dynamicAllocation.enabled plus min, max, and idle timeout. Cached data on an executor can be lost if that executor is removed, so Spark may keep executors that hold cache. In production, set a max so one job cannot take the whole cluster. Speculative execution is a different feature.
Trap
Confusing dynamic allocation with AQE. AQE changes the plan. Dynamic allocation changes the number of executors.
Q-PYS-025 What is speculative execution?
Answer
Speculative execution launches a second copy of a slow task on another executor. Spark keeps the first result that finishes. It helps when one machine is sick or straggling, not when one key is huge.
Explanation
Enable with spark.speculation. It wastes some CPU because two tasks do the same work. If the task is slow because of skew, the duplicate is also slow. Fix skew instead. In production, use speculation for noisy clusters. Use AQE or salting for hot keys.
Trap
Turning on speculation to "fix skew." Speculation copies the same fat partition.
Q-PYS-026 What is checkpointing?
Answer
Checkpoint writes a dataset to reliable storage and cuts lineage. Later recovery starts from the checkpoint file, not from the original source. Streaming also uses checkpoints for offsets and state.
Explanation
Lineage is enough for short pipelines. Long iterative graphs and stateful streaming need a durable cut. rdd.checkpoint() is lazy until an action. A common pattern is cache then checkpoint so Spark does not compute twice. Checkpoint is slower than cache because it writes to HDFS or object storage.
Trap
Using cache when the interviewer asked how to truncate lineage. Cache does not cut the chain.
Q-PYS-027 What are accumulators?
Answer
Accumulators are counters or sums that executors can add to. Only the driver should read the value. They are useful for metrics like bad-record counts.
Explanation
Updates inside transformations can run more than once because Spark retries tasks. That double-counts. Prefer accumulators in actions such as foreach, or use DataFrame metrics instead. Accumulators are not a replacement for aggregations you need in the output table.
Trap
Reading accumulator values on executors, or trusting counts updated inside a map.
Q-PYS-028 What are broadcast variables?
Answer
A broadcast variable sends a read-only value to each executor once. Tasks on that executor share it. Use it for lookup maps, small configs, or a small join side.
Explanation
Without broadcast, Spark can ship a Python closure with every task. A 100 MB lookup times 1000 tasks is a lot of network. sc.broadcast(obj) then bc.value is the RDD pattern. DataFrame broadcast joins use the same idea. The object must fit in driver and executor memory.
Trap
Broadcasting a large DataFrame through a Python dict on the driver without checking size.
Q-PYS-029 What is Spark SQL?
Answer
Spark SQL is the module that runs SQL and DataFrame plans on Spark. spark.sql(...) and the DataFrame API share Catalyst and the same execution engine. Temp views let you mix both styles.
Explanation
df.createOrReplaceTempView("orders") then SQL is still a Spark job. There is no separate SQL cluster. Hive support lets Spark read metastore tables. In production, SQL and DataFrame quality is the same if the plan is the same. Check explain either way.
Trap
Thinking Spark SQL is only for Hive, or that SQL is slower than DataFrames by default.
Q-PYS-030 How does Spark use a Hive metastore?
Answer
With Hive support, Spark can use the Hive metastore as a catalog of databases, tables, and partitions. Spark then reads the files those tables point to. The metastore stores metadata, not the big data itself.
Explanation
enableHiveSupport() and a metastore URI are the usual setup. Spark can create managed or external tables. Concurrent writers still need a table format or process that handles that, such as Hive ACID or Delta. In production, treat the metastore as a contract for schema and location.
Trap
Saying Hive metastore stores the Parquet data. It stores names, schema, and file locations.
Q-PYS-031 What is schema merge?
Answer
Schema merge lets Spark combine files that do not have the same columns. Missing fields become null. Extra fields from newer files appear in the merged schema. Parquet merge is off by default because it is expensive.
Explanation
spark.read.option("mergeSchema", "true").parquet(path) scans file footers to build a union schema. That listing cost hurts when you have many small files. unionByName(allowMissingColumns=True) is the DataFrame version when you already have two frames. In production, prefer a controlled schema and additive columns over silent merge.
Trap
Leaving mergeSchema on for every huge lake path. Metadata listing can dominate the job.
Q-PYS-032 How does Spark handle bad records?
Answer
Readers have modes: PERMISSIVE keeps bad rows and fills nulls, DROPMALFORMED drops them, and FAILFAST fails the job. CSV and JSON also have a corrupt-record column option. Pick a mode that matches how strict the pipeline must be.
Explanation
PERMISSIVE is the usual default. Bad data can hide as nulls. FAILFAST is safer when a malformed file should page you. For production lakes, land raw files first, then quarantine bad rows into a dead-letter path. Do not silently drop keys you need for money or identity.
Trap
Using DROPMALFORMED in a finance pipeline and not logging what was dropped.
Q-PYS-033 How does AQE coalescing work?
Answer
AQE coalescing merges tiny shuffle partitions after Spark knows real sizes. You may start with 200 shuffle partitions. If the data is small, AQE combines them into fewer, larger tasks. That cuts scheduler overhead and small files.
Explanation
Configs include spark.sql.adaptive.coalescePartitions.enabled and advisoryPartitionSizeInBytes (often 64 MB). This is not the same as df.coalesce(). AQE runs after a shuffle stage. It cannot fix a scan that already created 50,000 tiny input files. In production, still compact writes. Let AQE clean shuffle leftovers.
Trap
Setting spark.sql.shuffle.partitions to 2 because AQE exists. Start reasonable. Let AQE shrink.
Q-PYS-034 What is Spark Connect?
Answer
Spark Connect splits the client from the Spark driver. Your notebook or app sends plans over a gRPC API. The remote cluster runs the driver and executors. The client does not embed a full JVM SparkSession.
Explanation
This helps thin clients, multiple languages, and remote IDE workflows. The DataFrame API still looks similar. Some older RDD and context APIs may not map 1:1. In production, treat Connect as a client protocol, not a new execution engine.
Trap
Saying Spark Connect replaces executors or Tungsten.
Q-PYS-035 What is the pandas API on Spark?
Answer
The pandas API on Spark (pyspark.pandas) lets you write pandas-like code that Spark executes as distributed plans. It is not pandas running on the driver. Some pandas operations still need a full shuffle or are unsupported.
Explanation
Use it when a team knows pandas but data does not fit on one machine. For new ETL, the DataFrame API is usually clearer and closer to Spark features. to_pandas() still collects to the driver and can OOM. In production, check the Spark plan behind the pandas-like calls.
Trap
Thinking import pyspark.pandas as ps is the same as import pandas as pd on a 1 TB table.
Q-PYS-036 Job vs stage vs task
Answer
One action creates one job. The job splits into stages at shuffles. Each stage has one task per partition. Tasks run in parallel on executor cores.
Explanation
df.count() is one job. If the plan has two shuffles, you may see three stages. Stage 1 writes shuffle files. Stage 2 reads them. This map is how you talk in interviews and how you read Spark UI. Memory problems show up at the task that holds the fat partition.
Trap
Using "job," "stage," and "task" as if they mean the same thing.
Q-PYS-037 Broadcast join vs sort-merge join
Answer
Broadcast hash join ships the small side and avoids shuffling the large side. Sort-merge join shuffles both sides by key, sorts, then merges. Use broadcast when one side safely fits in memory. Use sort-merge when both sides are large.
Explanation
Sort-merge is the default for large-large equi joins. It is robust but pays shuffle plus sort. AQE can switch to broadcast after a filter shrinks one side. Nested-loop joins are for non-equi conditions and can explode memory. Always verify the chosen strategy in explain.
Trap
Forcing broadcast on a growing dimension until the driver OOMs.
Q-PYS-038 reduceByKey vs groupByKey
Answer
reduceByKey combines values locally before the shuffle, then combines again. groupByKey shuffles every value for a key, then groups. For aggregations, reduceByKey or DataFrame groupBy().agg() uses less network and memory.
Explanation
groupByKey can build a huge in-memory list per key and OOM on skew. DataFrame groupBy is not the same as RDD groupByKey. Spark SQL aggregations can use partial maps. In production, never collect all events per user into one list unless the list is bounded.
Trap
Using groupByKey plus a Python sum and calling it the fast path.
Q-PYS-039 What is the small-files problem?
Answer
Many tiny files make listing, opening, and scanning slow. Each file has NameNode or object-store overhead and often becomes its own tiny task. Writes create one file per partition, so too many partitions create too many files.
Explanation
Streaming micro-batches are a common cause. Fix by coalescing or repartitioning before write, compacting later, or using a table format optimize command. Aim for files around 128-512 MB depending on the engine. In production, watch output file count as a first-class metric.
Trap
Raising executor count so the job writes even more 1 MB files.
Q-PYS-040 How do you tune shuffle partitions?
Answer
spark.sql.shuffle.partitions sets the default number of partitions after a shuffle. Size it from shuffle bytes, not from habit. AQE coalescing then merges leftovers. Too few partitions OOM. Too many partitions waste the scheduler.
Explanation
A simple starting math is shuffle size divided by 128-200 MB. 10 GB of shuffle is tens of partitions, not 2000. spark.default.parallelism is more of an RDD default and is a different knob. After you set a sane baseline, let AQE coalesce. Measure stage time and spill, then change one config.
Trap
Copying shuffle.partitions = 2000 from a 10 TB cluster onto a 2 GB job.
Q-PYS-041 Filter employees earning more than 50k
Answer
Filter on salary, keep the columns you need, then sort. filter is a narrow transformation. Nothing runs until an action such as show or write.
Explanation
This is the first DataFrame question in many interviews. Use column expressions, not a Python if over collect(). A filter can be pushed into Parquet or partition folders, which cuts scan and later shuffle bytes. In production, filter before joins.
Code
from pyspark.sql import functions as F
# keep rows where salary is above 50k
result = (
employees
.filter(F.col("salary") > 50000)
.select("emp_id", "name", "dept", "salary")
.orderBy(F.col("salary").desc())
)
What this code does
- It starts from the employees DataFrame.
- It keeps only rows with salary greater than 50000.
- It selects a small set of columns.
- It sorts by salary high to low.
Trap
Collecting all rows to pandas and then filtering. That kills the cluster on large data.
Q-PYS-042 Count orders and spend per customer
Answer
groupBy the customer, then agg count and sum. Aggregation is a wide transformation. It shuffles on the group key.
Explanation
Say the grain first: one row per customer. Alias the metrics so names are clear. Skewed customers can make one task huge. AQE or two-phase aggregation helps when one customer dominates.
Code
from pyspark.sql import functions as F
# one output row per customer
result = (
orders
.groupBy("customer_id")
.agg(
F.count("order_id").alias("total_orders"),
F.sum("amount").alias("total_spent"),
)
.orderBy(F.col("total_spent").desc())
)
What this code does
- It groups orders by customer_id.
- It counts orders and sums amount in the same aggregation.
- It sorts customers by total spend.
Trap
Using count("*") when you meant unique orders, or summing after a collect().
Q-PYS-043 Top N products per category with dense_rank
Answer
Aggregate spend first if needed. Then dense_rank inside each category. Keep ranks that are less than or equal to N. Ties stay in the top N.
Explanation
Window functions do not collapse rows. They add a rank column, then you filter. This shuffles by the partition key. dense_rank keeps ties. row_number would drop extras. In production, partition only by the group you rank on.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
# spend per category and product
totals = (
product_spend
.groupBy("category", "product")
.agg(F.sum("spend").alias("total_spend"))
)
# rank inside each category, highest spend first
w = Window.partitionBy("category").orderBy(F.col("total_spend").desc())
result = (
totals
.withColumn("rnk", F.dense_rank().over(w))
.filter(F.col("rnk") <= 2)
)
What this code does
- It sums spend per product in a category.
- It ranks those products inside the category.
- It keeps rank 1 and rank 2, including ties.
Trap
Using rank() when the interviewer wants both tied rows in the top 2. rank can skip numbers.
Q-PYS-044 Second highest salary
Answer
Dense-rank salaries descending. Filter rank 2. If every salary is the same, the result is empty and you should return null.
Explanation
dense_rank treats the same salary as one level. row_number would pick an arbitrary second row even when salaries tie. A global window still shuffles to sort. For a huge table, approx_percentile is a different question.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
# rank distinct salary levels from high to low
w = Window.orderBy(F.col("salary").desc())
ranked = employees.withColumn("rnk", F.dense_rank().over(w))
# keep only the second salary level
result = (
ranked
.filter(F.col("rnk") == 2)
.select("salary")
.distinct()
)
What this code does
- It ranks every employee by salary.
- Equal salaries share a rank.
- It keeps rows whose rank is 2 and returns that salary.
Trap
Using row_number == 2, which can return the second person, not the second salary.
Q-PYS-045 Running total of revenue
Answer
Use a window ordered by date. Sum from the start of the window through the current row. That is a running total.
Explanation
rowsBetween(unboundedPreceding, currentRow) is the usual frame. Without a frame, some functions still default in a surprising way, so write the frame. A global running total needs a sort and can be heavy. In production, partition by account or region if the total should reset.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
# running sum from the first date through today
w = (
Window
.orderBy("date")
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
)
result = revenue.withColumn("running_total", F.sum("amount").over(w))
What this code does
- It orders revenue by date.
- For each row it sums amount from the beginning through that row.
- It stores that sum as running_total.
Trap
Using rangeBetween on a timestamp and accidentally including many rows with the same date.
Q-PYS-046 7-day rolling average
Answer
Window-order by date. Average the current row plus the previous six rows. Say whether the window is 7 rows or 7 calendar days.
Explanation
rowsBetween(-6, 0) is 7 rows, not 7 calendar days. If dates are missing, that is a different window. rangeBetween on a unix timestamp can mean real days. Rolling windows shuffle and sort. In production, partition by store or user.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
# current day plus six previous rows
w = Window.orderBy("date").rowsBetween(-6, Window.currentRow)
result = daily_sales.withColumn(
"rolling_7d_avg",
F.round(F.avg("sales").over(w), 2),
)
What this code does
- It orders daily sales by date.
- It averages sales over seven rows ending at the current date.
- It rounds the rolling average to two decimals.
Trap
Saying it is 7 calendar days when the data has gaps and you used rowsBetween.
Q-PYS-047 Employees who earn more than their manager
Answer
Self-join employees to themselves. Match manager_id to emp_id. Then filter employee salary greater than manager salary.
Explanation
Alias both sides or column names collide. This join shuffles on the join key unless one side is tiny. Null manager_id is the CEO and should not match. In production, watch skewed manager ids if one person manages a huge org.
Code
from pyspark.sql import functions as F
# same table used twice: employee side and manager side
emp = employees.alias("e")
mgr = employees.alias("m")
result = (
emp.join(mgr, F.col("e.manager_id") == F.col("m.emp_id"), "inner")
.filter(F.col("e.salary") > F.col("m.salary"))
.select(
F.col("e.emp_id"),
F.col("e.name").alias("employee"),
F.col("e.salary").alias("emp_salary"),
F.col("m.name").alias("manager"),
F.col("m.salary").alias("mgr_salary"),
)
)
What this code does
- It joins each employee to the row of that employee's manager.
- It keeps employees whose salary is higher than the manager salary.
- It returns both names and both salaries.
Trap
Joining on name instead of id, or using a left join and comparing salaries when the manager columns are null.
Q-PYS-048 Customers with no orders using left_anti
Answer
left_anti keeps left rows that do not match the right table. That is the clean way to express "customers with no orders."
Explanation
A left join plus order_id IS NULL works, but left_anti is clearer and often planned well. left_semi is the opposite: left rows that do match. Anti joins still shuffle unless the right side is broadcast. Prefer anti join over NOT IN with nulls.
Code
from pyspark.sql import functions as F
# customers who never appear in orders
result = (
customers
.join(orders, "customer_id", "left_anti")
.select("customer_id", "name")
)
What this code does
- It looks for each customer in the orders table.
- It keeps only customers with zero matching orders.
- It returns those customer ids and names.
Trap
Using NOT IN (SELECT customer_id FROM orders) when customer_id can be null. SQL nulls can empty the result.
Q-PYS-049 dropDuplicates on selected columns
Answer
dropDuplicates(["email"]) keeps one row per email. Which row you keep is not guaranteed. If you need the latest row, use row_number, not dropDuplicates.
Explanation
dropDuplicates() with no columns is DISTINCT on all columns. Dedup shuffles. For production CDC, "one row" must mean "the latest by event time." Then window + filter rn == 1 is the right pattern.
Code
from pyspark.sql import functions as F
# one arbitrary row per email
unique_email = users.dropDuplicates(["email"])
# all columns identical
fully_unique = users.dropDuplicates()
What this code does
- The first call keeps one row for each email value.
- The second call keeps fully unique rows.
- Neither call promises the newest timestamp.
Trap
Using dropDuplicates(["user_id"]) and assuming you kept the latest profile update.
Q-PYS-050 Month-over-month revenue with lag
Answer
Order months, lag the previous revenue, then compute percent change. The first month has no previous value, so the change is null.
Explanation
lag(col, 1) reads the prior row in the window. Guard a zero previous revenue to avoid divide by zero. This window shuffles and sorts. Partition by product if MoM is per product, not global.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
# order months so lag can see the previous month
w = Window.orderBy("year_month")
result = (
monthly_revenue
# copy last month's revenue onto this row
.withColumn("prev_revenue", F.lag("revenue", 1).over(w))
.withColumn(
"mom_pct_change",
# percent change; nullif stops divide by zero
F.round(
(F.col("revenue") - F.col("prev_revenue"))
/ F.nullif(F.col("prev_revenue"), F.lit(0))
* 100,
2,
),
)
)
What this code does
- It orders revenue by year_month.
- It copies last month's revenue onto the current row.
- It computes percent change and turns a 0 previous into null.
Trap
Forgetting that the first row is null, or dividing by zero when previous revenue is 0.
Q-PYS-051 Pivot monthly sales from long to wide
Answer
groupBy the remaining keys, pivot the column that should become headers, then aggregate. Pass the list of pivot values when you know them.
Explanation
Pivot scans distinct values unless you pass the list. That extra distinct is a shuffle. Too many distinct months or ids makes a very wide row and can OOM. Unpivot is the reverse, usually with stack or explode.
Code
from pyspark.sql import functions as F
# tell Spark the month list so it skips a distinct scan
months = ["Jan", "Feb", "Mar", "Apr", "May"]
result = (
sales
.groupBy("product")
.pivot("month", months)
.agg(F.sum("revenue"))
)
What this code does
- It keeps one row per product.
- It turns each month value into a column.
- It fills those columns with summed revenue.
Trap
Pivoting a high-cardinality column such as user_id. The row becomes huge.
Q-PYS-052 Explode an array of tags and count them
Answer
explode makes one row per array element. Then groupBy the tag and count. Empty arrays drop out unless you use explode_outer.
Explanation
Explode can multiply rows and shuffle more bytes in the next aggregate. Null arrays with explode remove the parent row. In production, cap array size before explode so one bad row cannot blow memory.
Code
from pyspark.sql import functions as F
result = (
posts
# one row per tag in the array
.withColumn("tag", F.explode(F.col("tags")))
.groupBy("tag")
.agg(F.count(F.lit(1)).alias("usage_count"))
.orderBy(F.col("usage_count").desc())
.limit(5)
)
What this code does
- It turns each tags array into one row per tag.
- It counts how often each tag appears.
- It returns the five most used tags.
Trap
Using explode on null arrays and wondering why parent posts disappeared.
Q-PYS-053 Sessionize events with a 30-minute gap
Answer
Order events per user. Flag a new session when the gap is more than 30 minutes. A running sum of those flags becomes session_id.
Explanation
This is a gaps-and-islands pattern on time. lag gets the previous timestamp. Unix seconds make the 1800-second check easy. The cumulative sum window shuffles by user. Skewed bots with millions of events can OOM a single user partition.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
# previous event for the same user
w_order = Window.partitionBy("user_id").orderBy("event_time")
# running sum frame from first event through current
w_cum = (
Window
.partitionBy("user_id")
.orderBy("event_time")
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
)
flagged = (
events
.withColumn("prev_time", F.lag("event_time").over(w_order))
.withColumn(
"new_session",
# 1 when this is the first event or the gap is over 30 minutes
F.when(
F.col("prev_time").isNull()
| (
(F.unix_timestamp("event_time") - F.unix_timestamp("prev_time"))
> 1800
),
1,
).otherwise(0),
)
)
# cumulative flags become the session id
result = flagged.withColumn("session_id", F.sum("new_session").over(w_cum))
What this code does
- It finds the previous event time for the same user.
- It marks a new session when there is no previous event or the gap is over 30 minutes.
- It sums those flags over time so each session gets an id.
Trap
Using 30 rows instead of 30 minutes, or sessionizing without partitioning by user.
Q-PYS-054 Gaps and islands for consecutive purchase days
Answer
Deduplicate to one row per customer per date. Add a row number. date - row_number is constant inside a consecutive island. Count rows per island and keep streaks of length 3 or more.
Explanation
This is the classic islands trick. Consecutive dates share the same date_sub(date, rn) key. The window and the groupBy both shuffle. Distinct first, or same-day duplicates fake a streak. In production, store dates as date, not messy timestamps.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
# one row per customer per date so same-day orders do not fake a streak
deduped = orders.select("customer_id", "order_date").distinct()
w = Window.partitionBy("customer_id").orderBy("order_date")
numbered = deduped.withColumn("rn", F.row_number().over(w))
# consecutive dates share the same date minus row number
islands = numbered.withColumn(
"island_key",
F.expr("date_sub(order_date, rn)"),
)
result = (
islands
.groupBy("customer_id", "island_key")
.agg(F.count(F.lit(1)).alias("streak_len"))
.filter(F.col("streak_len") >= 3)
.select("customer_id")
.distinct()
)
What this code does
- It keeps one purchase date per customer.
- It numbers those dates and builds an island key.
- It keeps customers who have at least three days in one island.
Trap
Skipping distinct dates, so two orders on the same day look like two streak days.
Q-PYS-055 Market-basket product pairs
Answer
Self-join order lines on order_id with product_a < product_b. Then count pairs. The inequality stops (A,B) and (B,A) from both appearing.
Explanation
This join can explode. An order with n items makes n*(n-1)/2 pairs. Cap items per order or you will blow shuffle and memory. In production, aggregate to distinct products per order first.
Code
from pyspark.sql import functions as F
# same items table on both sides of a self join
o1 = order_items.alias("o1")
o2 = order_items.alias("o2")
result = (
o1.join(
o2,
# same order, and product_id < product_id so each pair appears once
(F.col("o1.order_id") == F.col("o2.order_id"))
& (F.col("o1.product_id") < F.col("o2.product_id")),
"inner",
)
.groupBy(
F.col("o1.product_id").alias("product_a"),
F.col("o2.product_id").alias("product_b"),
)
.agg(F.count(F.lit(1)).alias("co_purchase_count"))
.orderBy(F.col("co_purchase_count").desc())
.limit(5)
)
What this code does
- It joins items that share an order.
- It keeps each unordered pair once by using product_id less than.
- It counts pairs and returns the top five.
Trap
Joining without product_id < product_id, which doubles every pair and includes a product with itself.
Q-PYS-056 Funnel conversion rates
Answer
Count users, not events. Per user, flag whether they viewed, carted, and purchased. Sum those flags. Then divide stage counts.
Explanation
A user who views 10 times is still one viewer. max(when(...)) per user does that. groupBy event_type over-counts repeats. This is two aggregations and two shuffles. Collecting a 3-row summary to the driver is safe.
Code
from pyspark.sql import functions as F
# 0/1 flags per user so repeats do not count twice
funnel = events.groupBy("user_id").agg(
F.max(F.when(F.col("event_type") == "view", 1).otherwise(0)).alias("viewed"),
F.max(F.when(F.col("event_type") == "add_to_cart", 1).otherwise(0)).alias("carted"),
F.max(F.when(F.col("event_type") == "purchase", 1).otherwise(0)).alias("purchased"),
)
# unique users at each funnel stage
summary = funnel.agg(
F.sum("viewed").alias("total_views"),
F.sum("carted").alias("total_carted"),
F.sum("purchased").alias("total_purchased"),
)
What this code does
- It builds one row per user with 0/1 flags for each stage.
- It sums those flags to get unique users at each stage.
- Conversion rates are purchased / viewed and similar ratios.
Trap
Counting events instead of users, which inflates the top of the funnel.
Q-PYS-057 Flatten nested JSON
Answer
Parse JSON to a struct with from_json and an explicit schema. Then select nested fields with dot names or .*. Explode arrays after that.
Explanation
inferSchema on JSON is slow and unstable. Explicit schema is the production habit. Nested structs are still one column until you flatten. Deep explode of nested arrays can explode row count and memory.
Code
from pyspark.sql import functions as F
from pyspark.sql.types import IntegerType, StringType, StructField, StructType
# explicit nested schema so Spark does not infer
schema = StructType(
[
StructField("user_id", IntegerType(), True),
StructField(
"address",
StructType(
[
StructField("city", StringType(), True),
StructField("zip", StringType(), True),
]
),
True,
),
]
)
# parse the JSON string into a struct
parsed = json_events.withColumn("payload", F.from_json("json_str", schema))
# lift nested fields into flat columns
result = parsed.select(
F.col("payload.user_id").alias("user_id"),
F.col("payload.address.city").alias("city"),
F.col("payload.address.zip").alias("zip"),
)
What this code does
- It defines the nested JSON schema.
- It parses the string column into a struct.
- It lifts nested fields into flat columns.
Trap
Calling from_json without a schema and hoping inference stays stable in production.
Q-PYS-058 unionByName with evolving schemas
Answer
unionByName matches columns by name, not by position. allowMissingColumns=True fills missing fields with null. Plain union requires the same order and types.
Explanation
This is the DataFrame form of schema merge. Position-based union silently mis-assigns columns when a new field appears in the middle. In production, still enforce a target schema after the union so types do not drift.
Code
from pyspark.sql import functions as F
jan = spark.read.parquet("s3://bucket/data/month=01/")
feb = spark.read.parquet("s3://bucket/data/month=02/")
# align by name even if feb gained a new column
result = jan.unionByName(feb, allowMissingColumns=True)
What this code does
- It reads two monthly folders that may differ in columns.
- It stacks them by column name.
- Columns that exist on only one side become null on the other.
Trap
Using union when column order changed. Values land in the wrong columns.
Q-PYS-059 Salt a skewed join
Answer
Add a random salt to the large side's key. Explode the small side for every salt. Join on the salted key. Try broadcast or AQE first if one side is small.
Explanation
A hot key such as US lands in one shuffle partition. Salt splits it into N keys. The small side grows by N, so it must still be manageable. This extra explode is memory and shuffle cost. In production, measure task times before and after.
Code
from pyspark.sql import functions as F
salt_factor = 10
# split the hot country key into 10 salted keys
orders_salted = orders.withColumn(
"salted_key",
F.concat(
F.col("country_code"),
F.lit("_"),
(F.rand() * salt_factor).cast("int").cast("string"),
),
)
countries_exploded = (
countries
# copy the small side once per salt so keys still match
.withColumn("salt", F.explode(F.array(*[F.lit(i) for i in range(salt_factor)])))
.withColumn(
"salted_key",
F.concat(F.col("country_code"), F.lit("_"), F.col("salt").cast("string")),
)
)
result = orders_salted.join(countries_exploded, "salted_key", "inner").drop(
"salted_key",
"salt",
)
What this code does
- It appends a random 0-9 suffix to each orders country key.
- It copies each countries row once per suffix.
- It joins on the salted key so the hot country spreads across tasks.
Trap
Salting only the large table and still joining on country_code.
Q-PYS-060 Broadcast hint on a fact-dimension join
Answer
Wrap the small dimension with F.broadcast. Spark builds a hash map on every executor and does not shuffle the fact table. Confirm BroadcastHashJoin in explain.
Explanation
Auto-broadcast may not fire if stats are missing or the table is just over 10 MB. A hint fixes that. Broadcasting a 10 GB table OOMs the driver or executors. In production, this is the first join optimization for fact plus small dim.
Code
from pyspark.sql import functions as F
# force a broadcast hash join on the tiny store table
result = transactions.join(F.broadcast(store_metadata), "store_id")
# prove the plan
result.explain("formatted")
What this code does
- It marks store_metadata as a broadcast side.
- It joins transactions to stores on store_id without shuffling stores.
- It prints the physical plan so you can see BroadcastHashJoin.
Trap
Broadcasting the 100 million row fact table by accident.
Q-PYS-061 Write partitioned Parquet
Answer
partitionBy columns that you always filter on, such as date. Each partition value becomes a folder. File count still follows the number of Spark partitions inside each folder.
Explanation
Partitioning helps predicate pushdown. Over-partitioning on a high-cardinality column such as user_id creates millions of tiny folders. Repartition by the same columns before write if you need fewer files per folder. Use overwrite with care so you do not wipe the whole table.
Code
from pyspark.sql import functions as F
(
events
# derive the folder key from the timestamp
.withColumn("event_date", F.to_date("event_time"))
# shuffle so files inside each date folder are healthier
.repartition("event_date")
.write
.mode("overwrite")
.partitionBy("event_date")
.parquet("s3://lake/events/")
)
What this code does
- It derives a date column from the event timestamp.
- It shuffles so each date tends to sit together.
- It writes Parquet folders like
event_date=2024-01-01.
Trap
Partitioning by user_id and creating one folder per user.
Q-PYS-062 Watermark idea in Structured Streaming
Answer
A watermark tells Spark how late an event can be. After event_time minus the watermark, Spark can drop old state. Without a watermark, streaming group-bys grow forever.
Explanation
withWatermark("event_time", "10 minutes") means data 10 minutes late can still join or aggregate. Later than that may be ignored. Watermarks need a streaming source and an event-time column. They are not a batch filter. In production, pick the delay from real late-arrival metrics.
Code
from pyspark.sql import functions as F
# drop state older than 10 minutes behind max event time
clicks = (
spark.readStream.format("json").schema(click_schema).load("s3://bus/clicks/")
.withWatermark("event_time", "10 minutes")
)
result = (
clicks
.groupBy(F.window("event_time", "5 minutes"), F.col("campaign_id"))
.count()
)
What this code does
- It reads a streaming JSON source.
- It sets a 10-minute late-data allowance on event_time.
- It counts clicks in 5-minute event-time windows per campaign.
Trap
Grouping a stream without a watermark and leaking state until the job OOMs.
Q-PYS-063 Fill nulls and drop rows with a missing key
Answer
Drop rows where the business key is null. Fill remaining numeric and string nulls with defaults. Do not fill the primary key with a fake id.
Explanation
na.drop(subset=...) is row removal. na.fill is value replacement. Filling ids hides bad data. In production, send dropped rows to a quarantine path so you can measure data quality.
Code
from pyspark.sql import functions as F
result = (
df
# drop rows that have no business key
.na.drop(subset=["emp_id"])
# fill remaining nulls with defaults
.na.fill({"salary": 0.0, "dept": "Unknown"})
)
What this code does
- It removes rows with a null employee id.
- It sets null salaries to 0.
- It sets null departments to Unknown.
Trap
Filling emp_id with 0 and then grouping, which merges all bad rows into one fake employee.
Q-PYS-064 Prefer a built-in function over a Python UDF
Answer
Write the extra column with F.length or another built-in. A Python UDF sends rows to Python workers, blocks Catalyst, and often blocks codegen. Use a UDF only when no built-in exists.
Explanation
Built-ins stay in the JVM, can be pushed down, and use Tungsten. Python UDFs pay pickle and process hops. Pandas UDFs are better than row UDFs but still worse than native functions. In production, rewrite the UDF if Spark already has the function.
Code
from pyspark.sql import functions as F
from pyspark.sql.types import IntegerType
# slow path: Python UDF, shown only to contrast
@F.udf(IntegerType())
def py_len(text):
return len(text) if text is not None else None
slow = names.withColumn("n", py_len(F.col("name")))
# fast path: native expression
fast = names.withColumn("n", F.length(F.col("name")))
What this code does
- The UDF version computes length in Python per row.
- The built-in version computes length inside Spark.
- Both add a column n, but only the built-in stays fully optimizable.
Trap
Wrapping len in a UDF because it "looks like Python."
Q-PYS-065 when / otherwise salary bands
Answer
when chains are Spark's CASE WHEN. The first matching condition wins. otherwise catches the rest, including values that missed every band.
Explanation
Order matters. If you test >= 50000 before > 100000, high salaries become Mid. Null salary fails every comparison and falls into otherwise unless you handle isNull first. This is a narrow projection. No shuffle.
Code
from pyspark.sql import functions as F
result = employees.withColumn(
"salary_band",
# first matching when wins, so High must come before Mid
F.when(F.col("salary").isNull(), "Missing")
.when(F.col("salary") > 100000, "High")
.when(F.col("salary") >= 50000, "Mid")
.otherwise("Low"),
)
What this code does
- It labels null salaries as Missing.
- It labels salaries above 100k as High.
- It labels 50k to 100k as Mid and the rest as Low.
Trap
Putting the Mid condition first so every 150k salary is tagged Mid.
Q-PYS-066 Array contains, size, and filter
Answer
Use array functions instead of exploding when you only need a check, a length, or a filtered array. array_contains, size, and filter stay on the same row.
Explanation
Explode is for when you need one row per element. Array functions avoid that row explosion and extra shuffle. Higher-order filter/transform run in the JVM. In production, bound array size. Huge arrays in one row still OOM a task.
Code
from pyspark.sql import functions as F
result = (
posts
.withColumn("tag_count", F.size("tags"))
.withColumn("has_spark", F.array_contains("tags", "spark"))
.withColumn(
"short_tags",
# keep tags that are at most 10 characters, without exploding
F.filter(F.col("tags"), lambda t: F.length(t) <= 10),
)
)
What this code does
- It counts tags in the array.
- It flags rows whose array includes spark.
- It builds a new array of tags with length at most 10.
Trap
Exploding just to check membership, which multiplies rows for no reason.
Q-PYS-067 row_number vs rank vs dense_rank
Answer
row_number always unique 1,2,3. rank leaves gaps after ties. dense_rank shares the rank and does not skip. Pick the function from the business rule, not from habit.
Explanation
Top-N with ties usually wants dense_rank. Dedup "exactly one row" wants row_number. Competition ranking wants rank. The window shuffles by partition key. OrderBy must be deterministic or row_number is random among ties.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
w = Window.partitionBy("dept").orderBy(F.col("salary").desc())
result = (
employees
# unique sequence even when salaries tie
.withColumn("rn", F.row_number().over(w))
# competition rank with gaps
.withColumn("rk", F.rank().over(w))
# shared rank, no gaps
.withColumn("dr", F.dense_rank().over(w))
)
What this code does
- It computes three different ranks inside each department.
- row_number gives a unique sequence.
- rank and dense_rank show how ties are numbered.
Trap
Using rank <= 3 for "top 3 people" and returning 4 rows, or using row_number and dropping a tied third place.
Q-PYS-068 Keep the latest record per key
Answer
row_number partitioned by the business key, ordered by time descending, filter rn == 1. That keeps exactly one latest row.
Explanation
dropDuplicates does not honor time. rank can keep two rows if timestamps tie. Add a tie breaker such as an ingest id. This window shuffles by the key. It is the standard CDC "current row" pattern.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
# newest event first; event_id breaks timestamp ties
w = Window.partitionBy("user_id", "event_type").orderBy(
F.col("created_at").desc(),
F.col("event_id").desc(),
)
result = (
events
.withColumn("rn", F.row_number().over(w))
.filter(F.col("rn") == 1)
.drop("rn")
)
What this code does
- It numbers events per user and event type, newest first.
- It keeps only number 1.
- It drops the helper column.
Trap
Using dense_rank == 1 when two events share the same timestamp.
Q-PYS-069 Word count
Answer
Split text, explode words, group and count. Prefer DataFrames. The RDD flatMap / reduceByKey version is the classic talking point.
Explanation
reduceByKey combines locally before shuffle. groupByKey does not. The DataFrame path uses Catalyst. Clean punctuation or you count Spark. and Spark as two words. In production this is a toy. The lesson is local aggregation.
Code
from pyspark.sql import functions as F
result = (
spark.read.text("data/text_file.txt")
# lowercase, split on whitespace, then one row per word
.withColumn("word", F.explode(F.split(F.lower(F.col("value")), r"\s+")))
.filter(F.col("word") != "")
.groupBy("word")
.count()
.orderBy(F.col("count").desc())
.limit(10)
)
What this code does
- It reads each line as a value column.
- It splits and explodes words to lowercase.
- It counts words and returns the top 10.
Trap
Using RDD groupByKey to count, which shuffles every 1.
Q-PYS-070 Max salary per department
Answer
groupBy dept and max(salary). If you also need the employee name, that is a different question: join back or use a window.
Explanation
max in agg returns one metric row per dept, not the full employee row. People often want the person who has that salary. Then use row_number or join the max back. Aggregation shuffles on dept.
Code
from pyspark.sql import functions as F
result = (
employees
.groupBy("dept")
# one metric per department, not the full employee row
.agg(F.max("salary").alias("max_salary"))
)
What this code does
- It groups employees by department.
- It computes the highest salary in each group.
- It does not return the employee name.
Trap
Writing groupBy("dept").max() and thinking you still have name.
Q-PYS-071 Customers who placed at least one order using left_semi
Answer
left_semi keeps left rows that have a match on the right. It is an existence join. It does not duplicate the customer if they have many orders.
Explanation
An inner join on orders would repeat the customer once per order. Semi join is the efficient "exists" form. Anti join is "not exists." Both still shuffle or broadcast the right side.
Code
from pyspark.sql import functions as F
# keep customers that exist in orders, without duplicating them
result = customers.join(orders, "customer_id", "left_semi")
What this code does
- It checks whether each customer appears in orders.
- It keeps customers who appear at least once.
- It does not copy order columns or duplicate customers.
Trap
Inner-joining then distinct, which is extra shuffle for an existence check.
Q-PYS-072 collect_list of products per order
Answer
groupBy order_id and collect_list(product_id) builds an array. Bound the list. Unbounded collect on a hot key is a common OOM.
Explanation
collect_list keeps duplicates and is unordered unless you use a window. collect_set dedups. This aggregation can hold a whole group in memory. In production, cap with slice or avoid collecting huge event histories.
Code
from pyspark.sql import functions as F
result = (
order_items
.groupBy("order_id")
.agg(F.collect_list("product_id").alias("products"))
# cap the array so one fat order cannot OOM the task
.withColumn("products", F.slice("products", 1, 100))
)
What this code does
- It groups items by order.
- It gathers product ids into an array.
- It keeps at most 100 products so one fat order cannot explode memory.
Trap
collect_list of all clicks per user for a year on a skewed user.
Q-PYS-073 Monthly revenue with date_trunc
Answer
Truncate the timestamp to month, then group and sum. date_trunc keeps a date/timestamp, which sorts better than a string.
Explanation
date_format to 'yyyy-MM' also works but is a string. Truncate before grouping so the shuffle key is the month. In production, also decide timezone. UTC vs local can move late events to the next month.
Code
from pyspark.sql import functions as F
result = (
orders
# month grain as a timestamp, not a string
.withColumn("month", F.date_trunc("month", F.col("order_ts")))
.groupBy("month")
.agg(F.sum("amount").alias("revenue"))
.orderBy("month")
)
What this code does
- It maps each order timestamp to the first instant of its month.
- It sums amount per month.
- It sorts months in time order.
Trap
Grouping on order_ts itself and getting one group per second.
Q-PYS-074 Temperature higher than the previous calendar day
Answer
lag the previous temperature and date. Require datediff == 1. A missing day must not compare against two days ago unless the problem says so.
Explanation
Windows compare rows, not calendars, unless you check the date gap. This is a common LeetCode-style Spark question. A global orderBy shuffles the whole weather table.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
w = Window.orderBy("record_date")
result = (
weather
.withColumn("prev_temp", F.lag("temperature").over(w))
.withColumn("prev_date", F.lag("record_date").over(w))
.filter(
# hotter than the previous row, and that row is yesterday
(F.col("temperature") > F.col("prev_temp"))
& (F.datediff(F.col("record_date"), F.col("prev_date")) == 1)
)
)
What this code does
- It fetches yesterday's row using lag.
- It checks that the previous row is exactly one calendar day earlier.
- It keeps days hotter than that previous day.
Trap
Comparing to the previous recorded row when weekends are missing.
Q-PYS-075 Longest consecutive login streak
Answer
Same islands pattern as consecutive purchases. Dedup dates, build date_sub(login_date, rn), count island length, then max per user.
Explanation
Streak questions are islands questions. Distinct dates first. Then window plus groupBy. Skewed power users with years of daily logins still fit if you only store one row per day. Do not explode session events into the streak.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
# one login day per user
deduped = logins.select("user_id", "login_date").distinct()
w = Window.partitionBy("user_id").orderBy("login_date")
# island key is constant for consecutive dates
islands = deduped.withColumn("rn", F.row_number().over(w)).withColumn(
"island_key",
F.expr("date_sub(login_date, rn)"),
)
result = (
islands
.groupBy("user_id", "island_key")
.agg(F.count(F.lit(1)).alias("streak_len"))
.groupBy("user_id")
.agg(F.max("streak_len").alias("longest_streak"))
)
What this code does
- It keeps one login date per user.
- It groups consecutive dates into islands.
- It takes the longest island per user.
Trap
Using event timestamps without truncating to date, so two logins the same day break or inflate the streak.
Q-PYS-076 Parse a JSON column with from_json
Answer
Build a StructType, call from_json, then select fields. Bad JSON becomes null in permissive mode instead of crashing, unless you choose failfast at read time.
Explanation
This is for a string column that happens to hold JSON, not for spark.read.json on files. Explicit schema avoids a second inference pass. In production, log null payload rates. That is your bad-record metric.
Code
from pyspark.sql import functions as F
from pyspark.sql.types import StringType, StructField, StructType
# contract for the JSON payload
event_schema = StructType(
[
StructField("event_id", StringType(), True),
StructField("user_id", StringType(), True),
]
)
# parse once, then select inner fields
result = raw.withColumn("event", F.from_json("payload", event_schema)).select(
"event.event_id",
"event.user_id",
)
What this code does
- It declares the expected JSON fields.
- It parses payload into a struct.
- It selects the inner fields as columns.
Trap
get_json_object for every field on a large table when a single from_json would parse once.
Q-PYS-077 Transform array elements with a higher-order function
Answer
transform applies an expression to each array element and returns a new array. Stay in Spark functions. Do not explode-map-collect unless you must.
Explanation
Higher-order functions avoid a shuffle that explode plus groupBy would need. They still run per row, so a 10 million element array is a memory problem. Prefer them over Python UDFs that loop lists.
Code
from pyspark.sql import functions as F
result = items.withColumn(
"prices_cents",
# convert each array element in place, no explode
F.transform(F.col("prices"), lambda p: (p * 100).cast("int")),
)
What this code does
- It reads an array column of prices.
- It multiplies each element by 100.
- It stores the new array as integer cents.
Trap
Exploding the array, multiplying, then collect_list, which shuffles and can reorder.
Q-PYS-078 Vectorized pandas UDF
Answer
A pandas UDF processes Arrow batches, not one Python row at a time. It is faster than a row UDF, still slower than a built-in, and it still hides the logic from Catalyst.
Explanation
Use pandas UDFs for vector math Spark cannot express. You pay Arrow conversion and Python workers. They can increase executor memory. If F.cos exists, use it. In production, measure CPU and memory against a native rewrite.
Code
from pyspark.sql import functions as F
from pyspark.sql.functions import pandas_udf
import pandas as pd
@pandas_udf("double")
def scaled_score(s: pd.Series) -> pd.Series:
# vector math on a pandas Series batch
return (s - s.mean()) / s.std(ddof=0)
result = scores.withColumn("z", scaled_score(F.col("score")))
What this code does
- It declares a pandas UDF that returns doubles.
- It standardizes a batch of scores.
- It adds a z column using Python vector code instead of a per-row UDF.
Trap
Writing a row-at-a-time @udf and calling it a pandas UDF.
Q-PYS-079 First and last purchase per customer
Answer
Window first and last with a full partition frame, or aggregate min/max of dates. Aggregation is simpler if you only need the dates, not the whole row.
Explanation
first without ignorenulls can return null. For the full first-order row, row_number ascending and descending is clearer. Two windows still shuffle once per partition key if they share the spec.
Code
from pyspark.sql import functions as F
result = orders.groupBy("customer_id").agg(
F.min("order_date").alias("first_purchase"),
F.max("order_date").alias("last_purchase"),
) # earliest and latest dates only, not full order rows
What this code does
- It groups orders by customer.
- It takes the earliest order date.
- It takes the latest order date.
Trap
Using first("order_date") without ordering and thinking it is the earliest purchase.
Q-PYS-080 Approx distinct users
Answer
approx_count_distinct uses HyperLogLog. It is cheaper than countDistinct on high-cardinality keys. Tell the interviewer the result is approximate.
Explanation
Exact countDistinct shuffles a lot of unique values. Approx is the production default for dashboards. You can pass a relative standard deviation. Do not use approx for money or billing uniqueness.
Code
from pyspark.sql import functions as F
result = events.groupBy("country").agg(
# cheap HyperLogLog estimate
F.approx_count_distinct("user_id").alias("approx_users"),
# exact distinct, more shuffle
F.countDistinct("user_id").alias("exact_users"),
)
What this code does
- It groups events by country.
- It estimates unique users with HyperLogLog.
- It also computes exact distinct for comparison.
Trap
Using countDistinct on a 2 billion row click stream for a daily dashboard.
Q-PYS-081 Rollup subtotals
Answer
rollup adds subtotal and grand-total rows. cube adds all combinations. Null grouping columns in the output are those totals, unless you also had real nulls.
Explanation
This is Spark SQL grouping sets. It is more than one groupBy. Extra totals mean extra aggregation work and shuffle. In production, replace null totals with labels using grouping() so they are not confused with unknown country.
Code
from pyspark.sql import functions as F
result = (
sales
# country+product, country subtotal, and grand total
.rollup("country", "product")
.agg(F.sum("amount").alias("revenue"))
.withColumn(
"grain",
# grouping_id tells which total row this is
F.when(F.grouping_id() == 0, "country_product")
.when(F.grouping_id() == 1, "country_total")
.otherwise("grand_total"),
)
)
What this code does
- It aggregates revenue at country plus product.
- It also emits country subtotals and a grand total.
- It labels those extra rows using grouping_id.
Trap
Filtering country IS NOT NULL after rollup and deleting the grand total by accident.
Q-PYS-082 Range condition join
Answer
A non-equi join such as event_time between start and end cannot use a simple sort-merge equi join. Broadcast the small side if you can. Otherwise this can become a nested loop.
Explanation
Range joins are a common production surprise. Spark may pick BroadcastNestedLoopJoin. That is O(n*m) in the worst case. Bin events into time buckets and join on bucket plus a residual filter to make an equi join. Check the plan.
Code
from pyspark.sql import functions as F
result = events.join(
# broadcast the small campaign windows
F.broadcast(campaigns),
# range condition, not an equi join
(events["event_time"] >= campaigns["start_ts"])
& (events["event_time"] < campaigns["end_ts"]),
"inner",
)
What this code does
- It broadcasts the small campaigns table.
- It keeps events whose time falls in a campaign window.
- It avoids shuffling campaigns.
Trap
Cross-joining two large time tables with only >= and < and no broadcast.
Q-PYS-083 Find duplicate emails
Answer
Group by email, count, keep count greater than 1. Join back if you need the full rows.
Explanation
This is a shuffle aggregation. An inner join back to users can duplicate the count onto every matching row. For huge tables, write the duplicate keys first, then join.
Code
from pyspark.sql import functions as F
duplicates = (
users
.groupBy("email")
.agg(F.count(F.lit(1)).alias("cnt"))
.filter(F.col("cnt") > 1)
)
# join back to return the full duplicate rows
result = users.join(duplicates, "email", "inner")
What this code does
- It counts rows per email.
- It keeps emails that appear more than once.
- It joins back to return the full duplicate rows.
Trap
dropDuplicates when the interviewer asked you to show the duplicates.
Q-PYS-084 Track file origin with input_file_name
Answer
input_file_name() adds the source path as a column. Use it when you union many files and need lineage for bad rows.
Explanation
This is a metadata column, not a shuffle. It helps quarantine. On some sources it can be empty, so test it. In production, store it in bronze tables.
Code
from pyspark.sql import functions as F
result = (
spark.read.parquet("s3://bucket/data/year=2024/month=*/")
# record which file each row came from
.withColumn("source_file", F.input_file_name())
)
What this code does
- It reads every matching Parquet path.
- It records which file each row came from.
- Later quality checks can group errors by source_file.
Trap
Parsing file names from a collect() of paths on the driver for a billion-row table.
Q-PYS-085 Recursively read a directory tree
Answer
Set recursiveFileLookup to true, or use a glob. Combine with a path suffix filter so you do not ingest _SUCCESS or random files.
Explanation
Object stores can hide deep date folders. Recursive listing has a metadata cost. Many tiny files will still hurt. Prefer Hive-style partitions when you can prune.
Code
from pyspark.sql import functions as F
result = (
spark.read
# walk nested folders
.option("recursiveFileLookup", "true")
# skip non-parquet files in the tree
.option("pathGlobFilter", "*.parquet")
.parquet("s3://bucket/raw/events/")
)
What this code does
- It walks nested folders under the events prefix.
- It only loads files that match
*.parquet. - It returns one DataFrame of those files.
Trap
Recursive read of a whole bucket including logs and tmp files.
Q-PYS-086 Streaming aggregation with watermark
Answer
Read a stream, set a watermark on event time, group by window and key, then write with checkpointing. The watermark lets Spark forget old windows.
Explanation
Batch groupBy does not need a watermark. Streaming state does. Checkpoint location stores offsets and state. Without it, a restart duplicates or drops data. Output mode is usually append for windowed aggregations with watermarks.
Code
from pyspark.sql import functions as F
stream = (
spark.readStream.format("json").schema(event_schema).load("s3://bus/events/")
# allow 15 minutes of late events, then drop old state
.withWatermark("event_time", "15 minutes")
)
agg = stream.groupBy(
F.window("event_time", "10 minutes"),
F.col("country"),
).count()
query = (
agg.writeStream
.format("parquet")
.option("path", "s3://lake/agg/")
# checkpoint stores offsets and aggregation state
.option("checkpointLocation", "s3://lake/checkpoints/agg/")
.outputMode("append")
.start()
)
What this code does
- It reads JSON as a stream and allows 15 minutes of lateness.
- It counts events in 10-minute windows per country.
- It writes Parquet and stores streaming state in a checkpoint.
Trap
Forgetting checkpointLocation, so every restart reprocesses from scratch or duplicates.
Q-PYS-087 posexplode an array
Answer
posexplode returns the index and the element. Use it when order in the array matters, such as the first product in a basket.
Explanation
explode drops the position. posexplode_outer keeps empty arrays. Positions start at 0. The row count still grows, so the same memory warning as explode applies.
Code
from pyspark.sql import functions as F
result = baskets.select(
"order_id",
# keep both the array index and the product
F.posexplode("products").alias("item_index", "product_id"),
)
What this code does
- It expands each products array.
- It keeps the position of the item in the array.
- It keeps the product id at that position.
Trap
Using explode and then monotonically_increasing_id as if that were the array index.
Q-PYS-088 Explode a map column
Answer
explode on a map yields key and value columns. You can also use map_keys and map_values if you do not need row explosion.
Explanation
Maps are not ordered. Do not treat keys as a stable schema. If keys are really columns, pivot after explode. Huge maps in one row are another OOM pattern.
Code
from pyspark.sql import functions as F
result = (
events
# map explode yields key and value columns
.select("event_id", F.explode("properties").alias("k", "v"))
.groupBy("k")
.count()
)
What this code does
- It turns each map entry into a row with k and v.
- It groups by the map key name.
- It counts how often each property appears.
Trap
Selecting properties.key as if a map were a struct with fixed fields.
Q-PYS-089 coalesce to the first non-null column
Answer
F.coalesce(a, b, c) returns the first non-null argument. It is a column function. It is not DataFrame.coalesce(n).
Explanation
This is one of the most common name collisions in Spark interviews. F.coalesce is SQL COALESCE. df.coalesce(n) changes partition count. Mixing them up is an instant trap.
Code
from pyspark.sql import functions as F
result = contacts.withColumn(
"best_phone",
# first non-null phone; this is not df.coalesce(n)
F.coalesce("mobile", "home", "work"),
)
What this code does
- It looks at mobile first.
- If mobile is null it uses home, then work.
- It stores that value as best_phone.
Trap
Calling df.coalesce("mobile", "home") and thinking you filled nulls. That API wants a partition count.
Q-PYS-090 ntile buckets
Answer
ntile(4) assigns quartile buckets inside a window. Rows are as even as possible. It is not a statistical percentile function.
Explanation
Use ntile for even-sized groups. Use percent_rank or approx_percentile for value-based cuts. The window sort shuffles. Skewed values can put the same amount in different tiles.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
w = Window.orderBy("amount")
# four even-sized buckets by row count, not by value cut
result = orders.withColumn("quartile", F.ntile(4).over(w))
What this code does
- It sorts orders by amount.
- It splits that ordered list into four buckets.
- It labels each row with quartile 1 to 4.
Trap
Treating ntile as percentile <= 0.25. Bucket sizes are about row counts, not value cuts.
Q-PYS-091 Lead the next purchase date
Answer
lead looks forward in the ordered window. Use it for next-event and time-to-next-purchase questions. The last row per customer is null.
Explanation
lag looks back. lead looks forward. Partition by customer or you will leak across users. Datediff then gives conversion delay. This is a shuffle sort per customer.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
w = Window.partitionBy("customer_id").orderBy("order_date")
result = (
orders
# next purchase for the same customer
.withColumn("next_order_date", F.lead("order_date", 1).over(w))
.withColumn(
"days_to_next",
F.datediff(F.col("next_order_date"), F.col("order_date")),
)
)
What this code does
- It orders each customer's purchases by date.
- It copies the next purchase date onto the current row.
- It computes the gap in days.
Trap
Using lead without partitionBy("customer_id"), mixing one customer's next date with another customer.
Q-PYS-092 Percent of total with a window sum
Answer
A window sum with no extra order computes the group total on every row. Divide the row amount by that total. You keep row grain, unlike groupBy.
Explanation
This is why windows exist: add group context without collapsing. partitionBy country gives country share. An empty partition spec gives global share. The window still shuffles by the partition key.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
# country total on every row, without collapsing grain
w = Window.partitionBy("country")
result = sales.withColumn(
"pct_of_country",
F.round(F.col("amount") / F.sum("amount").over(w) * 100, 2),
)
What this code does
- It computes total amount per country on every row.
- It divides the row amount by that total.
- It stores a percent of country sales.
Trap
Grouping first and losing the product-level rows the interviewer wanted.
Q-PYS-093 SCD type 2 current flag with a window
Answer
Explanation
This is a batch reconstruction of type-2 history. Real pipelines also handle late updates. The window shuffles by the business key. Overlapping ranges are a data bug. In production, prefer a merge into a Delta table, but the window logic is what interviews ask.
Code
from pyspark.sql import functions as F
from pyspark.sql.window import Window
w = Window.partitionBy("customer_id").orderBy("start_date")
w_desc = Window.partitionBy("customer_id").orderBy(F.col("start_date").desc())
result = (
customer_history
# next version's start becomes this version's end
.withColumn("next_start", F.lead("start_date").over(w))
.withColumn("rn", F.row_number().over(w_desc))
.withColumn("end_date", F.col("next_start"))
# latest start_date is the current row
.withColumn("is_current", F.col("rn") == 1)
.drop("next_start", "rn")
)
What this code does
- It finds the next start date for the same customer.
- That next start becomes the current row's end date.
- It flags the latest row as current.
Trap
Filtering end_date IS NULL when you never set end dates, so every historical row looks current.
Q-PYS-094 Two-phase aggregation for a skewed groupBy
Answer
First aggregate by the real key plus a salt. Then drop the salt and aggregate again. Partial aggregates shuffle as many small records instead of one giant key.
Explanation
This is the aggregation cousin of join salting. It helps when one country or one customer has most of the rows. AQE does not always fix groupBy skew. In production, try it when UI shows one reduce task holding almost all shuffle bytes.
Code
from pyspark.sql import functions as F
salt = 10
partial = (
orders
# split the hot country across several partial groups
.withColumn("salt", (F.rand() * salt).cast("int"))
.groupBy("country_code", "salt")
.agg(F.sum("amount").alias("partial_sum"))
)
# second aggregation removes the salt
result = partial.groupBy("country_code").agg(
F.sum("partial_sum").alias("total_amount")
)
What this code does
- It adds a random salt so one country splits across several partial groups.
- It sums amount inside each salted group.
- It sums those partials to the true country total.
Trap
Salting and never doing the second aggregation, which leaves split totals.
Q-PYS-095 Coalesce before write to control file count
Answer
Output files follow Spark partitions. After a large filter, coalesce(n) reduces files without a full shuffle. Use repartition(n) when you need even file sizes.
Explanation
Thousands of 2 MB files are a production incident. coalesce(1) is only for tiny results. Target file size around a few hundred MB. AQE coalescing shuffle partitions helps the compute stage. You still may need an explicit coalesce at write time.
Code
from pyspark.sql import functions as F
filtered = events.filter(F.col("event_date") == "2024-01-01")
(
filtered
# merge leftover empty partitions after the filter
.coalesce(20)
.write
.mode("overwrite")
.parquet("s3://lake/events/event_date=2024-01-01/")
)
What this code does
- It filters to one date, which can leave many almost empty partitions.
- It merges those partitions down to 20.
- It writes about 20 Parquet files instead of hundreds of tiny ones.
Trap
coalesce(1) on a 500 GB filtered set, which writes with one task and often OOMs or times out.
No questions match. Clear search or pick All.