Make it cheaper without making it wrong. Partition counts, broadcast thresholds, what is worth caching and what caching costs, and cluster sizing that is argued from the data rather than doubled until it works.
How many partitions, where the count comes from at each point in a job, and why the number people tune first is usually already handled.
Making joins cheaper
5
Broadcast against sort-merge, projecting before joining, and aggregating before you widen. The largest wins in most pipelines.
Caching that earns its memory
5
When reuse justifies it, which storage level, and why an unreleased cache makes something else spill.
Cluster sizing & cost
5
Arguing a cluster shape from the data rather than doubling until it passes, and the settings that change the bill most.
Evergreen · asked verbatim
6
The flat form, in the words interviewers actually use. Same ground as the questions above, asked as recall rather than as a situation — because the two fail separately, and a candidate who can read a plan can still stall on “define a stage”.
A join between 2,000 orders and 400 customers is running as a sort-merge join because the threshold is disabled. Both versions are below. What does the hint buy, and what did you just take responsibility for?
out = (spark.table("orders")
.join(F.broadcast(spark.table("customers")), "customer_id")
.groupBy("tier").agg(F.sum("amount").alias("total")))
Why they ask this
It is the highest-leverage single change in most Spark jobs, and the interviewer wants to hear the cost acknowledged rather than the win recited.
Say this
It removes the join's two Exchanges and both Sorts — three shuffles become one. What you have taken on is the size judgement, permanently, including as the dimension grows.
The reasoning
The sort-merge version hash-partitions both sides on the join key and sorts each partition before merging: two Exchanges and two Sorts. The broadcast version ships the small side to every executor and joins in place, leaving only the aggregation's Exchange. Both return identical rows, which the harness asserts, so this is a pure cost reduction.
The responsibility is that a hint overrides the optimizer's size check permanently. The dimension is small today; the hint will still be there when it is 800 MB, collecting all of it to the driver and shipping a copy to every executor. The failure arrives as a broadcastTimeout or a driver OOM long after anyone remembers writing the line.
So the better default is usually to let AQE make the same decision from the actual shuffled size at runtime, which gets the win without the hard-coded assumption. Reach for the explicit hint when the estimate is genuinely wrong — after a chain of filters where Catalyst's idea of the size has drifted — and leave a comment saying what the size was when you wrote it.
What it actually returns 1 shuffle, run on Spark 4.2
Identical rows, one Exchange instead of three. Asserted by the harness.
"Always broadcast the smaller side." Only when it genuinely fits. The build side goes through the driver and then sits on every executor, so 'smaller' is not the test — 'small in absolute terms' is.
They’ll ask next
How would AQE reach the same plan without the hint, and when could it not?
Both versions compute revenue per tier. One joins 2,000 orders to customers and then aggregates; the other aggregates per customer first. Compare them.
customers(customer_id int, tier string, country string)
400 customers. Small enough to broadcast.
customers
customer_id
tier
country
0
free
EU
1
pro
US
2
free
APAC
3
pro
EU
The code — what does Spark do with it?
per = spark.table("orders").groupBy("customer_id").agg(F.sum("amount").alias("spend"))
out = (per.join(F.broadcast(spark.table("customers")), "customer_id")
.groupBy("tier").agg(F.sum("spend").alias("total")))
The job as written — it works, and it is slow
out = (spark.table("orders")
.join(spark.table("customers"), "customer_id")
.groupBy("tier").agg(F.sum("amount").alias("total")))
per = spark.table("orders").groupBy("customer_id").agg(F.sum("amount").alias("spend"))
out = (per.join(F.broadcast(spark.table("customers")), "customer_id")
.groupBy("tier").agg(F.sum("spend").alias("total")))
Why they ask this
Reducing before widening is the most transferable optimisation there is, and it is one Catalyst will not do for you.
Say this
Aggregating first means the join carries 400 pre-aggregated rows instead of 2,000 order rows. The harness asserts the answers are identical and that the rewrite has fewer shuffles.
The reasoning
The join-first version shuffles every order row, widened with customer columns, and then aggregates. The aggregate-first version reduces orders to one row per customer before anything is joined, so the join and everything after it operate on a much smaller set. On real data that ratio is where the saving is.
Catalyst will not make this rewrite for you. It pushes filters and prunes columns freely, and it does not move an aggregation through a join in the general case, because that transformation is only valid under conditions it usually cannot prove. This is one of the optimisations that is genuinely yours to write, which is why it is worth having as a reflex.
The validity condition is worth being able to state, because an interviewer will push on it: the pre-aggregation has to be at a grain the join preserves. Summing per customer and then joining a customer dimension is safe because the join is many-to-one on that key. If the join could fan out — a dimension with duplicate keys, or a join on a coarser key — pre-aggregating changes the answer, and the fact that the harness asserts identical rows here is exactly the check that would catch it.
What it actually returns 2 shuffles, run on Spark 4.2
Same rows both ways. The rewrite shuffles less, and the harness asserts both.
"Catalyst reorders it anyway, so write whichever reads better." It reorders filters and projections. Moving an aggregation across a join is not something it does in the general case, so writing it yourself is the whole point.
They’ll ask next
When would aggregating first give a different answer?
Both sides are selected down to the columns actually needed before joining. What does that change in the plan, and how much of it would Catalyst have done anyway?
o = spark.table("orders").select("order_id", "product_id", "amount")
p = spark.table("products").select("product_id", "category")
out = o.join(p, "product_id").groupBy("category").agg(F.sum("amount").alias("total"))
The code — predict the output before reading on
o = spark.table("orders").select("order_id", "product_id", "amount")
p = spark.table("products").select("product_id", "category")
out = o.join(p, "product_id").groupBy("category").agg(F.sum("amount").alias("total"))
Why they ask this
Column pruning is largely automatic, and knowing the boundary of 'largely' is what makes the answer useful rather than superstitious.
Say this
It narrows ReadSchema so the scan decodes fewer columns and the shuffle carries narrower rows. Catalyst does most of this automatically — the value of writing it is the cases where it cannot.
The reasoning
The plan shows the effect in the scan's ReadSchema: only the projected columns are read, which on a wide table is a large saving in a columnar format because the other columns are physically elsewhere and never fetched. Narrower rows then move through any shuffle downstream.
Catalyst's column pruning already does this when it can see the whole query — it walks back from the final projection and reads only what is referenced. So for a straightforward pipeline the explicit select changes nothing, and writing it is documentation rather than optimisation.
Where it matters is where the optimizer's view is broken. A cache boundary fixes the schema at the point you cached, so caching a wide DataFrame and then selecting three columns caches all of them. A UDF taking a struct or the whole row forces those columns to be materialised. And writing an intermediate to storage ends the optimisation window entirely. In all three the explicit projection is the only thing that narrows the read — which is why 'select early' survives as advice even though the optimizer usually beats you to it.
What it actually returns 1 shuffle, run on Spark 4.2
ReadSchema narrowed to the columns the join and the aggregate actually use.
"Always select before joining, it saves the shuffle." Usually the optimizer already did. The habit earns its keep at cache boundaries, around UDFs and across materialisation points, and saying which is what makes it a real answer.
They’ll ask next
You cache a 40-column DataFrame and then select three. What is in memory?
An aggregation producing three rows runs with shuffle partitions set to 8 rather than the default 200. What is the default costing you, and why is this less of a problem than it used to be?
out = spark.table("orders").groupBy("region").agg(F.sum("amount").alias("total"))
The code — predict the output before reading on
out = spark.table("orders").groupBy("region").agg(F.sum("amount").alias("total"))
Why they ask this
spark.sql.shuffle.partitions is the setting everyone has been told to tune, and AQE has largely taken it over — knowing that is more current than knowing the old advice.
Say this
200 partitions for a three-row result means 197 empty tasks, each with real scheduling and fetch overhead. AQE's partition coalescing merges them at runtime, which is why the setting matters far less than it did.
The reasoning
The setting applies to every shuffle in the application, so it is tuned for the largest stage and wasteful everywhere else. A selective filter followed by an aggregation produces a handful of rows spread across 200 partitions — 200 tasks scheduled, 200 fetches issued, and almost no work done. Task overhead is milliseconds each and it adds up against a stage that should have taken one.
The historical fix was to set it per job, which meant either a compromise number or changing the config between stages. AQE replaces that: it looks at the actual bytes written by the shuffle and coalesces adjacent partitions into targets of a reasonable size, so a small result gets a small number of tasks without anyone choosing a number.
So the current advice is to leave it high enough for the largest stage and let AQE coalesce downward, rather than to tune it. It still matters in one direction: AQE coalesces but does not split, so a value far too low leaves partitions too large and there is nothing to correct it. Erring high with AQE on is the shape to recommend.
What it actually returns 1 shuffle, run on Spark 4.2
Exchange with 8 partitions instead of 200, for a three-row result.
"Set shuffle partitions to two or three times your core count." A reasonable rule of thumb for one stage and wrong for a job whose stages differ in size by orders of magnitude — which is why AQE deciding per shuffle beat the rule.
They’ll ask next
AQE coalesces but does not split. What does that mean for how you pick the value?
base = spark.table("orders").filter(F.col("amount") > 100).select("region", "customer_id", "amount")
by_region = base.groupBy("region").agg(F.sum("amount").alias("total"))
out = by_region.filter(F.col("total") > 0)
The code — predict the output before reading on
base = spark.table("orders").filter(F.col("amount") > 100).select("region", "customer_id", "amount")
by_region = base.groupBy("region").agg(F.sum("amount").alias("total"))
out = by_region.filter(F.col("total") > 0)
Why they ask this
Caching is applied by reflex. The interviewer wants the condition stated, and the condition is reuse across actions rather than reuse in the code.
Say this
No — it is used once, so there is nothing to reuse and the cache would cost memory for no saving. It flips when a second branch triggers a second action over the same expensive work.
The reasoning
The condition is reuse across *actions*, not across lines of code. A DataFrame referenced once produces one job, and caching adds a write to storage memory that nothing will read. Worse, cached blocks occupy the same unified region execution needs, so an unnecessary cache can push a later stage into spilling.
It flips when two branches each trigger an action over shared, expensive work — and both halves matter. Shared but cheap is not worth it: re-reading a small Parquet file twice costs less than holding it in memory. Expensive but used once has nothing to amortise.
When it does apply, the mechanics matter as much as the decision. cache() is lazy, so follow it with count() to materialise every partition rather than letting the first branch populate it partially. Prefer MEMORY_AND_DISK, because MEMORY_ONLY silently drops partitions that do not fit and recomputes them while still reporting as cached. And unpersist when the last reader is done, because memory held after that is directly causing spill somewhere else.
What it actually returns 1 shuffle, run on Spark 4.2
One branch, one action. There is nothing here for a cache to amortise.
"Cache it anyway, it cannot hurt." It takes memory from execution, which can push a downstream stage into spilling — so an unnecessary cache can make a job slower rather than merely not faster.
They’ll ask next
Two branches, and the shared work is one cheap Parquet scan. Cache?
Partitioning, repartition vs coalesceSmall files problem
Trace where the partition count comes from at three points in a job: after the read, after a shuffle, and at the write. They have three different sources.
Why they ask this
People tune one number and wonder why the others did not move. Knowing which lever applies where is what makes partition tuning tractable.
Say this
The read is set by input splits — file count and maxPartitionBytes. A shuffle is set by spark.sql.shuffle.partitions, or by AQE coalescing it. The write is whatever the last stage left, unless you repartition or coalesce deliberately.
The reasoning
Read parallelism comes from the source layout. Spark packs files into splits bounded by spark.sql.files.maxPartitionBytes, with openCostInBytes accounting for the per-file cost, so two big files give two tasks and forty thousand small ones give far too many. No configuration of executors changes it, which is why the fix for an under-parallel scan is always file layout or the split size.
Shuffle parallelism is the one people know: spark.sql.shuffle.partitions applies to every Exchange, and AQE then coalesces downward based on the bytes actually written. That is why the same job can show 200 partitions in the plan and eight tasks in the UI — the plan is provisional and the runtime number is the real one.
Write parallelism is whatever the final stage happened to have, which is the part that surprises people. If the last operation was a shuffle you get that count; if it was a narrow chain from the scan you get the scan's. That is how a job ends up writing 200 files for a tiny result, and why an explicit repartition or coalesce before the write is the only way to control file count deliberately.
The formulations
Read: fix file layout or the split sizeship
spark.sql.files.maxPartitionBytes = 128m
# or write ~256 MB source files
The only levers that change scan parallelism. Executors and shuffle partitions do nothing here.
AQE coalesces down but never splits up, so erring high is the safe direction.
Write: repartition deliberately, or inherit chaosship
df.repartition('dt').write.partitionBy('dt')
File count is whatever the last stage left unless you say otherwise. This is where you say otherwise.
The answer most people give
"spark.sql.shuffle.partitions controls the parallelism of the job." It controls shuffles only. A scan-bound stage ignores it entirely, which is why raising it does nothing for a job that reads two files.
They’ll ask next
Your job reads 2 files and writes 200. Where did each number come from?
Two large tables are joined on customer_id several times a day. Bucketing would remove the shuffle. When is that worth doing, and what does it constrain?
Why they ask this
Bucketing is the durable version of repartitioning and it is under-used because its costs are real. Knowing both sides is the answer.
Say this
Worth it when the same key is joined on repeatedly and the tables are written by a job you control. It constrains you to a fixed bucket count, a Spark-managed table, and a write path that always bucket-writes.
The reasoning
Bucketing pre-partitions the data on disk by hashing the key into a fixed number of files. Two tables bucketed the same way on the same key have matching keys in corresponding buckets, so a sort-merge join between them needs no Exchange on either side. You pay the shuffle once at write time and every subsequent join is free of it.
That is a large win for a table joined the same way many times a day, and it comes with real constraints. The bucket count is fixed at write time and both sides must match — a mismatch means Spark shuffles anyway and you have paid the write cost for nothing. It requires a Spark-managed table via saveAsTable rather than a plain path write, which not every platform wants. And every writer to that table has to bucket, or the layout is broken for everyone.
So the decision is about stability and repetition. A dimension joined on the same surrogate key by twenty models is a strong candidate; a table whose access pattern is still changing is not, because rebucketing means rewriting it. It is also worth comparing against the alternatives first — if one side is broadcastable the shuffle is already gone, and if AQE's coalescing has made the shuffle cheap enough, the constraint may not be worth taking on.
The formulations
Bucket both sides on a stable, frequently-joined keyship
Pays the shuffle once at write time. Strong when the same join happens many times a day.
Broadcast instead, if one side fitsship
fct.join(F.broadcast(dim), 'customer_id')
Removes the shuffle with no layout constraint at all. Check this before bucketing.
Bucket with mismatched countsavoid
fct bucketed 200, dim bucketed 50
Spark shuffles anyway, so you carry the write-side constraint and get none of the benefit.
The answer most people give
"Bucketing always speeds up joins." Only when both sides are bucketed on the same key into the same number of buckets, and only for joins on that key. Otherwise it is write-time cost with no read-time return.
They’ll ask next
You bucket into 200 and the table triples in size. What now?
AQE is enabled. Name its three main behaviours and one situation where each will not fire.
Why they ask this
AQE is treated as a single switch. It is three separate features with separate thresholds, and knowing where each stops is what stops an investigation ending at 'it is on'.
Say this
Coalescing post-shuffle partitions, converting a sort-merge join to a broadcast, and splitting skewed join partitions. Each needs a completed shuffle to observe, and each has thresholds a job can sit under.
The reasoning
Coalescing merges small post-shuffle partitions into targets of a reasonable size, which is what makes a high shuffle-partitions setting safe. It will not fire when there is no shuffle, and it only merges — it never splits, so a value set far too low stays too low.
Join conversion re-checks the actual shuffled size of each side and switches a sort-merge join to a broadcast when one fits. It cannot fire before the shuffle has run, so the first plan you print always shows the sort-merge; and it will not convert a join type broadcast cannot serve, such as a full outer join.
Skew join splitting divides oversized partitions across tasks and replicates the matching side. It applies to joins only — a skewed groupBy or window partition is untouched — and requires the partition to exceed both an absolute size threshold and a multiple of the median, so a stage where every partition is large triggers nothing. All three share one precondition worth stating: AQE re-plans at stage boundaries, so a job with no shuffle gets none of it.
The formulations
Leave all three on and shuffle partitions highship
AQE coalesces down and never up, so a high starting value plus coalescing is the safe combination.
Check the thresholds before concluding it did not helpship
skewedPartitionThresholdInBytes and
skewedPartitionFactor vs your median partition
Skew splitting needs both an absolute size and a ratio to the median. Plenty of skew misses one.
Treat AQE as a single switchavoid
spark.sql.adaptive.enabled = true # and stop there
Three features with three sets of conditions; assuming one switch covers all of them ends investigations early.
The answer most people give
"AQE optimises the whole query at runtime." It re-plans at stage boundaries using statistics from completed shuffles. A job with no shuffle gives it nothing to observe and nothing to change.
They’ll ask next
Your skew is in a window partitionBy. Which AQE feature helps?
MEMORY_ONLY, MEMORY_AND_DISK, MEMORY_ONLY_SER and the _2 variants. When would you pick each, and which is the trap?
Why they ask this
Storage levels are chosen by copy-paste. The MEMORY_ONLY trap is silent, which makes it worth knowing precisely.
Say this
MEMORY_AND_DISK is the sane default. MEMORY_ONLY is the trap — partitions that do not fit are silently dropped and recomputed while the Storage tab still reports the DataFrame as cached. SER trades CPU for a much smaller footprint.
The reasoning
MEMORY_AND_DISK keeps what fits in memory and spills the rest to local disk, so a cache larger than available storage memory still helps. That predictability is why it is the default for DataFrames and the right choice almost always.
MEMORY_ONLY drops partitions that do not fit and recomputes them on next access. Nothing warns you: the Storage tab shows the DataFrame cached at some percentage, and the missing partitions are quietly recomputed from lineage every time they are read. A cache that is 60% resident over an expensive lineage can be worse than no cache, because you pay the memory *and* the recomputation.
The serialised variants store each partition as one compact byte array rather than many live objects. That is several times smaller and dramatically reduces garbage collection pressure, at the cost of deserialising on every read — worth it for large caches or when GC time is already high. The _2 variants replicate each partition to a second executor, which is for expensive-to-recompute data on unreliable capacity and rarely worth double the memory otherwise.
The formulations
MEMORY_AND_DISKship
df.persist() # the DataFrame default
Predictable: what does not fit spills rather than silently vanishing. The right choice almost always.
MEMORY_AND_DISK_SER for large cachesship
df.persist(StorageLevel.MEMORY_AND_DISK_SER)
One byte array per partition instead of millions of objects. Trades CPU for footprint and much less GC.
MEMORY_ONLYavoid
df.persist(StorageLevel.MEMORY_ONLY)
Partitions that do not fit are dropped and recomputed, while the Storage tab still says cached.
The answer most people give
"MEMORY_ONLY is fastest because it avoids disk." It is fastest for what fits and silently recomputes what does not — so on anything near the memory limit it is frequently the slowest option available.
They’ll ask next
Storage shows your DataFrame 60% cached with MEMORY_ONLY. What is happening on each read?
A pipeline has forty chained transformations and each action takes longer than the last. Would you cache or checkpoint, and what is the difference?
Why they ask this
Growing plans are a real and confusing failure, and the two remedies do different things — one keeps the lineage and one deletes it.
Say this
Checkpoint. cache stores the data and keeps the lineage, so the plan keeps growing and recomputation on loss is still expensive. checkpoint writes to reliable storage and truncates the lineage, which is what stops the growth.
The reasoning
cache is a performance shortcut that does not change the plan: the lineage behind the cached DataFrame is still there, so the logical plan keeps accumulating, Catalyst keeps re-analysing a larger tree, and if a cached block is lost it is recomputed through the whole chain.
checkpoint writes the DataFrame to reliable storage and replaces its lineage with a read of that location. The plan is cut, so planning stops getting slower and a lost partition is re-read rather than recomputed. The cost is a real write, and unlike cache it is not free to discard.
The symptom described — each action slower than the last with no more data — is usually plan growth rather than execution cost, and it shows up as driver time before any task is scheduled. Iterative algorithms and long chains of withColumn are the classic producers. localCheckpoint is the cheaper cousin that truncates lineage without the reliable write, which is fine when losing an executor is acceptable and not when it is not.
Cuts the plan, so planning stops slowing down and recovery is a read rather than a recomputation.
cache for reuse across branchesship
df.cache(); df.count()
The right tool for amortising shared work. It does nothing about plan growth.
cache to fix a slow-planning pipelineavoid
df.cache() # lineage is still attached
Stores the data and keeps the tree, so analysis keeps getting slower and recovery stays expensive.
The answer most people give
"cache and checkpoint are the same thing with different storage." cache keeps the lineage and checkpoint deletes it. That difference is the entire reason to use checkpoint, and it is invisible if you only think about where the bytes go.
They’ll ask next
Your driver spends four minutes before any task starts. What are you looking at?
A long job caches four DataFrames early and never unpersists. Later stages start spilling. Connect the two.
Why they ask this
Storage and execution share one memory region, so an unreleased cache is a direct cause of spill elsewhere — and almost nobody traces it back.
Say this
Cached blocks and execution memory come from the same unified pool. Four caches held past their last reader occupy space later stages needed, so those stages exceed their share and spill to disk.
The reasoning
Spark's unified memory model gives execution and storage one region with a soft boundary. Execution can evict storage when it needs room, but only down to a protected floor — so cached blocks below that floor cannot be evicted and are held regardless of what execution needs.
Four caches held for the whole job means that floor is occupied for the whole job. Later stages get less execution memory than they otherwise would, exceed it, and spill. The spill shows up in a stage that has nothing to do with the caching, which is why the connection is rarely made — the symptom and the cause are far apart in both the code and the UI.
So unpersist when the last reader has run, and prefer to cache the narrowest thing that is actually reused rather than a wide intermediate. The Storage tab is the diagnostic: if it shows several large cached DataFrames while a late stage reports heavy spill, that is the pattern. And the first question is still whether each cache was earning its place, because a cache with one reader is pure cost.
The formulations
unpersist as soon as the last reader has runship
base.unpersist() # after the branches are done
Returns the memory to execution. The one-line fix for spill that appeared for no visible reason.
Cache the narrowest thing that is reusedship
base = wide.select(needed_cols).filter(...)
base.cache()
Less memory held for the same benefit, so less is taken from execution.
Cache everything early and leave itavoid
a.cache(); b.cache(); c.cache(); d.cache()
Occupies the storage floor for the whole job, and later stages spill in a place nothing connects to it.
The answer most people give
"Cache and execution memory are separate, so caching cannot cause spill." They share one unified region, and cached blocks below the storage floor cannot be evicted — which is exactly how a cache makes an unrelated stage spill.
They’ll ask next
Which tab tells you whether this is what is happening?
Cluster sizing & costPartitioning, repartition vs coalesce
A daily job reads 500 GB of Parquet and does one wide aggregation. Size the cluster, and say which number you are least confident about.
Why they ask this
Cluster sizing is usually done by doubling until it passes. The interviewer wants an argument from the data with the uncertainty named.
Say this
Work from partition size: 500 GB into ~200 MB partitions is about 2,500 tasks; at 5 cores per executor and a target of a few waves, that is roughly 20 executors. The least certain number is the in-memory expansion of compressed Parquet.
The reasoning
Start from the partition target rather than the cluster. Aiming for partitions of roughly 128–256 MB, 500 GB gives on the order of 2,500 tasks. Then decide how many waves you are willing to run: with 5 cores per executor, 20 executors gives 100 concurrent tasks and about 25 waves, which is a reasonable shape for a batch job with a generous window.
Memory follows from what a task holds, not from total data. Each task needs room for its partition plus whatever the aggregation accumulates, and 5 tasks share an executor's pool — so a few gigabytes per core is a starting point, adjusted upward if the aggregation's state is large or the data expands a lot on decode.
The number to flag as uncertain is that expansion. 500 GB of Parquet is compressed and columnar; in memory it can be several times larger, and the ratio depends entirely on the data — high-cardinality strings expand far more than dictionary-encoded low-cardinality columns. Everything else in the estimate is arithmetic; that one is a measurement, and the honest answer is to size from a sample run rather than from a rule.
The formulations
Size from partition target, then waves, then memory per taskship
Every step is arithmetic somebody else can check and disagree with a specific number.
Measure the expansion on a sample firstship
run one day, read the stage's shuffle read/spill metrics
The one term that is a measurement rather than arithmetic. A sample run replaces the guess.
Double the cluster until it passesavoid
--num-executors 100 # it worked, ship it
Produces a number nobody can defend and a bill nobody can reduce, because nothing was reasoned.
The answer most people give
"Give it enough memory to hold the whole dataset." Spark is designed to process data far larger than memory by streaming partitions through tasks. Sizing for the whole dataset is how a job that needed 20 executors gets 200.
They’ll ask next
The job finishes in 20 minutes and the window is 4 hours. What do you change?
Dynamic allocation is enabled so the job scales executors to demand. What does it require to work safely, and where does it not help?
Why they ask this
It is recommended for cost and has one hard prerequisite that people miss, which turns it into a source of cascading failures.
Say this
It needs the external shuffle service, or removing an executor destroys the shuffle files it wrote. It does not help a job whose stages are uniformly busy, and it adds latency while executors are being acquired.
The reasoning
Dynamic allocation adds executors when tasks are queued and removes them when they have been idle. The saving is real for jobs with uneven shapes — a wide scan followed by a narrow aggregation, or a pipeline with long serial sections — because you stop paying for capacity nothing is using.
The prerequisite is the external shuffle service. Shuffle files live on the executor's local disk, so removing an executor that has written shuffle output makes that output unreachable and triggers FetchFailedException and stage recomputation. With the shuffle service those files are served by a separate process on the node and survive the executor's removal. Enabling dynamic allocation without it is a known way to make a job less reliable while trying to make it cheaper.
Where it does not help: a job that is busy end to end has nothing to release, so the overhead is pure. And acquisition is not instant — on Kubernetes or YARN under contention there is a real delay before new executors are running, so a spiky job can spend its time waiting for capacity. Setting a sensible minimum avoids scaling from zero on every stage boundary.
The formulations
Dynamic allocation with the external shuffle serviceship
spark.dynamicAllocation.enabled = true
spark.shuffle.service.enabled = true
minExecutors set above zero
The saving without the failure mode. The shuffle service is the prerequisite, not an optimisation.
Fixed sizing for a uniformly busy jobship
--num-executors 20 --executor-cores 5
Nothing to release, so dynamic allocation is overhead. Fixed is simpler and no more expensive.
Dynamic allocation without the shuffle serviceavoid
spark.dynamicAllocation.enabled = true # only
Removing an executor loses its shuffle files, producing FetchFailed and stage recomputation.
The answer most people give
"It always saves money because you only pay for what you use." Only when there is idle capacity to release, and without the shuffle service it buys failures whose recomputation costs more than the executors saved.
They’ll ask next
Your dynamically-allocated job keeps hitting FetchFailedException. What is missing?
A nightly job costs more than the rest of the platform combined. Name the things you would look at, in the order that usually pays.
Why they ask this
Cost work is prioritisation. Starting in the wrong place is how a week goes into something that moves 3%.
Say this
Bytes scanned first — layout, pruning and projection. Then shuffle volume. Then wasted parallelism and idle executors. Cluster size last, because it is the symptom rather than the cause.
The reasoning
Bytes read is usually the largest term and the easiest to move. Check whether partition pruning is actually happening, whether the projection is narrow, and whether the source is thousands of small files. A job scanning a whole table because a predicate wrapped the partition column in a function is a one-line fix with an enormous return.
Shuffle volume is next, and the levers are the ones from the join and aggregation questions: broadcast where the side fits, aggregate before widening, project before shuffling. Each reduces bytes moved rather than time spent, which is what the bill responds to on most platforms.
Then wasted capacity: stages running with far fewer tasks than cores, executors idle through a long serial section, a cluster sized for the largest stage and paid for throughout. Dynamic allocation and AQE address much of this. Cluster size comes last because shrinking a cluster that is doing unnecessary work just makes the job take longer for the same total — the reductions above are what make a smaller cluster viable.
The formulations
Attack bytes scanned firstship
check PartitionFilters and ReadSchema in the plan;
compact small files
Usually the largest single term, and the fixes are often one line with a large return.
Then shuffle volumeship
broadcast where it fits; aggregate before widening;
project before the wide op
Reduces bytes moved rather than time spent, which is what most billing models track.
Shrink the cluster firstavoid
--num-executors 20 # was 60
The same work on less capacity takes proportionally longer. It moves the shape of the bill, not the size.
The answer most people give
"Use a smaller cluster and let it run longer." On most pricing that is roughly the same total — you are buying the same core-hours more slowly. The wins are in doing less work, not in doing it with less at once.
They’ll ask next
The plan shows PartitionFilters empty on a partitioned table. What do you check in the query?
A job takes 40 minutes and the window is 6 hours. Your manager wants it tuned. What do you say?
Why they ask this
Knowing when not to optimise is a seniority signal, and the honest answer involves asking what the tuning is for.
Say this
Ask what problem it is solving. If it meets its SLA and its cost is not material, tuning it buys nothing and risks correctness — the effort belongs on a job that is failing, expensive, or close to its window.
The reasoning
A job meeting its SLA with five hours of headroom is not a problem, and every change to it carries a risk of altering the answer. The tuning questions in this bank all have harness assertions that the rewrite returns identical rows precisely because that risk is real, and in production nobody is checking.
So the first question is which constraint is being violated. If it is cost, measure it — a job's share of the bill is often much smaller than its runtime suggests, and a 40-minute job on a small cluster can be cheaper than a 4-minute one on a large one. If it is a dependency waiting on it, the fix might be scheduling rather than speed. If it is 'it feels slow', that is not a constraint.
Where the effort genuinely belongs: jobs that fail or retry, jobs close enough to their window that a bad day breaches it, and jobs whose cost is a visible line item. Saying that plainly — with the numbers — is a better answer than producing a 20% improvement nobody needed, and it is the kind of judgement the question is actually testing for.
The formulations
Ask which constraint is being violatedship
SLA? cost? a dependency waiting?
-> measure it before changing anything
Turns 'make it faster' into a target, and often reveals there is no problem to solve.
Spend the effort where jobs fail or nearly breachship
rank jobs by retry rate and by headroom to SLA
The same hours applied where a change actually removes risk rather than shaving a comfortable margin.
Tune it because it was asked foravoid
-- 40 min -> 32 min, six hours of headroom either way
Real risk of changing the answer, for a margin nobody was constrained by.
The answer most people give
"Faster is always better." Every change to a working job risks the number it produces, and a job with five hours of headroom has no constraint to relieve. The question is what the speed is for.
They’ll ask next
It turns out the job is 40% of the platform bill. Does your answer change?
Which format and compression choices move a job's cost most, and which of them is a decision about parallelism rather than about size?
Why they ask this
Format choices are made once and paid for daily, and the splittability point is the one that surprises people who think of compression as purely a size trade.
Say this
Columnar over row-oriented is the largest win, because of projection. The parallelism decision is the codec: gzip is not splittable, so one large .gz file is one task no matter how big the cluster.
The reasoning
Format first. A columnar format lets a query read only the columns it needs and skip row groups using statistics, so a report touching three of forty columns reads a fraction of the bytes. That is a larger effect than any codec choice, and it is why converting text sources to Parquet at landing is close to always right.
The codec is where the parallelism decision hides. gzip compresses well and is not splittable, so a single 20 GB .csv.gz is exactly one task — no configuration, no cluster size and no repartition changes that, because the file cannot be divided. Snappy and zstd are splittable in the container formats that matter and are the reason Parquet defaults to snappy. zstd gives noticeably better compression at similar speed and is the sensible modern choice where it is supported.
Then file sizing, which is the third and most-neglected term: aim for files in the hundreds of megabytes. Too small and you pay listing and per-file overhead; too large and you lose the ability to parallelise and to skip. Getting format, codec and file size right at the write is a one-time decision that every subsequent read benefits from, which is a much better return than tuning the readers afterwards.
The formulations
Parquet with snappy or zstd, files in the hundreds of MBship
Projection, row-group skipping and splittability together. The one-time decision every read benefits from.
Convert text sources at landingship
read csv with an explicit schema -> write parquet once
The raw file is parsed once instead of on every run, and the schema stops being a guess.
gzip on large text filesavoid
orders.csv.gz # 20 GB, one task
Not splittable, so the whole file is one task regardless of cluster size or configuration.
The answer most people give
"Use the codec with the best compression ratio to save storage." Storage is usually the smaller bill, and the highest-ratio codec is often the one that costs you splittability — which converts a storage saving into a parallelism problem.
They’ll ask next
You inherit a directory of 20 GB gzipped CSVs and cannot change the producer. What is your first job?
A join is skewed. Put AQE skew handling, broadcasting, salting and repartitioning in the order you would try them, and justify the ordering.
Why they ask this
Ordering by cost-to-implement rather than by cleverness is the judgement being tested, and salting-first is the common wrong answer.
Say this
AQE first because it is a config change, broadcast second because it removes the shuffle entirely, salting third because it is real code, and repartitioning not at all — it does not address skew.
The reasoning
AQE's skew join handling costs a setting. It splits oversized partitions across tasks and replicates the matching side, which handles the common case with no code change and nothing to maintain. Confirming whether it is enabled and whether your partition actually clears its thresholds takes minutes.
Broadcasting is second because it removes the problem rather than managing it: with no shuffle there is no partitioning by key and therefore no hot partition. It is only available when one side genuinely fits, which is why it is second rather than first — but when it is available it is strictly better than anything downstream of it.
Salting is third because it works everywhere and costs the most: an N-fold copy of the small side, an unreadable synthetic join key, a salt factor to maintain, and a re-aggregation to strip it. Repartitioning is not on the list at all — every row with the hot key hashes to one partition however many partitions exist, so more partitions makes every other partition smaller and leaves the problem exactly where it was. Suggesting it is the clearest signal that someone has not understood why skew happens.
The formulations
AQE skew handlingship
spark.sql.adaptive.skewJoin.enabled = true
A setting. Handles the common case with no code and nothing to maintain afterwards.
Broadcast, if a side fitsship
fct.join(F.broadcast(dim), 'account_id')
No shuffle means no hot partition. Removes the problem rather than distributing it.
More shuffle partitionsavoid
spark.sql.shuffle.partitions = 4000
One key still hashes to one partition. Every other partition shrinks and the hot one does not.
The answer most people give
"Salt it — that is the standard fix for skew." It is the standard *last* fix. Reaching for it before checking AQE and broadcast means taking on maintained complexity for something a setting may already handle.
They’ll ask next
AQE is on, nothing fits a broadcast, and you salt with N=8. How do you choose N?
Predicate & projection pushdownPartitioning, repartition vs coalesce
A query that used to read one partition now scans the whole table, and nobody changed the layout. What kinds of change to the query cause that?
Why they ask this
Losing pushdown is invisible until the bill arrives, and the causes are a short enumerable list worth knowing.
Say this
Wrapping the partition column in a function, comparing it to a non-literal, filtering through a UDF, or filtering after a boundary the optimizer cannot see through — a cache, a UDF, or a materialised intermediate.
The reasoning
The most common is applying a function to the partition column: casting it, formatting it, or calling to_date on it means the engine can no longer match the predicate against the partition values, so PartitionFilters comes back empty and every directory is listed. Comparing the column to a literal on the *other* side of the function is the fix — transform the constant, never the column.
A UDF in the predicate stops pushdown entirely, because the optimizer cannot reason about it. So does filtering after something that ends the optimisation window: a cache fixes the plan at the point you cached, and an intermediate written to storage and read back starts a new query with no knowledge of the filter you are about to apply.
The way to catch it is to read the scan node rather than to reason about the query. PartitionFilters, PushedFilters and ReadSchema are three lines that say exactly what the reader will do, and comparing them against what you expected takes seconds. Making that part of review — or asserting on it in a test for a critical job — is how a regression like this gets found before the bill does.
The formulations
Compare the column to a literal, untouchedship
F.col('order_date') == F.lit('2026-03-05')
Matchable against the partition values, so PartitionFilters is populated and directories are skipped.
Transform the constant, never the columnship
F.col('ts') >= F.lit(start_ts) # not to_date(F.col('ts')) == ...
Keeps the column in a form the reader can match while still expressing the comparison you wanted.
The engine can no longer match it to partition values, so every directory is listed and read.
The answer most people give
"The optimizer will figure out the cast is equivalent." It will not in the general case, and the failure is silent — the query is correct and reads a hundred times more data than it needs.
They’ll ask next
How would you assert in a test that a critical query still prunes partitions?
The SQL tab shows an operator estimated at 1,000 rows and actual at 40 million. What decisions did that estimate poison, and what do you do about it?
Why they ask this
Estimate-versus-actual is the most useful number in the SQL tab and almost nobody looks at it. It explains most bad plans.
Say this
Join strategy and join order — both are chosen from estimates before the query runs. The fixes are to collect real statistics, or to let AQE re-decide from measured sizes at each stage boundary.
The reasoning
Physical planning is where estimates matter. A side estimated at a thousand rows looks broadcastable; at forty million it is not, and the plan committed to the broadcast before anything ran — which is how a broadcastTimeout or a driver OOM happens. Join *order* in a multi-table query is chosen the same way, so a bad estimate can put the largest intermediate in the worst place.
The estimates come from statistics, and without ANALYZE TABLE those are file sizes. Compressed Parquet under-reports in-memory size systematically, and after a chain of filters and joins the propagated estimate can be off by orders of magnitude — selectivity of a predicate on a column with no histogram is essentially a guess.
Two fixes and they compose. Collect statistics on the tables and columns that matter, so the initial plan is made from measurements rather than file sizes. And enable AQE, which re-plans at each stage boundary using the bytes actually written — which is why it fixes this class of problem structurally rather than one query at a time. Comparing the provisional plan against the AQE final plan is the habit that shows you which estimates were wrong.
The formulations
AQE, so decisions come from measured sizesship
spark.sql.adaptive.enabled = true
Re-plans at stage boundaries on real bytes. Fixes the class of problem rather than one query.
ANALYZE TABLE for the tables that mattership
ANALYZE TABLE fct COMPUTE STATISTICS FOR ALL COLUMNS
Makes the initial plan a decision from measurements instead of from compressed file sizes.
Hint every join strategy by handworks
F.broadcast(...) / hints on each join
Works and freezes today's data sizes into the code, where they will outlive the assumption.
The answer most people give
"The estimates do not matter because Spark measures as it goes." Only with AQE, and only at stage boundaries. Without it every strategy and ordering decision is made from the estimate and never revisited.
They’ll ask next
You run ANALYZE and the estimate is still wrong. Where else could it come from?
Partitioning, repartition vs coalesceRDD vs DataFrame vs Dataset
A tuning change made a job twice as fast and the output no longer matches. Name three tuning changes that can legitimately alter a result.
Why they ask this
Tuning is supposed to be answer-preserving and several common changes are not. Knowing which is what makes a review of a performance PR meaningful.
Say this
Anything that changes partitioning changes the outcome of a non-deterministic operation: dropDuplicates, first/last without an ordering, and monotonically_increasing_id all pick differently when the partitioning does.
The reasoning
dropDuplicates keeps an arbitrary row per key. Which one survives depends on partitioning and task completion order, so changing shuffle partitions, adding a repartition, or enabling AQE coalescing can all change which duplicate wins. The job was always non-deterministic; the tuning change just moved the dice.
first() and last() in an aggregation without an ordering behave the same way, and monotonically_increasing_id encodes the partition id directly — so any change to partitioning changes the ids it generates. Using it as a stable key is the specific mistake, and it survives testing because a small dataset has stable partitioning.
The third category is genuine precision: changing a decimal to a double, or altering the order of a floating-point aggregation, produces slightly different sums because floating-point addition is not associative. A different partition count changes the order partial sums are combined in, so the total's last digits move. That is usually acceptable and it is worth knowing before someone reports it as a bug — and it is why the harness assertions in this bank compare exact rows.
It encodes the partition id, so any partitioning change renumbers everything.
The answer most people give
"Tuning cannot change results, so the difference must be a data change." Several ordinary operations are non-deterministic under repartitioning, and tuning changes partitioning by definition.
They’ll ask next
Your sums differ in the last two decimal places after a repartition. Bug or not?
EvergreenPartitioning, repartition vs coalesceAQESpill
You are shuffling roughly 1 TB. How many shuffle partitions do you set, and how did you get to that number?
Why they ask this
It is asked as arithmetic, and the arithmetic is trivial — so what is really being tested is whether you state the target partition size out loud instead of reciting a number.
Say this
Pick a target partition size first — 128–256 MB is the usual band — then divide. At 200 MB, 1 TB gives about 5,000 partitions; at 128 MB, about 8,000. Then sanity-check it against the core count.
The reasoning
The calculation: 1 TB is roughly 1,000,000 MB. Divide by the target size per partition. 1,000,000 / 200 ≈ 5,000. 1,000,000 / 128 ≈ 8,000. Both are defensible; a bare "5,000" without the target is not, because the number is meaningless without it.
Why that band. Too large and a single task has to hold its partition through a sort or an aggregation, which is where executor OOM and heavy spill come from. Too small and you pay fixed per-task overhead — scheduling, launch, and a small write — on work that takes milliseconds, and the driver becomes the bottleneck long before the executors do.
The second check is the cluster. Task count should be a comfortable multiple of total cores so every core stays fed and stragglers get absorbed — two to three times is the usual rule. 5,000 tasks on 126 cores is about 40 waves, which is fine. 5,000 tasks on 8 cores is not a partitioning decision, it is a cluster that is too small for the data.
And the honest modern caveat: with AQE on, this number is a **starting point rather than the final one**. `spark.sql.adaptive.coalescePartitions.enabled` lets Spark merge small post-shuffle partitions at runtime toward `spark.sql.adaptive.advisoryPartitionSizeInBytes`, so over-provisioning here is much cheaper than it used to be. Setting it too low is still a real mistake, because AQE can coalesce partitions but it cannot split one that is too big.
Safe with AQE on. Too high without AQE is a lot of tiny tasks and tiny files.
The answer most people give
"Set it to the number of cores." That makes every task hold data-size ÷ cores, which at 1 TB on 126 cores is 8 GB per task. Partition count comes from the data volume; the core count only tells you how many waves you will run.
They’ll ask next
AQE is on. Does that make this setting irrelevant?
You have 1 TB to process on four r5.12xlarge nodes — 48 vCPU and 384 GiB each. Give me your executor configuration and justify it.
Why they ask this
It is the standard numerical Spark question, and it is scored on the reasoning: the interviewer wants to hear you subtract the overhead before you divide.
Say this
One master and three workers, 6 cores per executor, 7 executors per node, so 21 executors and 126 cores. About 40 G heap plus 6 G overhead each. Parallelism around 320 and shuffle partitions around 5,000.
The reasoning
**Cores.** Reserve about 6 cores per node for the OS and the node manager, leaving 42. Executor cores go in the 4–6 band — below that you lose the benefit of shared broadcast data and JVM warmup, above that HDFS and cloud-storage throughput per executor stops scaling and GC pauses get long. Take 6: 42 / 6 = 7 executors per node, across 3 workers, so **21 executors and 126 cores**.
**Memory.** Reserve about 15% of the 384 GiB for the OS and daemons, leaving ~326 GiB, divided by 7 executors ≈ 46.6 GiB each. Split that into heap and off-heap overhead rather than handing it all to the heap: `spark.executor.memory=40G` and `spark.executor.memoryOverhead=6G` sums to 46 G, just inside the budget. Overhead is not optional — it is where shuffle buffers, the Python worker on PySpark, and native memory live, and omitting it is the direct cause of "Container killed by YARN for exceeding memory limits".
**Parallelism.** `spark.default.parallelism` at 2–3× total cores ≈ 250–380; 320 is a reasonable pick. `spark.sql.shuffle.partitions` comes from the data, not the cores: 1 TB at ~200 MB per partition ≈ 5,000. Turn AQE on so Spark can coalesce that down at runtime when a stage turns out smaller than the estimate.
The part worth saying out loud at the end is that this is a **starting configuration, not an answer**. You run it once, look at the Spark UI for spill, GC time and straggler tasks, and adjust. An interviewer who wanted a memorised table would not have given you the instance type.
Long GC pauses, and one lost executor costs a third of the cluster.
All the memory as heapavoid
spark.executor.memory=46G # no memoryOverhead
The classic route to "Container killed by YARN for exceeding memory limits".
The answer most people give
"48 cores per node × 4 nodes = 192 cores." Two mistakes in one: the master runs no executors, and you cannot hand every core to Spark — the OS and the node manager need theirs. The realistic figure is 126.
They’ll ask next
The job spills heavily at this configuration. Do you raise executor memory or raise the partition count first?
What is the difference between execution memory and storage memory, and how does Spark divide an executor’s heap between them?
Why they ask this
The two-region split is the model behind every OOM conversation, and the detail that the boundary moves is the part almost nobody knows.
Say this
Execution memory serves shuffles, joins, sorts and aggregations; storage memory holds cached blocks. They share one region and the boundary between them is soft — either side can borrow from the other.
The reasoning
An executor heap divides into reserved memory, user memory for your own objects, and the Spark region — `spark.memory.fraction`, 0.6 by default. Inside that region, `spark.memory.storageFraction` (0.5) marks how much storage is *guaranteed*, not how much it gets.
Execution memory is transient: it holds hash tables for aggregations and joins, sort buffers, and shuffle write buffers, and it is released when the task ends. Storage memory is what `cache()` and `persist()` fill, and it stays until eviction.
The important part is that **unified memory management makes the boundary soft**. If nothing is cached, execution can use the whole region. If execution needs memory and storage is over its guaranteed share, storage blocks are evicted to make room. But it does not go the other way: **execution memory cannot be evicted**, so storage cannot force execution to give anything back beyond the guaranteed line. That asymmetry exists because evicting a cached block costs a recomputation, while evicting a half-built hash table would mean failing the task.
What that means in practice: a cached DataFrame vanishing under a heavy shuffle is not a bug, it is the design. And on a 20 G executor the arithmetic is 20 × 0.6 = 12 G of Spark memory, of which 6 G is storage's guaranteed floor and the rest is contested.
The formulations
Execution memoryship
shuffle buffers, join hash tables,
sort buffers, aggregation state
Transient, per-task, and cannot be evicted mid-task.
Storage memoryship
cache() / persist() blocks,
broadcast variables
Evictable above the guaranteed fraction. Losing it costs a recompute.
Raising memory.fraction to fix an OOMavoid
spark.memory.fraction = 0.8
Squeezes user memory and often just moves the failure. Fix the partition size first.
The answer most people give
"They are two fixed halves and you tune the split." They were fixed before Spark 1.6; since unified memory management the line moves at runtime, which is why `storageFraction` is a floor rather than a size.
They’ll ask next
Your cached DataFrame keeps disappearing during a big join. Is that a bug?
EvergreenSmall files problemPartitioning, repartition vs coalesce
One terabyte stored as ten files versus the same terabyte as a million files. Same data — why is Spark so much slower on the second?
Why they ask this
Everybody knows small files are bad. The question is whether you can say what Spark actually does per file, because that is what tells you which fix applies.
Say this
Spark works file by file, so a million files means a million metadata calls, roughly a million tasks, a million scheduling decisions and a million fixed task startups — overhead that dwarfs the work.
The reasoning
**Listing.** Before anything runs, the driver enumerates the input. On object storage that is API calls, and a million files is a million of them — a serial-ish cost paid before the first row is read. On top of that the driver holds the resulting file metadata in memory, which is the first place a large listing shows up as driver pressure.
**Task creation.** A file smaller than the split size generally becomes its own task. A million small files is therefore roughly a million tasks, where 5,000 files of 200 MB would have been about 5,000.
**Scheduling.** The driver schedules every task individually and tracks its state. At a million tasks, the driver is the bottleneck and the executors sit waiting to be given work.
**Startup overhead.** Each task pays a fixed cost — launch, deserialise the closure, open the file, close it, report back. That cost is the same whether the file holds one kilobyte or two hundred megabytes. When the file is 1 MB, essentially all of the time is overhead.
Add executor utilisation to the list: tasks that finish in milliseconds leave cores idle between assignments, so a cluster that looks busy in the scheduler is doing almost no useful work. The good shape for this data is roughly 5,000 files of 200 MB — the same total bytes, two orders of magnitude less overhead.
The driver saturates before the executors do any real work.
5,000 × 200 MBship
~5k list calls, ~5k tasks, overhead amortised
over 200 MB of actual work each
Same bytes. Overhead becomes a rounding error.
10 × 100 GBworks
10 tasks — unless the format is splittable
Parquet splits by row group so this is fine; gzipped CSV would give you 10 tasks and no parallelism.
The answer most people give
"It is slower because of metadata overhead." True but too vague to act on — it does not tell you whether to fix the driver, the task count or the write. Naming the four steps is what turns it into a diagnosis.
They’ll ask next
Which of those four costs does increasing executor memory help with?
EvergreenSmall files problemPartitioning, repartition vs coalesceDelta / Iceberg / Hudi
Your pipeline writes tens of thousands of tiny files every run. What do you change, on the read side and on the write side?
Why they ask this
The follow-up to the previous question, and it separates people who name one fix from people who know the fixes apply at different points.
Say this
On the write side, control the number of output partitions and cap rows per file. On the read side, compact what already exists. They are different problems and both usually need doing.
The reasoning
**At write time**, the file count is the partition count — one file per partition per output directory. `coalesce(n)` reduces partitions without a shuffle and is the cheap fix when the data is already roughly balanced; `repartition(n)` costs a shuffle but distributes evenly, which matters when a `coalesce` would leave one enormous partition. If you are also partitioning by a column, `repartition(col)` before the write puts each value's rows on one task, which is what stops each partition directory getting one small file per input task.
**Cap the file size** rather than guessing at partition counts: `df.write.option("maxRecordsPerFile", 1000000)` splits any partition that would produce more, which keeps output files bounded even when the data volume moves between runs.
**For files that already exist**, compaction is a separate job: read a directory, repartition to a sensible count, write it back atomically. 1,000 files of 5 MB become 20 of 250 MB. On Delta or Iceberg this is a built-in operation (`OPTIMIZE`, `rewrite_data_files`) rather than something you hand-roll, and it handles the atomic swap for you.
The trap to avoid is `coalesce(1)`. It does produce one file, and it does it by collapsing the entire final stage to a single task — so the whole write becomes serial, and if that partition does not fit in one executor it fails outright. When someone asks for one file, they usually want *few* files.
One file per date instead of one per date per task. Costs a shuffle, worth it.
coalesce(1)avoid
df.coalesce(1).write.parquet(path)
Serialises the whole final stage into one task. Fails outright at scale.
The answer most people give
"Use coalesce before writing." Right instrument, and it does nothing for the millions of small files already sitting in the lake — those need a compaction job. Fixing the write stops the bleeding; it does not clean up.
They’ll ask next
You compact a directory a downstream job is reading right now. What could go wrong?
EvergreenPartitioning, repartition vs coalescePredicate & projection pushdownSmall files problem
How do you choose a column to partition a table by, and why is user_id almost always the wrong answer?
Why they ask this
Partition-column choice is the storage decision with the longest half-life — it is expensive to change later, and the failure mode is not slowness, it is a table nobody can query.
Say this
Partition on a column your queries actually filter on, with cardinality low enough that each partition holds a substantial amount of data. Date usually qualifies; user_id gives millions of directories holding a few rows each.
The reasoning
Two requirements have to hold at once. The column must appear in the **WHERE clause of the queries that matter**, or pruning never triggers and you have paid for the layout without getting anything. And its **cardinality has to be low enough** that a partition is worth reading as a unit — the working target is partitions in the hundreds of megabytes.
`event_date` satisfies both for most analytical tables: everything filters on a time range, and a day of data is a real chunk. `country` can work as a second level when queries filter on it, though it skews badly if one country dominates. `user_id` satisfies the first and fails the second catastrophically: millions of directories, each with a handful of rows, which reproduces the small-file problem *and* makes listing the table slow enough that planning takes longer than the query.
The other half of the answer is that partitioning is not the only tool, and reaching for it reflexively is the mistake. **Partitioning is coarse physical separation; clustering, sorting or Z-ordering give you skipping within a partition without creating directories.** For a high-cardinality column you want to filter on, clustering is the right instrument — partition by date, cluster by user_id, and you get both.
Worth saying out loud: this decision is hard to reverse. Changing a partition scheme means rewriting the table, so a bad choice is lived with far longer than a bad query.
The formulations
Partition by dateship
.partitionBy('event_date') # ~365 dirs/year, GBs each
Filtered by nearly every query, and each partition is a useful size.
Partition by date, cluster by usership
.partitionBy('event_date') + ZORDER BY (user_id)
Coarse separation plus fine skipping. The answer for a high-cardinality filter.
Partition by user_idavoid
.partitionBy('user_id') # millions of dirs, a few rows each
Small files plus a listing cost that dominates the query.
Partition by a column nothing filters onavoid
.partitionBy('source_system')
All the write cost, none of the pruning.
The answer most people give
"Partition on the column with the most distinct values so partitions stay small." Backwards. High cardinality is exactly what produces millions of tiny partitions; what you want is enough distinct values to prune usefully and few enough that each partition is worth reading.
They’ll ask next
Queries filter on both date and country. Do you partition by both?