Distributed Data Processing with Spark and Hadoop Questions
Distributed compute engines for large-scale data processing, with Apache Spark as the primary focus: driver/executor architecture, the RDD/DataFrame/Dataset APIs, lazy evaluation and the Catalyst/Tungsten optimizers, shuffle and partitioning, data skew detection and mitigation (salting, broadcast joins, adaptive query execution), memory tuning, and caching/persistence strategies. Covers engine-internal execution and recovery within a single job: how the DAG scheduler breaks work into stages and tasks, task retries, speculative execution, stage recomputation from RDD lineage, and Structured Streaming's own checkpointing and write-ahead logs for exactly-once semantics. Also covers the Hadoop ecosystem as historical foundation (HDFS, MapReduce, YARN, Hive) and modern lakehouse table formats (Delta Lake, Iceberg) that have largely replaced it in current interview practice. The distributed-processing depth expected of data engineers at scale: writing and tuning jobs, diagnosing OOMs and skew, and choosing the right engine, storage format, and cluster configuration for a workload. Distinct from workflow-orchestration-and-scheduling, which owns the external, Airflow-style scheduler that invokes jobs like these as steps in a larger multi-system pipeline, and from data-reliability-and-fault-tolerance, which owns designing idempotent, cross-system pipeline behavior around job failures rather than the engine's own internal recovery mechanics.
Implement a map-side join in PySpark given the following schemas: large_df(user_id string, event_time timestamp, value double) with billions of rows and small_lookup(id string, country string, iso_code string) with a few million rows that fit in memory. Write a PySpark function that broadcasts the small lookup and performs the join without collecting the large dataframe to the driver. Use DataFrame APIs in Python and explain assumptions about memory and thresholds.
Sample Answer
Direct answer
Wrap the small lookup DataFrame with F.broadcast() before the join, and rely on the resulting DataFrame transformation staying entirely LAZY and distributed, never calling .collect()/.toPandas() on the large side at any point. F.broadcast() is a PLANNER HINT (it marks the DataFrame for Catalyst to broadcast, it does not itself trigger any driver-side materialization), and the actual broadcast of the SMALL side's bytes to executors happens as part of normal distributed execution, not through the driver collecting and re-distributing anything.
Structured elaboration
Why the large side must never be collected. large_df has billions of rows; .collect()/.toPandas() on it would attempt to materialize the ENTIRE dataset in the driver's memory, an immediate OOM (out of memory) risk regardless of the join strategy, and fundamentally defeats the point of distributed processing. The correct implementation never calls either method on large_df at any point in the pipeline; the join, and any subsequent transformation, stays as a lazy, distributed DataFrame plan until a genuinely appropriate action (a distributed WRITE, or a small, already-aggregated .collect() of a SUMMARY result) triggers execution.
Why broadcasting the small side is safe. small_lookup has "a few million rows" per the question, small enough (assuming reasonably narrow row width) to fit comfortably in every executor's available memory; F.broadcast(small_lookup_df) ships this SMALL side, ONCE per executor, directly as part of the join's own distributed execution (not via the driver acting as an intermediary), and every executor's tasks then join their local slice of large_df against the LOCAL, in-memory copy of the broadcast lookup, with NO shuffle of large_df at all.
Assumptions about memory and thresholds. The explicit F.broadcast() hint OVERRIDES Spark's own spark.sql.autoBroadcastJoinThreshold-based automatic decision (default 10 MB), appropriate here since the question already establishes the small side is broadcast-safe by design ("fits in memory"); a few million rows of, say, (id, country, iso_code) at a modest row width is very plausibly well under what a typical executor's memory comfortably accommodates, though a real production deployment should validate this against the ACTUAL row width and executor memory budget rather than assuming "a few million rows" is automatically small enough in every case (the broadcast-safety validation in more depth).
Worked example
from pyspark.sql import functions as F
def broadcast_map_side_join(large_df, small_lookup_df):
'''Broadcast the small lookup and join without ever collecting the
large side to the driver. F.broadcast marks small_lookup_df for a
broadcast (map-side) join; the result stays a lazy, distributed
DataFrame -- no .collect()/.toPandas() call happens anywhere here.'''
return (large_df.alias("l")
.join(F.broadcast(small_lookup_df).alias("s"),
F.col("l.user_id") == F.col("s.id"), how="left")
.select("l.user_id", "l.event_time", "l.value", "s.country", "s.iso_code"))
I ran this against a 20-row large_df (standing in for the billions-of-rows table, since this sandbox cannot hold a genuinely billion-row DataFrame; the JOIN MECHANICS being verified, physical-plan strategy selection and null-safe left-join behavior, do not depend on row count) and a 20-row small_lookup:
Output (actually executed with python3.12 + pyspark 3.5.1, Java 17, local[2]):
Plan contains 'BroadcastHashJoin': True
Plan contains 'SortMergeJoin' (should be False): False
=== Joined rows (first 5) ===
u1 1.0 IN IND
u10 10.0 US US
u11 11.0 IN IND
u12 12.0 US US
u13 13.0 IN IND
Total joined row count: 20 (expect 20)
Rows with unmatched lookup (expect 0, every user has a lookup row): 0
Orphan user (no lookup match) still present via LEFT join: True
Orphan user's country is NULL (not dropped, not defaulted): True
Directly inspecting the query's physical plan (via .explain(), or programmatically through the DataFrame's private _jdf.queryExecution() handle, since queryExecution is not part of PySpark's public DataFrame API the way it is in Scala) confirmed it genuinely chose BroadcastHashJoin and NOT SortMergeJoin, hard evidence the broadcast hint took effect rather than assuming it from the code alone. Separately confirmed null-safe handling: adding a large_df row with no matching lookup key (u_orphan) and re-running the join showed that row correctly PRESERVED (via the left join, not silently dropped) with NULL lookup columns, not an error or a silently-defaulted value, the correct behavior for a genuinely missing key.
Complexity
- Time: O(n) in the large side's row count for the actual join probe (each large-side row does an O(1) expected hash-map lookup against the broadcast copy), with NO shuffle of the large side at all; a categorically cheaper shape than a shuffle (sort-merge) join's O(nlogn)-per-partition sort cost.
- Space: the broadcast lookup occupies memory on EVERY executor (not just once cluster-wide), proportional to the small side's size; this is the real cost side of the trade, worth validating against actual executor memory budgets for a genuinely large "small" side.
Edge cases
- A
large_dfrow with no matching key (the orphan case, executed above): correctly preserved vialeftjoin withNULLlookup columns, not silently dropped, which matters for downstream logic that needs to distinguish "no lookup match" from "lookup match with an actually-null attribute." - Duplicate keys on the SMALL side (two lookup rows sharing the same
id): would cause the join to produce MULTIPLE output rows per matching large-side row (a standard join fan-out), a correctness risk worth validating the lookup table's key uniqueness for explicitly (small_lookup_df.groupBy("id").count().filter("count > 1")) before relying on a broadcast join to behave as a clean one-to-one enrichment. - The small side growing past broadcast-safe size over time. A hardcoded
F.broadcast()hint does not protect against this the way relying on Spark's own AUTOMATIC threshold-based decision (lettingautoBroadcastJoinThresholdand, since Spark 3.x, Adaptive Query Execution's runtime broadcast conversion decide) would, worth periodically re-validating for a table whose size is not fixed.
Trade-offs and pitfalls
- Common mistake: calling
.collect()onlarge_df(or the joined result) "just to check a few rows" during development and leaving it in production code; even a seemingly innocuous debugging.collect()left in a job meant for billions of rows is a latent OOM risk waiting for a data-volume increase to trigger it. - Common mistake: assuming an inner join is always "safer" or simpler than a left join for an enrichment lookup; an INNER join would SILENTLY DROP the
u_orphanrow entirely rather than preserving it with nulls, which is a correctness bug if downstream logic actually needs to know about (and handle) unmatched large-side rows, exactly the scenario this worked example's orphan-row check was built to catch. - The explicit
F.broadcast()hint is a deliberate override, not merely a suggestion; validate the small side's actual size against real memory budgets before hardcoding it, since forcing a broadcast on a side that turns out NOT to be safely small produces the OOM/spilled-broadcast failure mode, a self-inflicted version of the very problem broadcasting is meant to avoid.
A production Spark job shows many straggler tasks: most tasks complete quickly but a few take 10x longer. Describe the steps you would take using the Spark UI and logs to identify whether the cause is data skew, GC, IO, network, or resource starving, and list three concrete mitigations for each possible root cause.
Sample Answer
Direct answer
Straggler tasks (most finish quickly, a few take an order of magnitude longer) have four common root causes: data skew, garbage collection (GC) pauses, disk or network I/O contention, and resource starvation from co-located work; the Spark UI's per-task shuffle-read size, GC time, and executor-host columns, read together, usually distinguish between them without needing to guess.
Structured elaboration
Diagnostic order. Open the Stages tab for the slow stage, sort the task list by duration descending, and cross-reference each slow task's Shuffle Read Size, GC Time, and Host/Executor ID columns against the fast tasks in the same stage:
| Signal on the slow tasks | Likely cause | Why |
|---|---|---|
| Shuffle read size far above median; disk spill present | Data skew | The task's partition genuinely holds more data than its siblings |
| High GC Time relative to task duration; shuffle read size normal | GC | The JVM is spending real wall-clock time reclaiming memory, not doing task work |
| Fetch wait time high; shuffle read size normal; concentrated on specific executors | Network/IO | The task is waiting on data transfer, not computing |
| Slow tasks concentrated on specific hosts, unrelated to shuffle size or GC | Resource starving | Something else on that node (another job, a noisy neighbor, disk contention from an unrelated process) is stealing resources |
Data skew. Confirmed by shuffle-read-size variance (a small number of tasks reading far more shuffled bytes than the rest) plus, often, disk spill on exactly those tasks. Mitigations: salting the skewed key (add a random suffix to spread one hot key across multiple partitions, then aggregate the sub-results), switching to a broadcast join if the other side is small enough to avoid the shuffle entirely, and enabling Adaptive Query Execution's runtime skew handling (spark.sql.adaptive.skewJoin.enabled), which detects and splits oversized partitions automatically during a sort-merge join.
GC. Confirmed by the GC Time column being a large fraction of total task duration on the slow tasks specifically. Mitigations: reduce per-task memory pressure by increasing partition count (smaller partitions per task), reduce object churn by preferring the DataFrame/Dataset API and native Spark SQL functions over row-by-row Python or Scala UDFs where possible, and tune spark.executor.memory/spark.memory.fraction if execution memory is genuinely undersized for the workload.
Disk/IO. Confirmed by high "Fetch Wait Time" (time spent waiting for shuffle data to arrive from a remote executor) or unusually slow read/write throughput visible in executor logs, without corresponding shuffle-size or GC anomalies. Mitigations: check for a genuinely overloaded or failing disk on the specific host (correlate with node-level infrastructure metrics outside Spark, like disk queue depth or SMART errors), increase spark.shuffle.io.* retry/timeout tolerances if transient network blips are the cause, and consider whether the cluster's network topology is putting too much shuffle traffic through a constrained link (for example, cross-rack shuffle when rack-local data placement was expected).
Network. Overlaps with disk/IO diagnostically (both show up as fetch-wait-time) but is specifically about the transport between executors rather than local disk speed; distinguishing the two usually requires infrastructure-level network metrics (packet loss, bandwidth saturation) alongside the Spark UI, since Spark's own metrics show the SYMPTOM (waiting) without always distinguishing the specific cause. Mitigations: reduce the volume of data that has to cross the network in the first place (prefer reduceByKey/DataFrame aggregations that map-side combine over groupByKey, and filter or project columns before a shuffle rather than after); increase Spark's tolerance for transient network blips (spark.shuffle.io.maxRetries, spark.shuffle.io.retryWait, spark.network.timeout) so a brief congestion spike triggers a retry instead of a task or stage failure; and address the topology itself (favor rack- or AZ-local placement so shuffle traffic does not routinely cross a constrained link, and confirm inter-node network bandwidth is actually sized for the cluster's peak shuffle volume).
Resource starving. Confirmed when slow tasks cluster on specific hosts with no corresponding skew, GC, or fetch-wait anomaly in Spark's own metrics; this points outside Spark entirely, to node-level resource contention (a co-scheduled batch job, a runaway process, insufficient CPU/memory isolation between tenants on a shared cluster) that Spark's UI cannot see directly, requiring node-level monitoring (CPU steal time, memory pressure, disk I/O from OTHER processes) to confirm. Mitigations: isolate Spark's executors from noisy neighbors with dedicated node pools or a resource-manager queue that guarantees capacity (a YARN queue with minimum-guaranteed resources, or Kubernetes taints/tolerations and resource requests) rather than best-effort sharing; set explicit CPU/memory requests and limits so the scheduler cannot over-pack a host beyond what its real capacity supports; and, once a specific host is confirmed as a repeat offender (as in the worked example below), drain and inspect or replace that host rather than treating each occurrence as an isolated incident.
Worked example
A stage with 400 tasks: 395 complete in 8 to 15 seconds, 5 take 90 to 140 seconds. Checking the 5 slow tasks:
- Shuffle read size: within normal range of the other 395 tasks. Rules out skew.
- GC Time: consistent with the fast tasks, not elevated. Rules out GC.
- Host/Executor ID: all 5 slow tasks ran on 2 of the cluster's 40 hosts, and those same 2 hosts show elevated "Fetch Wait Time" for tasks running on them across MULTIPLE stages that day, not just this one.
- Conclusion: this pattern (host-correlated, not data-correlated, persisting across unrelated stages) points to resource starving or a network/disk problem specific to those 2 hosts, not a data-shape problem; the fix is infrastructure-level (drain and inspect those 2 hosts, check for a co-located noisy neighbor or failing hardware), not a Spark configuration change.
Trade-offs and pitfalls
- Small files as an additional root cause. A high volume of small input files can also produce straggler-like symptoms distinct from the four causes above: reading many tiny files means many small tasks with disproportionate per-task overhead relative to actual data processed, and the "slow" tasks in this case are often the ones stuck waiting on many sequential small object-store GET requests rather than any of skew/GC/IO/starving in the usual sense; the fix is compaction (merging small files) upstream, not a runtime mitigation on the reading job itself.
- Common mistake: applying a skew mitigation (salting, broadcast join) when the actual cause was GC or resource starving; this wastes engineering effort and, worse, can mask the real problem if the mitigation happens to also slightly help (for example, more partitions from salting incidentally also reduces per-task memory pressure, muddying the signal that GC was the actual cause).
- The four causes are not mutually exclusive. A skewed key can also trigger more GC (larger partitions mean more live objects during processing) as a downstream EFFECT of the skew, not a separate cause; read the shuffle-size signal as primary in that case, since fixing the skew often resolves the GC symptom as a side effect.
- Correlating slow tasks across MULTIPLE stages or MULTIPLE jobs on the same hosts (as in the worked example) is a much stronger signal for infrastructure-level causes than looking at a single stage in isolation; a genuinely bad node reveals itself through persistence, not a single occurrence.
Explain the MapReduce computation pattern and how it maps to Spark RDD and DataFrame operations. Use the classical word-count example and indicate which Spark transformations/actions correspond to map, combine and reduce. Discuss scenarios where MapReduce semantics are insufficient and how Spark's DAG, in-memory caching, and DataFrame optimizations extend the model.
Sample Answer
Direct answer
MapReduce's map step (transform each input record independently) corresponds to Spark's map/flatMap; MapReduce's combine step (an optional local pre-aggregation before shuffling) corresponds to Spark's map-side combine inside reduceByKey/aggregateByKey; MapReduce's reduce step (aggregate all values for a key) corresponds to Spark's reduceByKey/groupByKey. The classical word-count example maps directly onto this correspondence, but Spark extends the model in ways plain MapReduce cannot: a DAG (directed acyclic graph) of many stages instead of a rigid two-phase map-then-reduce, in-memory caching across those stages, and a query optimizer (Catalyst) that plans the whole chain at once rather than executing each MapReduce job as an isolated unit with no visibility into what came before or after.
Structured elaboration
Word count, phase by phase.
| MapReduce phase | Purpose | Spark equivalent |
|---|---|---|
| Map | Emit (word, 1) for each word in each line | flatMap(line => line.split(" ")).map(word => (word, 1)) |
| Combine (optional) | Locally pre-sum (word, count) pairs within one mapper's output before shuffling | The map-side combine step built into reduceByKey automatically |
| Shuffle | Move all (word, *) pairs for the same word to one reducer | Spark's shuffle, triggered by reduceByKey/groupByKey |
| Reduce | Sum all counts for each word | reduceByKey(_ + _) |
Where MapReduce semantics are insufficient. Classical MapReduce forces every computation into a rigid map-then-reduce shape, with each JOB's output written durably to HDFS before the next job (if the overall computation needs more than one map-reduce round) reads it back in. Three concrete limits this creates: (1) iterative algorithms (machine learning training loops, graph algorithms like PageRank) need the SAME data reprocessed across many rounds; plain MapReduce forces a full disk write-and-read between every round, since each MapReduce job is independent and has no memory of the prior job's in-memory state. (2) Multi-step pipelines with more than one shuffle (a filter, then a join, then an aggregation) need to be expressed as SEPARATE chained MapReduce jobs, each with its own full read-shuffle-write cycle, when logically much of that could be planned and executed as one continuous flow. (3) Interactive/ad hoc queries pay the same job-startup and disk-round-trip overhead as a full batch job, since MapReduce has no notion of a warm, reusable, in-memory dataset across queries.
How Spark's DAG, in-memory caching, and DataFrame optimizations extend the model. Spark's DAGScheduler builds a graph of STAGES (not a rigid two-phase job), where a stage boundary only occurs where a shuffle is genuinely needed (a wide dependency), and multiple narrow transformations (many map/filter steps in a row) fuse into a SINGLE stage with no intermediate disk write at all. cache()/persist() lets a dataset be materialized in memory ONCE and reused across many subsequent actions or iterations, directly solving the iterative-algorithm problem MapReduce structurally cannot: an iterative Spark job caches the working dataset once and each iteration reads it from memory, rather than re-reading from HDFS every round. Catalyst's whole-query optimization (predicate pushdown, join reordering, projection pruning) sees the ENTIRE multi-stage DataFrame pipeline at once, something MapReduce's per-job isolation structurally prevents, since each MapReduce job has no visibility into what job came before or after it.
Worked example
text = spark.sparkContext.textFile("s3://bucket/large-corpus.txt")
counts = (text
.flatMap(lambda line: line.split(" ")) # MAP
.map(lambda word: (word, 1))
.reduceByKey(lambda a, b: a + b)) # COMBINE (automatic) + SHUFFLE + REDUCE
This single RDD chain runs as ONE Spark job with (at most) one shuffle boundary, the map-side combine happening automatically as part of reduceByKey's implementation. The equivalent classical MapReduce word count needs an explicit combiner class configured separately from the mapper and reducer classes (Hadoop's Job.setCombinerClass) to get the same local-pre-aggregation benefit, an opt-in step rather than something the reduce-style API gives by default.
Where the extension matters concretely: an iterative use built on top of this same word-count-shaped pattern. Suppose the word counts need to feed 10 rounds of a term-weighting refinement algorithm (each round adjusting weights based on the prior round's output, a simplified iterative-algorithm shape). In classical MapReduce, this is 10 separate MapReduce jobs, each reading its input from HDFS and writing its output back to HDFS, 10 full disk round trips for data that, in aggregate, is not actually changing size much between rounds. In Spark, counts.cache() after the initial computation keeps that data in memory, and each of the 10 refinement rounds operates on the CACHED RDD directly, paying disk I/O once (the initial read) rather than 10 times, which is the concrete, measurable structural advantage Spark's in-memory model has over MapReduce's per-job HDFS round trip for genuinely iterative work.
Trade-offs and pitfalls
- Common mistake: treating "Spark replaces MapReduce" as meaning MapReduce's underlying IDEAS (map, combine, reduce as a mental model for distributed aggregation) are obsolete; the phase-by-phase correspondence above shows the core map/combine/reduce mental model is still exactly what
flatMap/map/reduceByKeyimplement, Spark changed the EXECUTION model (DAG of stages, in-memory reuse, whole-plan optimization) around that same conceptual core, not the core aggregation idea itself. - A large-difference-from-embarrassingly-parallel angle worth naming: MapReduce (and its Spark equivalent here) is well suited to EMBARRASSINGLY PARALLEL problems, where each record's map step is fully independent of every other record; problems requiring genuinely sequential dependencies between records (not just between aggregation rounds) do not fit the map/reduce shape at all regardless of which engine implements it, and need a different computational model entirely (for example, a graph-processing framework's message-passing model for algorithms with true per-node sequential dependencies).
- Common mistake: assuming Spark's in-memory model means data never touches disk; Spark still writes shuffle data to disk (the shuffle write/read) and spills to disk under memory pressure, the "in-memory" advantage is specifically about avoiding UNNECESSARY disk round trips between logically-chained operations and across iterations of the SAME cached dataset, not about eliminating disk I/O altogether.
- For a genuinely one-shot, single-pass batch job with no iteration and no multi-stage pipeline benefit, the practical difference between classical MapReduce and Spark narrows considerably; Spark's advantages compound specifically with iteration, multi-stage complexity, and interactive/repeated access to the same data, which is most real-world data engineering work, but is worth naming as the actual mechanism rather than treating Spark as unconditionally faster for any workload shape.
Describe the main join strategies Spark may choose: broadcast-hash-join, shuffle-hash-join, and sort-merge-join. For a join between a 200M-row 'events' table and a 50k-row 'countries' lookup table, which strategy will Spark likely pick and why? Explain how to inspect and influence the planner (EXPLAIN, hints, configuration).
Sample Answer
Direct answer
Spark chooses among three main join strategies: BROADCAST-HASH-JOIN (ship the smaller side to every executor, no shuffle of the larger side), SHUFFLE-HASH-JOIN (shuffle both sides by key, build an in-memory hash table on the smaller SHUFFLED side per partition), and SORT-MERGE-JOIN (shuffle both sides by key, sort each partition, merge; Spark's general-purpose default for two large sides). For a 200M-row events table joined against a 50K-row countries lookup, Spark will almost certainly choose BROADCAST-HASH-JOIN automatically, since a 50K-row lookup table (even at a generous row width) is very plausibly well under the spark.sql.autoBroadcastJoinThreshold default (10 MB), making this one of the cases where the DEFAULT threshold correctly and automatically catches a genuinely safe broadcast without needing an explicit hint.
Structured elaboration
Broadcast-hash-join. Ships the SMALLER side's data, once per executor, so the larger side never shuffles; the smaller side is built into an in-memory HASH TABLE on each executor, and the larger side's local partition rows probe that hash table directly. Chosen AUTOMATICALLY when a side's estimated size is under spark.sql.autoBroadcastJoinThreshold, or explicitly via F.broadcast()/a SQL hint (the mechanics and threshold-override discipline in depth).
Shuffle-hash-join. BOTH sides are shuffled by the join key (unlike a broadcast join, which shuffles neither side), then, WITHIN each resulting partition, a hash table is built from the SMALLER side's shuffled data for that partition, and the larger side's rows in that same partition probe it. This is Spark's LEAST commonly chosen strategy in practice: it requires the smaller (post-shuffle, per-partition) side to still fit comfortably in memory for the hash-table build, and Spark's planner generally prefers either a broadcast join (if genuinely safe) or a sort-merge join (the more robust general-purpose default) over shuffle-hash-join unless specifically hinted (/*+ SHUFFLE_HASH(table) */) or under specific conditions where it is estimated favorable.
Sort-merge-join. BOTH sides shuffled by the join key, then EACH partition's data is SORTED by the key, and matching rows are found via a merge-scan (an efficient linear pass, since both sides are now sorted). This is Spark's general-purpose DEFAULT for a join between two sides where NEITHER is small enough to broadcast; it is more memory-robust than shuffle-hash-join (a sort-based merge does not need to hold a full hash table in memory) at the cost of the SORT step's own overhead (O(nlogn) per partition).
Which strategy Spark will likely pick for 200M rows vs 50K rows, and why. A 50K-row lookup table, even at a generous width (say 200 bytes/row, giving roughly 50,000×200=10 MB), sits right AT or comfortably UNDER the DEFAULT 10 MB auto-broadcast threshold for a typical lookup table's row width (most countries-style reference tables are narrow: a code, a name, maybe a region, well under 200 bytes/row in practice); this is a case where the CONSERVATIVE default threshold correctly catches a genuinely safe broadcast automatically, unlike the LARGER 2-million-row dimension example, which typically DOES need an explicit override. Spark's cost-based optimizer, seeing countries' estimated size well under the threshold, chooses BroadcastHashJoin WITHOUT needing any hint at all for this specific scale.
How to inspect and influence the planner. INSPECT: df.explain() (or .explain("formatted") for a more structured, readable breakdown) shows the CHOSEN physical plan directly (BroadcastHashJoin vs SortMergeJoin vs ShuffleHashJoin appears explicitly in the plan's operator names). INFLUENCE: F.broadcast(df) (DataFrame API) or /*+ BROADCAST(table) */ / /*+ SHUFFLE_HASH(table) */ / /*+ MERGE(table) */ (SQL hints) explicitly request a specific strategy, overriding the planner's own cost-based estimate; spark.sql.autoBroadcastJoinThreshold adjusts the GLOBAL automatic-broadcast size cutoff (a blunter, session-wide lever versus a per-join hint).
Worked example
events.join(countries, "country_code"), events at 200M rows, countries at 50K rows with a typical narrow row width (country_code, country_name, region, roughly 60-80 bytes/row), giving an estimated table size of roughly 50,000×70≈3.5 MB, comfortably under the 10 MB default threshold.
Expected physical plan: BroadcastHashJoin, chosen automatically, with countries as the broadcast side; df.explain() against this join shows BroadcastHashJoin explicitly in the plan, the concrete way to CONFIRM this expectation rather than assume it.
If countries instead had an unusually WIDE row (many additional text columns, pushing estimated size to, say, 50 MB, over the default threshold): the automatic decision would flip to SortMergeJoin even though 50K rows still SOUNDS small; an explicit F.broadcast(countries) hint, validated against actual executor memory, would restore the broadcast join deliberately, exactly the "the default threshold can miss a genuinely safe LARGER broadcast" gap with its own worked 2M-row example.
Trade-offs and pitfalls
- Common mistake: assuming a "small" table (as measured by ROW COUNT alone, 50K rows sounding trivially small) is automatically safe to broadcast without checking its actual estimated BYTE size; row count and byte size can diverge significantly depending on row width, and the threshold Spark actually compares against is a byte-size estimate, not a row count.
- Common mistake: never checking
explain()at all and simply assuming the "obviously right" strategy was chosen; for THIS specific scenario the automatic choice is very likely correct, but relying on that assumption without verification is a habit that fails silently the moment table sizes shift (acountriestable that grows wider over time, as in the trade-off above). - Shuffle-hash-join is worth knowing exists, but is rarely the RIGHT strategy to explicitly force; sort-merge-join's greater memory robustness (no full in-memory hash table required) generally makes it the safer general-purpose choice when neither side is broadcast-safe, and Spark's own planner reflects this preference by default.
- The
EXPLAINoutput's plan can change between Spark VERSIONS or with AQE enabled (runtime join re-planning directly); confirming the plan for a SPECIFIC Spark version and configuration, rather than assuming behavior carries over unchanged from a different version or a different AQE setting, is the more reliable habit.
In PySpark (Python), write code using the DataFrame API to compute the top-5 most frequent item_id values per user_id from a DataFrame events(user_id string, item_id string). The solution must avoid collecting all data to the driver, handle large cardinality, and use window functions or aggregations. Assume DataFrame is named 'events'.
Sample Answer
Direct answer
Aggregate to (user_id, item_id, freq) first (shrinking the data via a map-side-combined shuffle, the same principle for other aggregations), then rank each user's items by frequency using dense_rank() over a window partitioned by user_id and ordered by frequency descending, keeping only rank <= 5. The choice between dense_rank, rank, and row_number at the cutoff boundary matters more than it first appears, and the ORDER BY clause's exact columns determine what "tied" even means to the ranking function, a subtlety worth verifying directly rather than assuming.
Structured elaboration
Aggregate before ranking, not after. Computing (user_id, item_id) frequency via groupBy().count() FIRST, then ranking the much-smaller aggregated result, is far cheaper than trying to rank raw events directly; this is the same principle (aggregate before the expensive step, not after), and here it also happens to be the ONLY sensible order, since "frequency" is not even a well-defined per-row concept until the aggregation has happened.
Choosing a ranking function. row_number() assigns STRICTLY sequential ranks with no ties at all (even two items with identical frequency get different, arbitrary sequential numbers, decided by whatever secondary ordering exists, including none, which makes the choice effectively non-deterministic unless a genuine tie-breaker column is added). rank() assigns the SAME rank to tied rows but SKIPS subsequent rank numbers (two items tied for rank 2 means the next distinct item gets rank 4, not 3). dense_rank() assigns the SAME rank to tied rows WITHOUT skipping (two items tied for rank 2 means the next distinct item gets rank 3). For a "top-5" cutoff specifically, dense_rank() with a cutoff of rank <= 5 means potentially MORE than 5 items can be returned for a user whose 5th-place frequency is tied among several items, all of which are equally "top-5-worthy" by frequency and arguably should all be included rather than an arbitrary subset of them being cut.
The critical, easy-to-miss subtlety: what the ORDER BY clause defines as a "tie." A ranking window function's notion of "tied" is defined by the FULL set of columns in its ORDER BY, not just the first one. Adding a secondary column (like item_id) to the ORDER BY "for determinism" changes what counts as a tie: if item_id is unique per row (which it is here, since it is part of the grouping key), including it in the ORDER BY means NO two rows are EVER fully tied across the whole ordering tuple, which silently makes dense_rank() behave IDENTICALLY to row_number() (every rank distinct), defeating the entire point of choosing dense_rank() over row_number() in the first place. Ordering by frequency ALONE (no tie-breaker column) is what actually lets dense_rank() produce genuine ties at the cutoff boundary.
Worked example
from pyspark.sql import functions as F
from pyspark.sql.window import Window
item_freq = events.groupBy("user_id", "item_id").agg(F.count("*").alias("freq"))
w = Window.partitionBy("user_id").orderBy(F.desc("freq")) # freq ONLY, no tie-breaker column
ranked = item_freq.withColumn("rnk", F.dense_rank().over(w))
top5 = ranked.filter(F.col("rnk") <= 5)
Test data: user u1 has 6 distinct items with frequencies a=5, b=3, c=2, d=1, e=1, f=1 (a genuine 3-way tie for the lowest frequency); user u2 has only 3 distinct items (fewer than 5, an edge case).
First run, WITH an item_id tie-breaker in the ORDER BY (orderBy(F.desc("freq"), "item_id"), rnk <= 5):
Output (actually executed with python3.12 + pyspark 3.5.1, Java 17, local[2]):
u1 a 5 1
u1 b 3 2
u1 c 2 3
u1 d 1 4
u1 e 1 5
(f excluded -- rnk would be 6)
u1's 3-way-tied d/e/f all present at rnk=4 (dense_rank keeps ties): False
Because item_id was included in the ORDER BY, d, e, and f (all freq=1) were assigned STRICTLY INCREASING ranks 4, 5, 6 (not a shared rank 4), confirming that including a unique tie-breaker column silently converts dense_rank() into row_number()-equivalent behavior here, exactly the subtlety described above, caught by actually running it rather than assuming.
Second run, ordering by freq ALONE (no tie-breaker), rnk <= 4:
u1 a 5 1
u1 b 3 2
u1 c 2 3
u1 d 1 4
u1 e 1 4
u1 f 1 4
With freq-only ordering, u1's 3-way tie (d,e,f) all share rnk=4 and all appear: True
This confirms the genuine tie-preserving behavior: d, e, f all correctly share rnk=4 and all three appear, the behavior a "top-5" (or here, top-4-BY-RANK) request arguably actually wants when there is a real tie at the cutoff.
Both runs correctly handled u2 (fewer than 5 distinct items): all 3 of u2's items appeared with no error or padding, confirmed in the executed output.
Complexity
- Time: O(n) for the initial aggregation's map-side combine plus its shuffle (the mechanism), then O(glogg) for the per-user window ranking sort, where g is the number of distinct items PER USER (typically small), not the total event count.
- Space: proportional to the number of distinct
(user_id, item_id)pairs after aggregation, bounded by actual user-item interaction diversity, not raw event volume.
Edge cases
- A user with fewer than 5 distinct items (confirmed above for
u2): all their items correctly appear, no padding or error. - A user with a tie exactly AT the cutoff boundary (confirmed above for
u1):dense_rank()(ordered by frequency alone) correctly includes ALL tied items, meaning the actual row count returned can exceed 5 for a user with boundary ties, worth documenting explicitly as expected behavior, not a bug, if the true business intent really is "all items this frequent or more." - A user with NO events at all: does not appear in
events(there is nothing to aggregate), and therefore does not appear in the output at all; if the business requirement needs an explicit "0 items" row for such a user, that requires a separate LEFT JOIN against a full user roster, not something this aggregation-and-rank pipeline produces on its own.
Trade-offs and pitfalls
- Common mistake (the one this exercise deliberately surfaces): adding a tie-breaker column to ORDER BY "for determinism" without realizing it changes ranking-function TIE semantics, not just row output order; if genuine ties should be preserved as ties, order by ONLY the columns that should define a tie, and get determinism a different way if truly needed (accepting that a genuine tie has no further deterministic sub-order, which is honestly the CORRECT reflection of the data, not a flaw to paper over).
- Common mistake: using
row_number()when the actual business requirement is "give me every item at least this frequent," silently dropping legitimately-tied items past an arbitrary row-number cutoff. - Avoiding
collect()on a large-cardinality result: for a genuinely large number of users, the finaltop5DataFrame should be written distributed (.write.parquet(...)), never.collect()'d in full, the same driver-bottleneck discipline. - The aggregation step's shuffle key (
user_id,item_id) should be checked for skew (a small number of users with vastly more distinct items or event volume than typical) before assuming this pipeline scales uniformly well across all users.
Unlock Full Question Bank
Get access to all Distributed Data Processing with Spark and Hadoop interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.