SQL at Scale
The candidate is 3 rounds into a data engineering loop at Netflix, and the interviewer shares 1 table, playback_events, with the 4 columns profile_id, title_id, device_type and seconds_watched. The ask sounds like any SQL screen: total seconds watched per title for a day, highest first. The candidate writes it in a minute and the interviewer nods. Then comes the tail. 'That table is among the largest we keep, and the query runs as a Spark job. Walk me through what happens when it executes.' The candidate has written correct SQL and now has to say which line moves rows across the network, where the stages break, and what they would open first if the job ran for an hour. That second half is the question. This lesson builds the answer: the logic first, then the plan Spark runs, then the line that costs the most.
What you will be able to do
2 halves of the same question
- the filter drops the right rows
- the grouping key matches the question
- the join condition and join type are right
- the ordering is the one asked for
- you name each operation that moves rows
- you name the column it moves them by
- you count the shuffles and the stages
- you name what you would open in the Spark UI
The tail wears disguises
| How the tail is phrased | What it is asking for |
|---|---|
| This table is 10 TB. Does your query still work? | Which operations move rows, and where the plan breaks into stages |
| What happens in the cluster when you hit run? | The stages in order, with the shuffle between each pair |
| Where would this be slow? | The wide operation that moves the most rows |
| Which part of this needs the most memory? | The stage that holds the most rows on a single task: the join or the aggregate |
| How many jobs and stages is this? | The stage count, shuffles plus 1; the Jobs tab can list several jobs for 1 action, such as the sampling job an orderBy runs first |
| What would you look at in the Spark UI? | The SQL tab for the plan, the Stages tab for shuffle sizes and task times |
The model the tail assumes
The running example
Here is the table the rest of the lesson runs on: 7 orders, where each row's amount is tagged with a category and a status. The question is the one from the intro in a retail disguise: revenue per category, counting only completed orders, largest revenue first. Mapped back, category stands in for title_id and amount for seconds_watched. The filter on status replaces the filter on the day, so the plan built here is the answer to the Netflix tail. With 7 rows you can check the logic by hand and still follow each row as it moves between partitions in the next section.
| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 40 |
| 2 | Games | completed | 25 |
| 3 | Books | returned | 60 |
| 4 | Music | completed | 35 |
| 5 | Games | completed | 50 |
| 6 | Books | completed | 20 |
| 7 | Music | completed | 55 |
| category | revenue |
|---|---|
| Music | 90 |
| Games | 75 |
| Books | 60 |
The same logic as a DataFrame
3 correctness traps the tail will expose
- Filter on row attributes in WHERE, before the aggregate
- State the result with real numbers before the tail
- Name the grouping column out loud: it becomes the shuffle key
- Add a tiebreaker to ORDER BY when order must be stable
- Patch a HAVING on status by grouping on status: returned rows cross the shuffle
- Use COUNT(amount) when you mean COUNT(*)
- Start on execution with a query you haven't checked
- Rewrite the query during the tail without saying the plan changed
Each clause is already an operator
| Clause | Physical operators | Moves rows between partitions? |
|---|---|---|
| FROM orders | FileScan | No |
| WHERE status = 'completed' | Filter | No |
| GROUP BY category, SUM(amount) | HashAggregate (partial), Exchange hashed by category, HashAggregate (final) | Yes, once |
| ORDER BY revenue DESC | Exchange by revenue range, Sort | Yes, once |
| collect() | result returned to the driver | Rows leave the executors for the driver, outside the stages |
Calling explain() on the DataFrame prints these names, and each Exchange hashpartitioning or Exchange rangepartitioning node marks a stage boundary.
| Operation | Narrow or wide | Why |
|---|---|---|
| filter, where | Narrow | each row decides for itself |
| select, withColumn | Narrow | computed from the row alone |
| union | Narrow | partitions are concatenated, nothing moves |
| groupBy().agg() | Wide | every row of a key must meet in a single task |
| join between 2 large tables | Wide, both sides | matching keys must meet in a single task |
| join to a table under 10 MB | Narrow in effect | the small table is copied to every executor, no shuffle |
| orderBy, sort | Wide | a range Exchange places each row by its value |
| distinct, dropDuplicates | Wide | duplicates of a key must meet in a single task |
| repartition(col) | Wide | its purpose is the Exchange; a later groupBy on the same column reuses it |
Watch the example move
(orders.filter(F.col("status") == "completed").groupBy("category").agg(F.sum("amount").alias("revenue")).orderBy(F.col("revenue").desc()).collect())
Read the board as 3 stages. In stage 1, each task scans its partition with the filter applied and pre-sums by category before writing shuffle files hashed by category. Stage 2 finishes each category's sum from those files and writes shuffle files split by revenue range. Stage 3 sorts within each range and hands rows to collect(). Count 2 Exchanges and 3 stages, and notice what each Exchange moved: the first carried partial sums, at most 1 row per category from each task, and the second carried 3 finished rows. On the real table the proportions hold: the first Exchange moves at most categories times tasks rows, and the second moves a row per category.
| Stage | Runs | Ends with |
|---|---|---|
| 1 | scan orders, filter completed, partial sum per category | hash Exchange on category |
| 2 | final sum per category | range Exchange on revenue |
| 3 | sort by revenue descending | rows returned to the driver |
Say it the way the plan says it
The filter is narrow and runs with the scan. The groupBy on category is the first wide operation: an exchange hashed by category, 200 reducers by default. The orderBy on revenue is the second, a range exchange. That makes 2 shuffles and 3 stages, and the driver receives a row per category.
Interviewers like to add 2 cases that change the count. A join to a small table under 10 MB is broadcast: Spark ships the small table to every executor and the join runs inside the big table's current stage. The plan shows a BroadcastExchange above the small side, and that node copies the small table to the executors: no row of the big table moves and its stage doesn't break. Only Exchange hashpartitioning and Exchange rangepartitioning count as shuffles. And a groupBy on a column the data was already repartitioned by reuses that partitioning: Spark sees the rows for each key already together and skips the second Exchange. If the interviewer adds a join or a repartition to your query, apply the test again rather than adding a stage by reflex.
Where to look if it were slow
- ▸Exchange count, read from the plan tree in the SQL tab
- ▸Shuffle Write per stage, read from the Stages tab
- ▸Longest task against median task within a stage, read from the task table
3 shapes of the freeze
| What the candidate says | What the interviewer hears |
|---|---|
| Spark would distribute it across the cluster. | Nothing about this query: no operation, no key, no count |
| I would cache the table and add more executors. | A remedy for a problem that has not been located |
| The WHERE clause would be the expensive part on 10 TB. | A narrow operation named as the cost, which is an error |
| It depends on the data. | A true sentence that ends the conversation |
The 4-sentence frame
- ▸First: the narrow operations and the stage they share with the scan.
- ▸Second: the first wide operation and the exchange it causes on its key.
- ▸Third: each further wide operation with its key, then the total count of shuffles and stages.
- ▸Fourth: the place in the Spark UI you would confirm it, and the number you would read there.
The filter on status is narrow and stays in the scan's stage. The first wide operation is the groupBy, a hash exchange on category, and each task pre-sums before it. The orderBy on revenue adds a range exchange, so the plan has 2 shuffles and 3 stages. In the Stages tab I would read Shuffle Write for stage 1, which should be small because each task sends one partial total for each category it saw, never the orders themselves.
When the frame gets a follow-up
- starts with a remedy
- speaks about Spark in general
- names no column
- cannot say how many stages
- starts with the first wide operation
- speaks about this query
- names the key of every exchange
- gives a shuffle count and a stage count
To practice the frame, take any query you have written this week and, with the plan covered, say the 4 sentences before running explain(). Then compare: every Exchange hashpartitioning or Exchange rangepartitioning in the output should match a wide operation you named, and nothing you named as narrow should appear beside one. After a week of this, the first wide operation in a new query shows up as you read it.
The last turn of the tail is a ranking: of the lines in this transform, which costs the most? The answer at this depth comes from a single measure, rows crossing an exchange. The operator's name tells you whether rows cross at all. How many cross depends on where the operation sits in the chain and what sits before it. A sort of 50 aggregated rows and a sort of 200 million order rows are the same orderBy, and one of them is the costliest line in the job while the other is free.
Rank the running example
Put the first query on a table of 1 billion orders, of which 1 in 5 is completed, with 50 categories. The scan reads the table and is narrow. The filter passes 200 million rows and is narrow. The groupBy exchange carries partial sums rather than orders: with 2,000 scan tasks and 50 categories, at most 100,000 rows cross the network. The orderBy exchange carries 50 rows. The costliest line by rows crossing an exchange is the groupBy, and it's cheap in absolute terms. The costliest line in wall clock is the scan plus filter, which touches every byte, and that cost belongs to the table and arrives with every query over it.
| Line | Rows in | Rows crossing an exchange | Rank by shuffle cost |
|---|---|---|---|
| scan orders | 1,000,000,000 | 0 | narrow |
| filter completed | 1,000,000,000 | 0 | narrow |
| groupBy category | 200,000,000 | up to 100,000 partial sums | 1 |
| orderBy revenue | 50 | 50 | 2 |
| collect | 50 | 50, to the driver | outside the stages |
Now remove the aggregate. Ask for every completed order sorted by amount, and the orderBy is the only wide operation, with 200 million rows crossing its range exchange and 200 million rows sorted across 200 reducers. It's the same operator in a different place in the chain, and it goes from near free to the whole cost of the job.
Add a join
The interviewer's favourite extension adds a second table. Here orders gain a customer_id and a 3 row customers table holds each customer's region, and the question becomes completed revenue per region. The customers table is tiny, so Spark broadcasts it, and the program below says so with F.broadcast to make the choice visible on the board. Watch the join run inside the scan's stage: no row of orders moves for it.
(orders.filter(F.col("status") == "completed").join(F.broadcast(customers), "customer_id").groupBy("region").agg(F.sum("amount").alias("revenue")).collect())
Count it: 2 stages separated by 1 shuffle exchange hashed by region; the plan's BroadcastExchange is the copy of customers. The join cost a copy of the 3 row table to each executor and nothing else. West collects 150 from 4 orders, numbers 1, 4, 6 and 7, while East collects 75 from orders 2 and 5, and the driver receives 2 rows. In the ranking, the groupBy on region is the only wide line, and the join sits with the filter as narrow. Had the program left out the hint, Spark would have reached the same plan on its own, because customers is under the 10 MB threshold.
The same join when both sides are large
Replace the 3 row customers table with 500 million customer rows and the broadcast is off the table. Spark exchanges both sides on customer_id: 200 million completed orders and 500 million customer rows both go out hashed by customer. Then 200 reducers sort and merge their keys, and a third exchange hashes the joined rows by region for the sum. The join is now the costliest line by a wide margin: 700 million rows cross for it, against the partial sums of a row per region per task that cross for the aggregate. The frame changes to 3 exchanges and 4 stages, and the Stages tab would show the 2 stages feeding the join with the largest Shuffle Write.
| Line | Small customers (broadcast) | Large customers (sort-merge) |
|---|---|---|
| filter completed | narrow | narrow |
| join on customer_id | narrow in effect, 3 rows copied to each executor | both sides exchanged, 700,000,000 rows cross |
| groupBy region | a hash exchange carrying partial sums | a hash exchange carrying partial sums |
| shuffles, stages | 1, 2 | 3, 4 |
| costliest line | groupBy region | join on customer_id |
Reading the answer off the Spark UI
The capstone problems after this lesson hand you transforms you haven't seen, some with a join, some with a distinct, some with a repartition before the groupBy. For each, write the logic, say the 4 sentences, and then rank the wide lines by what reaches them.
> A marketplace's finance team asks for yesterday's completed revenue by seller region before the 9 am review. The orders table holds 1 billion rows, about 200 million of them completed, and the sellers table holds 40,000 rows with a region each. The job runs on a Spark cluster with the default of 200 shuffle partitions.
Write the query, then say where Spark shuffles it: the 2 halves of one interview question.
- Category
- Spark
- Difficulty
- beginner
- Duration
- 14 minutes
- Challenges
- 3 hands-on challenges
Topics covered: The Transform With a Tail, Write the Logic First, Point at the Wide Op, Correct Logic, Frozen, Which Line Costs the Most
Lesson Sections
- The Transform With a Tail
Most SQL interview questions end when the query is right. This one keeps going. Once you've written the aggregate or join the interviewer asked for, the conversation turns to the machine: the table is too large for 1 node, so Spark executes the statement across a cluster, and the interviewer wants to hear what that execution looks like. The tail is the point of the exercise. A query that returns the right rows on a laptop and the same right rows across 2,000 cores is the same query, so the first
- Write the Logic First
Logic comes first and execution second. An execution story about a query that returns the wrong rows is worth nothing, because the interviewer will correct the query and the story will no longer match it. The plan is also derived from the logic: the grouping column decides what the aggregate shuffles by, the join condition decides what the join shuffles by, and the ORDER BY decides whether a range shuffle appears at all. Change the query and you change the plan. So write the query, check it agai
- Point at the Wide Op
The second half depends on pointing at the wide operation, and that comes down to a single test. Ask of each operation: can a task produce its output partition from 1 input partition alone? If yes, the operation is narrow, and Spark runs it inside the current stage. If the output for a key needs rows from every input partition, the operation is wide, Spark inserts an Exchange, and a new stage begins after it. The test is about the key rather than the operator's name. A grouped sum needs every ro
- Correct Logic, Frozen
Here is the failure this question is built to catch. After writing a correct query and checking the result out loud, the candidate hears the tail and stops. What follows is one of 3 things: silence, a recital about Spark being distributed and lazy that never touches the query, or a jump to remedies such as adding executors or caching before any operation has been named. Each leaves the interviewer with the same note: knows SQL, cannot read a plan. The fix is a frame you can produce for any trans
- Which Line Costs the Most
Rank the running example Add a join The same join when both sides are large Reading the answer off the Spark UI The UI gives the same ranking as numbers. In the Stages tab, the stage with the largest Shuffle Write is the stage that ends in the costliest exchange, and the operation that caused it is the line you name. For the broadcast version there is a single such stage and its write is small. For the sort-merge version, 2 stages write hundreds of millions of rows each, and the join is the answ