PySpark Interview Questions for Data Engineers — From Basics to Production
Master PySpark the way it is tested in real data engineering interviews. This course covers everything from Spark architecture and lazy evaluation to joins, window functions, Delta Lake, MERGE operations, SCD patterns, streaming, and performance tuning. Every question is written the way a real interviewer asks it, with a clear thinking framework before any code, production-grade PySpark solutions, and concise answers to the follow-up questions that separate good candidates from great ones. Built for engineers with hands-on Spark experience who want to go deeper and walk into interviews fully prepared
Course Topics
Reading Data and Basic Transformations
The Interviewer Says
Let us start with the fundamentals before going into complex transformations. I want to see how you set up a Spark environment, read data, and perform some basic operations on a DataFrame. I have a CSV file with employee data. Walk me through how you would read it, explore it, and then apply some basic transformations like filtering, adding a new column, and renaming a column.
Input Data — employees.csv
emp_id,name,dept,salary,joining_date
E01,Karan,Finance,90000,2022-01-15
E02,Asha,IT,75000,2021-06-20
E03,Ravi,Finance,60000,2023-03-10
E04,Meena,HR,80000,2020-09-05
E05,Sunil,IT,70000,2022-11-30
E06,Divya,HR,55000,2023-07-18
How to Handle This in the Interview
Before writing anything, say: Let me walk through this in a logical order, environment setup first, then reading, then exploration, then transformations. Each step builds on the previous one.
Step 1: Understand SparkSession
- Say: SparkSession is the single entry point to all Spark functionality. In older versions of Spark you had SparkContext, SQLContext, and HiveContext as separate objects. From Spark 2.0 onwards SparkSession unifies all of them. The first thing in any PySpark script is always creating or getting the SparkSession.
Step 2: Read with the right options
- When reading a CSV, always think about three options, whether the first row is a header, whether Spark should infer the schema automatically or use a defined schema, and how to handle corrupt records. Say: For production pipelines I always define the schema explicitly rather than using inferSchema, since inferSchema does a full extra scan of the file just to guess types, which is expensive and can guess wrong.
Step 3: Explore before transforming
- Show, printSchema, describe, and count are the four basic exploration steps. Mention all of them and why each one is useful rather than jumping straight into transformations.
Step 4: Common transformation operations
- Filter rows, add a derived column using withColumn, rename a column using withColumnRenamed, select specific columns, and drop a column. These five operations cover the majority of basic interview transformation questions.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import ( StructType, StructField, StringType, IntegerType, DateType ) # Step 1: Create SparkSession spark = SparkSession.builder \ .appName("EmployeeTransformations") \ .getOrCreate() # Step 2: Define schema explicitly (preferred over inferSchema in production) schema = StructType([ StructField("emp_id", StringType(), nullable=True), StructField("name", StringType(), nullable=True), StructField("dept", StringType(), nullable=True), StructField("salary", IntegerType(), nullable=True), StructField("joining_date", DateType(), nullable=True) ]) # Step 3: Read CSV with defined schema df = spark.read \ .option("header", "true") \ .option("mode", "DROPMALFORMED") \ .schema(schema) \ .csv("employees.csv") # Step 4: Explore the DataFrame df.show() df.printSchema() df.describe().show() print("Total rows:", df.count()) print("Total partitions:", df.rdd.getNumPartitions()) # Step 5: Filter rows where salary is greater than 70000 df_filtered = df.filter(F.col("salary") > 70000) df_filtered.show() # Step 6: Add a new column for annual bonus as 10 percent of salary df_with_bonus = df_filtered.withColumn( "annual_bonus", F.round(F.col("salary") * 0.10, 2) ) df_with_bonus.show() # Step 7: Add a designation column based on salary df_with_designation = df_with_bonus.withColumn( "designation", F.when(F.col("salary") > 80000, "Senior") .when(F.col("salary") > 70000, "Mid Level") .otherwise("Junior") ) df_with_designation.show() # Step 8: Rename a column df_renamed = df_with_designation.withColumnRenamed("name", "employee_name") df_renamed.show() # Step 9: Select specific columns only df_final = df_renamed.select( "emp_id", "employee_name", "dept", "salary", "annual_bonus", "designation" ) df_final.show() # Step 10: Drop a column df_dropped = df_final.drop("annual_bonus") df_dropped.show()
What to say: I always define the schema explicitly in production rather than using inferSchema because inferSchema triggers a full additional scan of the source file just to guess data types, doubling the read cost before any actual processing begins. I also chain transformations rather than writing each one as a separate variable where possible, since PySpark transformations are lazy and the actual computation only happens when an action like show or count is called. Each withColumn, filter, and select call just adds to the logical plan without executing anything.
Expected Output — df_final
| emp_id | employee_name | dept | salary | annual_bonus | designation |
|---|---|---|---|---|---|
| E01 | Karan | Finance | 90000 | 9000.0 | Senior |
| E04 | Meena | HR | 80000 | 8000.0 | Mid Level |
Follow-up Questions & Answers
Q1. What is the difference between inferSchema and defining the schema manually? When would you use each?
inferSchema tells Spark to automatically detect the data type of each column by scanning the entire dataset first before reading it, which means Spark reads the file twice, once to infer types and once to actually load the data. This doubles the read cost and can guess wrong, for example reading a column of IDs like 001, 002 as integers and silently dropping the leading zeros, or reading a date column as a string because the format does not match what Spark expects. Defining the schema manually with StructType and StructField gives you full control over every column's type and nullability, costs no extra scan, and fails fast with a clear error if the data does not match rather than silently producing wrong types. In production always define the schema. inferSchema is acceptable only for quick exploratory work in a notebook where speed of prototyping matters more than correctness guarantees.
Q2. What is lazy evaluation in Spark and why does it matter?
Lazy evaluation means Spark does not actually execute any transformation, meaning filter, withColumn, select, join, and so on, the moment you call them. Instead it builds up a logical plan describing what you want to do. The actual computation only happens when you call an action like show, count, collect, write, or take. This matters for two reasons. First it allows Spark's Catalyst optimiser to look at the entire logical plan before executing it and rearrange or optimise the steps, for example pushing filter conditions earlier in the plan so less data flows through later stages. Second it means you can chain many transformations cheaply and the cost is only paid once when the action fires. The common misconception is thinking each transformation line executes immediately the way Python list operations do, which leads to confusion about when data is actually being processed.
Q3. What is the difference between show, collect, and take in PySpark?
All three trigger execution but they behave very differently in terms of where the data goes and how much of it moves.
# show prints up to N rows directly to the console, data stays in the cluster df.show(10) # collect brings ALL rows from every partition into the driver's memory as a Python list # dangerous on large datasets, can crash the driver with an out of memory error rows = df.collect() # take brings only the first N rows to the driver, much safer than collect first_5 = df.take(5) # head is an alias for take first_3 = df.head(3)
What to say: I use show for quick visual inspection in development and notebooks. I never use collect on a large DataFrame in production since it moves the entire dataset to the driver node which has limited memory, a DataFrame with a billion rows would almost certainly crash the driver. If I genuinely need data in Python I use take with a reasonable limit, or write it to a file and read it back in a controlled way.
Q4. What is the difference between withColumn and select for adding a new column?
Both can add columns to a DataFrame but they have different implications for performance and readability.
# withColumn adds or replaces one column while keeping all existing columns df_with_bonus = df.withColumn("annual_bonus", F.col("salary") * 0.10) # select explicitly lists every column you want in the output # useful when you want to reorder, rename, and add in one step df_with_bonus = df.select( "*", (F.col("salary") * 0.10).alias("annual_bonus") ) # For many columns, chaining multiple withColumn calls can be expensive # in older Spark versions because each call creates a new plan node # select with all columns at once is more efficient for wide transformations df_transformed = df.select( F.col("emp_id"), F.col("name"), F.col("salary"), (F.col("salary") * 0.10).alias("annual_bonus"), F.when(F.col("salary") > 80000, "Senior").otherwise("Junior").alias("designation") )
What to say: For adding one or two columns withColumn is clear and readable. For adding many columns at once or when you also want to reorder and rename in the same step, select with explicit column expressions is more efficient since it creates a single plan node rather than stacking multiple withColumn nodes. In Spark versions before 3.0, chaining many withColumn calls on a very wide DataFrame could cause performance issues due to plan explosion, which select avoids entirely.
Q5. What are the different read modes in Spark and when would you use each?
When reading structured files like CSV or JSON, Spark has three modes for handling malformed or corrupt records.
# PERMISSIVE (default): reads all rows, sets corrupt fields to NULL # and optionally stores the bad row in a _corrupt_record column df_permissive = spark.read \ .option("header", "true") \ .option("mode", "PERMISSIVE") \ .option("columnNameOfCorruptRecord", "_corrupt_record") \ .schema(schema) \ .csv("employees.csv") # DROPMALFORMED: silently drops any row that cannot be parsed df_drop = spark.read \ .option("header", "true") \ .option("mode", "DROPMALFORMED") \ .schema(schema) \ .csv("employees.csv") # FAILFAST: throws an exception immediately on the first bad row df_strict = spark.read \ .option("header", "true") \ .option("mode", "FAILFAST") \ .schema(schema) \ .csv("employees.csv")
What to say: PERMISSIVE is the default and the most forgiving, it keeps all rows and surfaces bad data in a separate column so you can investigate it later without losing the good rows. I use this in data ingestion pipelines where I want to capture and quarantine bad records rather than drop them silently. DROPMALFORMED is useful when bad rows are expected and genuinely useless, for example log files where a handful of corrupted lines should not stop the entire pipeline. FAILFAST is useful in strict validation scenarios where any bad record means the source data has a serious quality problem that must be fixed before processing can continue, common in financial or compliance pipelines where partial data would be worse than no data.
Joins in PySpark
The Interviewer Says
We have two DataFrames, an orders DataFrame and a customers DataFrame. I want you to demonstrate different types of joins in PySpark, and then tell me how you would optimise a join when one of the DataFrames is very small compared to the other. Walk me through your approach.
Input Data
orders DataFrame
| order_id | customer_id | product | amount |
|---|---|---|---|
| O01 | C01 | Laptop | 75000 |
| O02 | C02 | Phone | 25000 |
| O03 | C01 | Tablet | 30000 |
| O04 | C03 | Monitor | 15000 |
| O05 | C05 | Keyboard | 5000 |
customers DataFrame
| customer_id | name | city |
|---|---|---|
| C01 | Karan | Mumbai |
| C02 | Asha | Delhi |
| C03 | Ravi | Pune |
| C04 | Meena | Chennai |
How to Handle This in the Interview
Before writing anything, say: Joins in PySpark work similarly to SQL joins but there are some important PySpark specific considerations around performance, especially around shuffle joins versus broadcast joins, and how to handle duplicate column names when both DataFrames share a join key with the same name.
Step 1: Know the join types available
- PySpark supports inner, left, right, full outer, left semi, left anti, and cross joins. Knowing when to use left semi and left anti is something that separates a good candidate from a great one.
Step 2: Handle the duplicate column name problem
- When both DataFrames have a column with the same name and you join using that column, PySpark keeps both copies in the result which creates ambiguous column references. Say: There are two ways to handle this, either drop the duplicate after joining or use the join condition as a string column name rather than a column expression, which automatically deduplicates the join key.
Step 3: Know when to use broadcast join
- Say: When one DataFrame is small enough to fit in memory on every executor, typically under a few hundred megabytes, a broadcast join is dramatically faster than a shuffle join because it eliminates the expensive shuffle operation entirely by sending the small DataFrame to every node.
Step 4: Mention the Catalyst optimiser
- Say: Spark's Catalyst optimiser will automatically apply a broadcast join if the smaller DataFrame is below the threshold set by spark.sql.autoBroadcastJoinThreshold, which defaults to 10 megabytes. For larger small tables I can force it manually using the broadcast hint.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = SparkSession.builder.appName("JoinsDemo").getOrCreate() # Create DataFrames orders_data = [ ("O01", "C01", "Laptop", 75000), ("O02", "C02", "Phone", 25000), ("O03", "C01", "Tablet", 30000), ("O04", "C03", "Monitor", 15000), ("O05", "C05", "Keyboard", 5000), ] orders_df = spark.createDataFrame( orders_data, ["order_id", "customer_id", "product", "amount"] ) customers_data = [ ("C01", "Karan", "Mumbai"), ("C02", "Asha", "Delhi"), ("C03", "Ravi", "Pune"), ("C04", "Meena", "Chennai"), ] customers_df = spark.createDataFrame( customers_data, ["customer_id", "name", "city"] ) # INNER JOIN # Returns only rows where customer_id exists in both DataFrames inner_join = orders_df.join(customers_df, on="customer_id", how="inner") print("INNER JOIN:") inner_join.show() # LEFT JOIN # Returns all orders, NULL for customer details if no match left_join = orders_df.join(customers_df, on="customer_id", how="left") print("LEFT JOIN:") left_join.show() # RIGHT JOIN # Returns all customers, NULL for order details if no match right_join = orders_df.join(customers_df, on="customer_id", how="right") print("RIGHT JOIN:") right_join.show() # FULL OUTER JOIN # Returns everything from both sides full_join = orders_df.join(customers_df, on="customer_id", how="full") print("FULL OUTER JOIN:") full_join.show() # LEFT SEMI JOIN # Returns only orders where a matching customer exists # but does NOT include any columns from customers_df semi_join = orders_df.join(customers_df, on="customer_id", how="left_semi") print("LEFT SEMI JOIN:") semi_join.show() # LEFT ANTI JOIN # Returns only orders where NO matching customer exists # opposite of left semi join anti_join = orders_df.join(customers_df, on="customer_id", how="left_anti") print("LEFT ANTI JOIN:") anti_join.show() # BROADCAST JOIN # Force broadcast when customers_df is small # to avoid an expensive shuffle operation broadcast_join = orders_df.join( F.broadcast(customers_df), on="customer_id", how="inner" ) print("BROADCAST JOIN:") broadcast_join.show() # Handling duplicate column names when join key names differ # or when you need both copies orders_renamed = orders_df.withColumnRenamed("customer_id", "order_customer_id") explicit_join = orders_renamed.join( customers_df, orders_renamed["order_customer_id"] == customers_df["customer_id"], how="inner" ) explicit_join.show()
What to say: I pass the join key as a string when both DataFrames share the same column name, using on equals customer_id as a string, because PySpark then automatically keeps only one copy of the join key column in the result. If I passed it as a column expression like orders_df customer_id equals customers_df customer_id, PySpark would keep both copies and any subsequent reference to customer_id would be ambiguous and throw an AnalysisException. For the broadcast join I wrap the smaller DataFrame in F.broadcast to give Spark an explicit hint even if the DataFrame happens to be above the auto broadcast threshold.
Expected Outputs
INNER JOIN: 4 rows
| customer_id | order_id | product | amount | name | city |
|-------------|----------|----------|-------:|------|--------|
| C01 | O01 | Laptop | 75000 | Karan | Mumbai |
| C01 | O03 | Tablet | 30000 | Karan | Mumbai |
| C02 | O02 | Phone | 25000 | Asha | Delhi |
| C03 | O04 | Monitor | 15000 | Ravi | Pune |
LEFT JOIN: 5 rows (O05 kept with NULL customer details)
| customer_id | order_id | product | amount | name | city |
|-------------|----------|----------|-------:|------|--------|
| C01 | O01 | Laptop | 75000 | Karan | Mumbai |
| C01 | O03 | Tablet | 30000 | Karan | Mumbai |
| C02 | O02 | Phone | 25000 | Asha | Delhi |
| C03 | O04 | Monitor | 15000 | Ravi | Pune |
| C05 | O05 | Keyboard | 5000 | NULL | NULL |
LEFT SEMI JOIN: only order columns, matched rows only
| customer_id | order_id | product | amount |
|-------------|----------|----------|-------:|
| C01 | O01 | Laptop | 75000 |
| C01 | O03 | Tablet | 30000 |
| C02 | O02 | Phone | 25000 |
| C03 | O04 | Monitor | 15000 |
LEFT ANTI JOIN: only unmatched orders
| customer_id | order_id | product | amount |
|-------------|----------|----------|-------:|
| C05 | O05 | Keyboard | 5000 |
Follow-up Questions & Answers
Q1. What is the difference between left semi join and inner join? When would you use left semi join?
An inner join returns all columns from both DataFrames for matching rows, and if there are duplicate keys on the right side it multiplies the rows just like a SQL inner join. A left semi join returns only the columns from the left DataFrame for rows that have at least one match on the right, and it never duplicates rows regardless of how many matches exist on the right side. Left semi join is essentially a filter operation, it tells you which rows in the left DataFrame have a corresponding record on the right, without actually merging any columns from the right. Use left semi join when you want to filter one DataFrame based on the existence of a key in another, but do not need any data from the second DataFrame. A common real world example is filtering a large transactions table to only keep transactions whose customer exists in a valid customers table, without adding any customer columns to the result.
Q2. What is a shuffle join and why is it expensive?
A shuffle join, also called a sort merge join in Spark, is the default join strategy when both DataFrames are large. Spark needs to ensure all rows with the same join key end up on the same executor node before it can compare them. To achieve this it redistributes data across the network by hashing the join key and sending each row to the executor responsible for that hash bucket. This redistribution across the network is called a shuffle and it is one of the most expensive operations in Spark because it involves serialising data, writing it to disk, transferring it across the network between nodes, deserialising it on the receiving end, and then sorting it before the actual join can happen. On a cluster with hundreds of gigabytes of data, a shuffle can take minutes and is almost always the single biggest bottleneck in a Spark job.
Q3. How does a broadcast join avoid the shuffle and what are its limitations?
In a broadcast join, Spark takes the smaller DataFrame and sends a complete copy of it to every executor node in the cluster. Each executor then performs the join locally by looking up keys in its local copy of the small DataFrame, with no data movement between executors needed at all. This eliminates the shuffle entirely which is why it is so much faster. The limitation is size, the smaller DataFrame must be small enough to fit comfortably in the memory of every executor. If you broadcast a DataFrame that is too large, executors run out of memory and the job fails. The default auto broadcast threshold in Spark is 10 megabytes, controlled by spark.sql.autoBroadcastJoinThreshold. You can raise this if your executors have enough memory.
# Raise the auto broadcast threshold to 100MB spark.conf.set("spark.sql.autoBroadcastJoinThreshold", 100 * 1024 * 1024) # Or force a broadcast hint manually regardless of size result = large_df.join(F.broadcast(small_df), on="key", how="inner")
Q4. How would you handle the ambiguous column reference problem when both DataFrames have columns with the same name beyond just the join key?
When both DataFrames have multiple columns with the same name, joining them creates ambiguity for every shared column not just the key. The cleanest solutions are to rename columns before joining or to select and alias after joining.
# Option 1: Rename conflicting columns before joining orders_df2 = orders_df.withColumnRenamed("amount", "order_amount") result = orders_df2.join(customers_df, on="customer_id", how="inner") # Option 2: Use DataFrame specific column references after joining # when both have a column called 'amount' result = orders_df.join( customers_df, on=orders_df["customer_id"] == customers_df["customer_id"], how="inner" ).select( orders_df["customer_id"], orders_df["order_id"], orders_df["amount"].alias("order_amount"), customers_df["name"], customers_df["city"] ) # Option 3: Add a prefix to all columns of one DataFrame before joining from functools import reduce prefixed_customers = customers_df.select( [F.col(c).alias("cust_" + c) for c in customers_df.columns] ) result = orders_df.join( prefixed_customers, orders_df["customer_id"] == prefixed_customers["cust_customer_id"], how="inner" )
What to say: Option 1 is the cleanest for known column conflicts. Option 2 is useful when you need precise control over exactly which columns appear in the result. Option 3, prefixing all columns from one side, is a useful defensive pattern in generic pipeline code where you cannot predict which column names might clash, since it guarantees no ambiguity regardless of the source schema.
Q5. What would you check in the Spark UI if a join is running much slower than expected?
What to say: The Spark UI is the first place I go when a job is slower than expected. For a slow join I look at three specific things.
First I check the DAG in the SQL tab to confirm which join strategy Spark actually chose. If I expected a broadcast join but the plan shows a SortMergeJoin, it means the DataFrame was above the broadcast threshold and I need to either raise the threshold or explicitly add the broadcast hint.
Second I check the Stages tab for any stage with an unusually high amount of data read or written. A shuffle join stage that reads and writes hundreds of gigabytes signals that the shuffle itself is the bottleneck, and I would consider repartitioning the DataFrames on the join key before the join to reduce the shuffle cost.
Third I check for data skew in the Tasks tab within the shuffle stage. If most tasks finish in seconds but a handful take minutes, that means one or a few join key values have a disproportionately large number of rows, causing certain executors to receive far more data than others. This is called data skew and it requires a different fix, such as salting the join key to spread the skewed data across multiple partitions.
# Salting technique for skewed joins import random # Add a random salt to the skewed key in the large DataFrame orders_salted = orders_df.withColumn( "salted_key", F.concat(F.col("customer_id"), F.lit("_"), (F.rand() * 10).cast("int")) ) # Explode the small DataFrame to match all possible salt values from pyspark.sql.functions import array, explode, lit customers_salted = customers_df.withColumn( "salt", explode(array([lit(i) for i in range(10)])) ).withColumn( "salted_key", F.concat(F.col("customer_id"), F.lit("_"), F.col("salt")) ) # Join on salted key instead of raw customer_id result = orders_salted.join( customers_salted, on="salted_key", how="inner" ).drop("salted_key", "salt")
What to say: Salting works by artificially distributing the rows of a skewed key across multiple buckets by appending a random number to the key. The small DataFrame is expanded to have matching rows for every possible salt value. After joining on the salted key the salt columns are dropped. This spreads what was one overloaded task into ten smaller tasks that run in parallel, eliminating the straggler executor problem that skew causes.
GroupBy Aggregations and Window Functions
The Interviewer Says
We have a sales DataFrame with transactions across multiple products and regions. I want you to first perform some GroupBy aggregations to get summary statistics, and then use window functions to add running totals and rankings within groups, all without losing the row level detail. Walk me through how you think about when to use GroupBy versus window functions.
Input Data
| txn_id | product_id | region | sales_date | amount |
|---|---|---|---|---|
| T01 | P01 | North | 2024-01-05 | 5000 |
| T02 | P01 | South | 2024-01-06 | 7000 |
| T03 | P02 | North | 2024-01-07 | 3000 |
| T04 | P01 | North | 2024-01-08 | 6000 |
| T05 | P02 | South | 2024-01-09 | 4000 |
| T06 | P02 | North | 2024-01-10 | 8000 |
| T07 | P01 | South | 2024-01-11 | 9000 |
| T08 | P02 | South | 2024-01-12 | 2000 |
How to Handle This in the Interview
Before writing anything, say: GroupBy collapses multiple rows into one summary row per group, which means you lose the row level detail. Window functions add computed values alongside each row without collapsing anything. The choice between them depends entirely on whether the business needs a summary or a row level enrichment.
Step 1: GroupBy for summary statistics
- GroupBy with agg is the right choice when the output should be one row per group, for example total sales per product or average amount per region. The original rows are gone from the result.
Step 2: Window functions for row level enrichment
- When the business wants to see the running total, rank, or moving average alongside each individual transaction, use window functions. The original rows are preserved and the computed value is added as a new column.
Step 3: Know the Window specification components
- A Window spec has three optional parts, partitionBy which defines the group boundary, orderBy which defines the sort order within the group, and rowsBetween or rangeBetween which defines the frame. Knowing when each part is needed and what happens when one is omitted is important.
Step 4: Know which functions need an orderBy
- Ranking functions like rank, dense_rank, row_number, and lag always need an orderBy. Aggregation window functions like sum and avg work with or without orderBy but the frame changes depending on whether orderBy is present.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.window import Window spark = SparkSession.builder.appName("GroupByAndWindowFunctions").getOrCreate() data = [ ("T01", "P01", "North", "2024-01-05", 5000), ("T02", "P01", "South", "2024-01-06", 7000), ("T03", "P02", "North", "2024-01-07", 3000), ("T04", "P01", "North", "2024-01-08", 6000), ("T05", "P02", "South", "2024-01-09", 4000), ("T06", "P02", "North", "2024-01-10", 8000), ("T07", "P01", "South", "2024-01-11", 9000), ("T08", "P02", "South", "2024-01-12", 2000), ] df = spark.createDataFrame( data, ["txn_id", "product_id", "region", "sales_date", "amount"] ) # ------------------------------------------------------- # PART 1: GroupBy Aggregations # ------------------------------------------------------- # Total sales, average amount, max amount, and count per product product_summary = df.groupBy("product_id").agg( F.sum("amount").alias("total_sales"), F.avg("amount").alias("avg_sales"), F.max("amount").alias("max_sales"), F.count("*").alias("txn_count") ) print("Product Summary:") product_summary.show() # Total sales per product per region product_region_summary = df.groupBy("product_id", "region").agg( F.sum("amount").alias("total_sales") ).orderBy("product_id", "region") print("Product Region Summary:") product_region_summary.show() # Collect all transaction amounts per product as a list product_txn_list = df.groupBy("product_id").agg( F.collect_list("amount").alias("all_amounts"), F.collect_set("region").alias("unique_regions") ) print("Product Transaction List:") product_txn_list.show(truncate=False) # ------------------------------------------------------- # PART 2: Window Functions # ------------------------------------------------------- # Window spec: partition by product, order by sales_date w_product = Window.partitionBy("product_id").orderBy("sales_date") # Window spec: partition by product, full partition for aggregations w_product_full = Window.partitionBy("product_id") # Running total of amount per product ordered by date df_with_running_total = df.withColumn( "running_total", F.sum("amount").over( Window.partitionBy("product_id") .orderBy("sales_date") .rowsBetween(Window.unboundedPreceding, Window.currentRow) ) ) print("Running Total per Product:") df_with_running_total.show() # Rank each transaction within its product by amount descending df_with_rank = df.withColumn( "rank", F.rank().over( Window.partitionBy("product_id") .orderBy(F.col("amount").desc()) ) ).withColumn( "dense_rank", F.dense_rank().over( Window.partitionBy("product_id") .orderBy(F.col("amount").desc()) ) ).withColumn( "row_number", F.row_number().over( Window.partitionBy("product_id") .orderBy(F.col("amount").desc()) ) ) print("Ranks per Product:") df_with_rank.show() # LAG and LEAD: previous and next transaction amount per product df_with_lag_lead = df.withColumn( "prev_amount", F.lag("amount", 1).over(w_product) ).withColumn( "next_amount", F.lead("amount", 1).over(w_product) ).withColumn( "amount_diff_from_prev", F.col("amount") - F.lag("amount", 1).over(w_product) ) print("LAG and LEAD:") df_with_lag_lead.show() # Total sales of the entire product group attached to each row df_with_group_total = df.withColumn( "product_total", F.sum("amount").over(w_product_full) ).withColumn( "pct_of_product_total", F.round(F.col("amount") * 100 / F.sum("amount").over(w_product_full), 2) ) print("Each transaction as percentage of product total:") df_with_group_total.show()
What to say: I define the Window specification once and reuse it across multiple withColumn calls rather than repeating the full window definition each time, which keeps the code readable and avoids any chance of inconsistency between window specs that should be identical. The key distinction I always make clear is that groupBy produces one row per group while window functions keep every original row, and that difference drives which one I reach for based on what the output needs to look like.
Expected Output: Running Total
| txn_id | product_id | region | sales_date | amount | running_total |
|---|---|---|---|---|---|
| T01 | P01 | North | 2024-01-05 | 5000 | 5000 |
| T02 | P01 | South | 2024-01-06 | 7000 | 12000 |
| T04 | P01 | North | 2024-01-08 | 6000 | 18000 |
| T07 | P01 | South | 2024-01-11 | 9000 | 27000 |
| T03 | P02 | North | 2024-01-07 | 3000 | 3000 |
| T05 | P02 | South | 2024-01-09 | 4000 | 7000 |
| T06 | P02 | North | 2024-01-10 | 8000 | 15000 |
| T08 | P02 | South | 2024-01-12 | 2000 | 17000 |
Follow-up Questions & Answers
Q1. What is the difference between groupBy with agg and a window function using sum over, when should you use each?
groupBy with agg collapses the DataFrame to one row per group and computes the aggregate across all rows in that group. The individual transaction rows are gone from the result, replaced by a single summary row. A window function using sum over keeps every row and attaches the aggregate as a new column on each row. So if you have ten transactions for product P01 and you want one total, use groupBy. If you want each of those ten transactions to show both its own amount and the running total so far, use a window function. A practical way to remember this is to ask whether the business question is about the group as a whole, use groupBy, or about each individual row in the context of its group, use window function.
Q2. What is the difference between rowsBetween and rangeBetween in a window frame?
Both define which rows are included in the window calculation relative to the current row, but they differ in how they interpret the boundaries.
# rowsBetween uses physical row offsets # exactly 3 rows before and including the current row w_rows = Window.partitionBy("product_id") \ .orderBy("sales_date") \ .rowsBetween(-3, Window.currentRow) # rangeBetween uses value based offsets on the ORDER BY column # all rows whose sales_date is within 3 days before the current row w_range = Window.partitionBy("product_id") \ .orderBy("sales_date") \ .rangeBetween(-3, Window.currentRow) # Common constants # Window.unboundedPreceding = from the very first row of the partition # Window.currentRow = up to and including the current row # Window.unboundedFollowing = up to and including the very last row
What to say: rowsBetween counts actual physical rows regardless of the values in the ORDER BY column, so rowsBetween minus 2 and currentRow always includes exactly three rows. rangeBetween interprets the offset as a value range on the ORDER BY column, so for a date column rangeBetween minus 3 and currentRow includes all rows whose date is within three days before the current row's date, which could be zero rows or ten rows depending on the data. For running totals and rankings I always use rowsBetween since it gives predictable, deterministic behaviour. rangeBetween is useful when the frame should be defined by a time or value interval rather than a fixed row count.
Q3. What happens if you define a Window with partitionBy but no orderBy, and then use rank on it?
This throws an AnalysisException at runtime because ranking functions require an ordering to determine positions. There is no meaningful concept of which row is first or second without knowing the sort order, so Spark refuses to run it.
# This throws AnalysisException w_no_order = Window.partitionBy("product_id") df.withColumn("rank", F.rank().over(w_no_order)) # ERROR # This works because orderBy is specified w_with_order = Window.partitionBy("product_id").orderBy("amount") df.withColumn("rank", F.rank().over(w_with_order)) # OK
What to say: Ranking functions like rank, dense_rank, row_number, lag, and lead always need an orderBy inside the window spec. Aggregation functions like sum, avg, min, and max can work with or without orderBy, but the behaviour changes significantly. Without orderBy, sum over the full partition gives the grand total of the group for every row. With orderBy, sum defaults to a cumulative running total up to the current row. This difference catches many people off guard and is a common source of silent incorrect results.
Q4. How would you find the top one transaction per product by amount using window functions without using groupBy?
from pyspark.sql.window import Window from pyspark.sql import functions as F w_rank = Window.partitionBy("product_id").orderBy(F.col("amount").desc()) top_per_product = df.withColumn( "row_num", F.row_number().over(w_rank) ).filter(F.col("row_num") == 1) \ .drop("row_num") top_per_product.show()
What to say: This is a very common interview pattern. I use row_number rather than rank here specifically because row_number assigns a unique number to every row even when amounts are tied, guaranteeing exactly one row per product in the output. If I used rank and two transactions in P01 had the same highest amount, both would get rank one and I would get two rows for P01. Whether tied rows should both appear or only one should be picked is exactly the question I would ask the interviewer before choosing between row_number and rank.
Q5. What is the performance implication of having too many window functions with different partition and order specifications in the same query?
Each distinct window specification triggers a separate shuffle in Spark since data needs to be repartitioned and sorted differently for each unique combination of partitionBy and orderBy columns. If you have five window functions but three of them share the same window spec and two use a different one, Spark performs two separate shuffles.
# Less efficient: three different window specs trigger three shuffles df.withColumn("col1", F.sum("amount").over(Window.partitionBy("product_id").orderBy("sales_date"))) \ .withColumn("col2", F.rank().over(Window.partitionBy("product_id").orderBy("amount"))) \ .withColumn("col3", F.sum("amount").over(Window.partitionBy("region").orderBy("sales_date"))) # More efficient: reuse the same window spec object wherever possible w1 = Window.partitionBy("product_id").orderBy("sales_date") w2 = Window.partitionBy("product_id").orderBy("amount") w3 = Window.partitionBy("region").orderBy("sales_date") df.withColumn("running_total", F.sum("amount").over(w1)) \ .withColumn("amount_rank", F.rank().over(w2)) \ .withColumn("region_total", F.sum("amount").over(w3))
What to say: Defining window specs as named variables and reusing them is both more readable and more efficient. Spark recognises when multiple withColumn calls use the same window spec and combines them into a single shuffle and sort rather than repeating the expensive redistribution multiple times. I always extract window specs into named variables at the top of a transformation block and reuse them consistently, which is a small habit that makes a meaningful difference in job performance when the DataFrame is large.
Handling Null Values in PySpark
The Interviewer Says
In real world data pipelines, null values are one of the most common and dangerous problems you will face. A null in the wrong place can silently corrupt aggregations, break joins, or cause entire pipeline stages to fail. I have a customer transactions DataFrame with several null values scattered across different columns. Walk me through how you would detect, analyse, and handle nulls in PySpark, and tell me the different strategies you would consider for each type of column.
Input Data
| txn_id | customer_id | product | amount | txn_date | region |
|---|---|---|---|---|---|
| T01 | C01 | Laptop | 75000 | 2024-01-05 | North |
| T02 | C02 | NULL | 25000 | 2024-01-06 | South |
| T03 | NULL | Tablet | NULL | 2024-01-07 | North |
| T04 | C03 | Monitor | 15000 | NULL | NULL |
| T05 | C04 | Keyboard | 5000 | 2024-01-09 | South |
| T06 | C05 | Phone | NULL | 2024-01-10 | NULL |
How to Handle This in the Interview
Before writing anything, say: Null handling is not a one size fits all operation. Before deciding what to do with nulls I always want to first understand how many there are, which columns have them, and what the business meaning of a null is in each column. A null customer_id probably means a data quality failure and that row should be dropped. A null amount might mean the transaction is pending and should be filled with zero or flagged separately. A null region might mean the customer is international and can be filled with a default value.
Step 1: Quantify nulls before doing anything
- Say: The first thing I always do is write a null count check across every column so I know the scale of the problem before choosing a strategy. This is a diagnostic step, not a transformation step.
Step 2: Understand the three strategies
- Drop rows with nulls using dropna when a null makes the entire row meaningless. Fill nulls with a default value using fillna when a sensible default exists. Replace nulls with a derived value like a column mean or median when dropping or using a fixed default would distort the analysis.
Step 3: Apply different strategies per column
- Never apply the same null handling strategy to all columns blindly. Different columns have different business meanings and different appropriate responses to a null value.
Step 4: Null safe comparisons
- Mention that normal equality comparisons with null in PySpark behave the same as SQL, null equals anything is always false. Show the null safe equality operator or IS NULL filter for detecting nulls.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import ( StructType, StructField, StringType, IntegerType, DateType ) spark = SparkSession.builder.appName("NullHandling").getOrCreate() data = [ ("T01", "C01", "Laptop", 75000, "2024-01-05", "North"), ("T02", "C02", None, 25000, "2024-01-06", "South"), ("T03", None, "Tablet", None, "2024-01-07", "North"), ("T04", "C03", "Monitor", 15000, None, None), ("T05", "C04", "Keyboard", 5000, "2024-01-09", "South"), ("T06", "C05", "Phone", None, "2024-01-10", None), ] df = spark.createDataFrame( data, ["txn_id", "customer_id", "product", "amount", "txn_date", "region"] ) print("Raw DataFrame:") df.show() # ------------------------------------------------------- # STEP 1: Quantify nulls across all columns # ------------------------------------------------------- print("Null counts per column:") null_counts = df.select([ F.count( F.when(F.col(c).isNull(), c) ).alias(c) for c in df.columns ]) null_counts.show() # Null count as percentage of total rows total_rows = df.count() print(f"Total rows: {total_rows}") null_pct = df.select([ F.round( F.count(F.when(F.col(c).isNull(), c)) * 100.0 / total_rows, 2 ).alias(c + "_null_pct") for c in df.columns ]) null_pct.show() # ------------------------------------------------------- # STEP 2: dropna strategies # ------------------------------------------------------- # Drop rows where ANY column is null df_drop_any = df.dropna(how="any") print("After dropping rows with ANY null:") df_drop_any.show() # Drop rows where ALL columns are null df_drop_all = df.dropna(how="all") print("After dropping rows where ALL columns are null:") df_drop_all.show() # Drop rows where specific critical columns are null # customer_id is critical, a transaction with no customer is meaningless df_drop_critical = df.dropna(subset=["customer_id"]) print("After dropping rows where customer_id is null:") df_drop_critical.show() # ------------------------------------------------------- # STEP 3: fillna strategies # ------------------------------------------------------- # Fill nulls with fixed values per column df_filled = df.fillna({ "product": "Unknown", "region": "International", "amount": 0 }) print("After fillna with fixed values:") df_filled.show() # Fill null amount with the mean amount mean_amount = df.select(F.mean("amount")).collect()[0][0] print(f"Mean amount: {mean_amount}") df_filled_mean = df.withColumn( "amount", F.when( F.col("amount").isNull(), F.lit(round(mean_amount, 2)) ).otherwise(F.col("amount")) ) print("After filling null amount with mean:") df_filled_mean.show() # ------------------------------------------------------- # STEP 4: Null safe operations # ------------------------------------------------------- # Normal equality fails with nulls # col("region") == "North" returns false for null rows, not true or false df_north = df.filter(F.col("region") == "North") print("Filter region == North (null rows excluded silently):") df_north.show() # Explicit null check df_null_region = df.filter(F.col("region").isNull()) print("Rows where region is null:") df_null_region.show() # Null safe equality using eqNullSafe df_null_safe = df.filter(F.col("region").eqNullSafe("North")) print("Null safe equality for North:") df_null_safe.show() # ------------------------------------------------------- # STEP 5: Replace nulls with a window derived value # ------------------------------------------------------- from pyspark.sql.window import Window # Add region to the data to make this meaningful # Fill null amount with the average amount of the same region w_region = Window.partitionBy("region") df_with_region_avg = df.withColumn( "region_avg_amount", F.avg("amount").over(w_region) ).withColumn( "amount_cleaned", F.when( F.col("amount").isNull(), F.round(F.col("region_avg_amount"), 2) ).otherwise(F.col("amount")) ).drop("region_avg_amount") print("Amount filled with region average:") df_with_region_avg.show()
What to say: I always start with the null count diagnostic before touching any data because the right strategy depends entirely on how many nulls there are and in which columns. Dropping rows is destructive and should only be done for columns where a null makes the entire row invalid, like a transaction with no customer_id. Filling with a fixed value like zero or Unknown is appropriate for optional fields where a default is meaningful. Filling with a computed value like the column mean or a group average is appropriate for numerical fields where you want to preserve the statistical distribution of the data rather than distorting it with zeros.
Expected Output — Null Counts
| txn_id | customer_id | product | amount | txn_date | region |
|---|---|---|---|---|---|
| 0 | 1 | 1 | 2 | 1 | 2 |
Follow-up Questions & Answers
Q1. What is the difference between dropna and filter with isNull? When would you use each?
dropna is a high level convenience method specifically designed for dropping rows based on null conditions across one or more columns. filter with isNull is a general purpose row filter that gives more precise control.
# dropna: drop rows where amount OR region is null df.dropna(subset=["amount", "region"]) # Equivalent using filter but more explicit df.filter(F.col("amount").isNotNull() & F.col("region").isNotNull()) # dropna cannot express this: keep rows where amount is null # but only if region is also null # for this you need filter df.filter( F.col("amount").isNotNull() | (F.col("amount").isNull() & F.col("region").isNotNull()) )
What to say: dropna is faster to write for common patterns like drop any row missing a critical column. filter with isNull and isNotNull gives you full boolean logic for complex multi column null conditions that dropna cannot express. In production pipelines I tend to use filter for null handling because the explicit boolean logic makes the intent obvious to anyone reading the code later, whereas dropna hides the column list inside a method argument that is easy to miss during a code review.
Q2. What happens to null values in a groupBy aggregation like sum or count?
This is one of the most important null behaviours to understand in Spark because it matches SQL behaviour and can silently produce wrong results if you are not aware of it.
# SUM ignores nulls, so null amounts are simply skipped df.groupBy("region").agg(F.sum("amount").alias("total")) # A region with 3 transactions where 2 have null amounts # will show only the sum of the 1 non-null amount # COUNT star counts all rows including those with nulls df.groupBy("region").agg(F.count("*").alias("row_count")) # COUNT on a specific column counts only non-null values df.groupBy("region").agg(F.count("amount").alias("non_null_count")) # To explicitly include nulls in count df.groupBy("region").agg( F.count("*").alias("total_rows"), F.count("amount").alias("non_null_amounts"), (F.count("*") - F.count("amount")).alias("null_amounts") )
What to say: SUM, AVG, MIN, and MAX all silently skip null values, which means the result is computed only over the non null rows. This is usually the correct behaviour but it can be misleading if a large fraction of values are null, since the average of ten values where three are null is computed over only seven rows, not ten, and the denominator is silently different from what you might expect. I always check null counts before running aggregations on numerical columns to understand how representative the result actually is.
Q3. How does coalesce work in PySpark and how is it different from fillna?
Both replace nulls but they operate differently and at different levels of flexibility.
# coalesce takes multiple column expressions and returns the first non-null one # useful when you want to fall back through a priority list of columns df.withColumn( "effective_region", F.coalesce( F.col("region"), F.col("customer_region"), # fall back to another column F.lit("Unknown") # final default if all are null ) ) # fillna replaces nulls with a fixed scalar value per column # cannot reference other columns as the replacement value df.fillna({"region": "Unknown", "amount": 0}) # coalesce is more powerful when the replacement value is dynamic # for example fill null amount with the previous row's amount from pyspark.sql.window import Window w = Window.partitionBy("customer_id").orderBy("txn_date") df.withColumn( "amount_filled", F.coalesce( F.col("amount"), F.lag("amount", 1).over(w) # use previous row's amount if current is null ) )
What to say: fillna is convenient for filling nulls with simple fixed scalar values across one or more columns in one call. coalesce is more powerful because the replacement value can itself be a column expression, another column's value, a window function result, or any computed expression. I reach for fillna for quick fixed value fills and coalesce whenever the replacement logic is conditional or references another column.
Q4. How would you write a reusable null audit function that works on any DataFrame regardless of its schema?
This is a very practical production skill since null auditing should run on every DataFrame entering a pipeline.
def null_audit(df, spark): total = df.count() audit_rows = [] for col_name in df.columns: null_count = df.filter(F.col(col_name).isNull()).count() null_pct = round(null_count * 100.0 / total, 2) if total > 0 else 0.0 dtype = dict(df.dtypes)[col_name] audit_rows.append((col_name, dtype, null_count, null_pct)) audit_df = spark.createDataFrame( audit_rows, ["column_name", "data_type", "null_count", "null_pct"] ) return audit_df.orderBy(F.col("null_count").desc()) audit_result = null_audit(df, spark) audit_result.show()
What to say: I use df.dtypes which returns a list of column name and type tuples to make the function fully schema agnostic. It works on any DataFrame without needing to know the column names in advance. In a real pipeline I would call this function at the entry point of every transformation stage and write the results to a data quality log table with a timestamp and the source table name, so there is a historical record of null rates over time that can be tracked and alerted on.
Q5. What is the difference between null and NaN in PySpark, and why does it matter?
This is a subtle but important distinction that catches many people off guard.
import math from pyspark.sql.types import DoubleType # NaN (Not a Number) is a special floating point value # it is NOT the same as null # NaN appears when you do things like 0.0 / 0.0 in floating point arithmetic data_with_nan = [ ("T01", 5000.0), ("T02", float("nan")), # NaN ("T03", None), # null ] df_nan = spark.createDataFrame(data_with_nan, ["txn_id", "amount"]) # isNull does NOT catch NaN df_nan.filter(F.col("amount").isNull()).show() # Returns only T03 # isnan catches NaN but NOT null df_nan.filter(F.isnan("amount")).show() # Returns only T02 # To catch both null and NaN together df_nan.filter( F.col("amount").isNull() | F.isnan("amount") ).show() # Returns both T02 and T03 # Replace both null and NaN with zero df_cleaned = df_nan.withColumn( "amount", F.when( F.col("amount").isNull() | F.isnan("amount"), F.lit(0.0) ).otherwise(F.col("amount")) ) df_cleaned.show()
What to say: NaN is a IEEE 754 floating point concept meaning the result of an undefined arithmetic operation, like dividing zero by zero. Null means the value is absent or unknown. In PySpark these are two completely separate things that require different detection methods. isNull only catches null and isnan only catches NaN. If you fill nulls with fillna but have NaN values in the same column, the NaN values pass through untouched and can silently corrupt downstream aggregations since most Spark aggregation functions propagate NaN, meaning a single NaN in a sum produces NaN as the total. I always check for both null and NaN in numerical columns when doing data quality validation.
Reading and Writing Different File Formats
The Interviewer Says
In a real data engineering pipeline you will rarely work with just CSV files. You need to know how to read and write different file formats efficiently, understand the trade-offs between them, and know when to use which format. Walk me through how you would read and write CSV, JSON, Parquet, and Delta formats in PySpark, and explain when you would choose each one.
How to Handle This in the Interview
Before writing anything, say: File format choice is one of the most impactful decisions in a data pipeline because it directly affects read performance, storage cost, schema evolution capability, and whether downstream consumers can query the data efficiently. Let me walk through each format and its real world use case.
Step 1: Know the core trade-offs
- CSV is human readable and universally supported but has no schema, no compression by default, and no support for nested data. JSON supports nested structures but is verbose and slow to parse at scale. Parquet is columnar, compressed, schema aware, and ideal for analytical workloads. Delta adds ACID transactions, time travel, and schema enforcement on top of Parquet.
Step 2: Know which format to recommend in an interview
- Say: For any serious data engineering workload I would always recommend Parquet as the minimum and Delta Lake if the platform supports it. CSV and JSON are acceptable only at the ingestion boundary where you have no control over what the upstream system sends.
Step 3: Understand partition pruning and predicate pushdown
- Writing data partitioned by a column like date or region allows Spark to skip entire folders when reading with a filter on that column, which dramatically reduces the amount of data scanned. This is called partition pruning and it only works with columnar formats like Parquet and Delta.
Step 4: Mention schema evolution
- Ask: Does the schema of this data change over time? If yes, I would lean toward Delta which handles schema evolution cleanly with mergeSchema and enforceSchema options.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import ( StructType, StructField, StringType, IntegerType, DateType ) spark = SparkSession.builder.appName("FileFormats").getOrCreate() data = [ ("T01", "C01", "Laptop", 75000, "2024-01-05", "North"), ("T02", "C02", "Phone", 25000, "2024-01-06", "South"), ("T03", "C03", "Tablet", 30000, "2024-01-07", "North"), ("T04", "C04", "Monitor", 15000, "2024-01-08", "South"), ("T05", "C05", "Keyboard", 5000, "2024-01-09", "North"), ] df = spark.createDataFrame( data, ["txn_id", "customer_id", "product", "amount", "txn_date", "region"] ) # ------------------------------------------------------- # CSV: Read and Write # ------------------------------------------------------- # Write CSV df.write \ .option("header", "true") \ .mode("overwrite") \ .csv("/output/transactions_csv") # Read CSV with explicit schema schema = StructType([ StructField("txn_id", StringType(), True), StructField("customer_id", StringType(), True), StructField("product", StringType(), True), StructField("amount", IntegerType(), True), StructField("txn_date", StringType(), True), StructField("region", StringType(), True) ]) df_csv = spark.read \ .option("header", "true") \ .option("mode", "DROPMALFORMED") \ .schema(schema) \ .csv("/output/transactions_csv") print("CSV Read:") df_csv.show() # ------------------------------------------------------- # JSON: Read and Write # ------------------------------------------------------- # Write JSON df.write \ .mode("overwrite") \ .json("/output/transactions_json") # Read JSON with schema inference is acceptable since JSON carries type hints df_json = spark.read \ .option("mode", "PERMISSIVE") \ .schema(schema) \ .json("/output/transactions_json") print("JSON Read:") df_json.show() # ------------------------------------------------------- # Parquet: Read and Write # ------------------------------------------------------- # Write Parquet, partitioned by region df.write \ .mode("overwrite") \ .partitionBy("region") \ .parquet("/output/transactions_parquet") # Read all Parquet data df_parquet = spark.read.parquet("/output/transactions_parquet") print("Parquet Read (all):") df_parquet.show() # Read with partition pruning, only North region is scanned df_parquet_north = spark.read \ .parquet("/output/transactions_parquet") \ .filter(F.col("region") == "North") print("Parquet Read (North only, partition pruning):") df_parquet_north.show() # ------------------------------------------------------- # Delta Lake: Read and Write # ------------------------------------------------------- # Write Delta df.write \ .format("delta") \ .mode("overwrite") \ .partitionBy("region") \ .save("/output/transactions_delta") # Read Delta df_delta = spark.read \ .format("delta") \ .load("/output/transactions_delta") print("Delta Read:") df_delta.show() # Delta Time Travel: read a previous version df_delta_v0 = spark.read \ .format("delta") \ .option("versionAsOf", 0) \ .load("/output/transactions_delta") print("Delta Time Travel (version 0):") df_delta_v0.show() # Delta Time Travel: read as of a specific timestamp df_delta_ts = spark.read \ .format("delta") \ .option("timestampAsOf", "2024-01-01") \ .load("/output/transactions_delta") print("Delta Time Travel (as of 2024-01-01):") df_delta_ts.show() # ------------------------------------------------------- # Write Modes # ------------------------------------------------------- # overwrite: replaces all existing data df.write.mode("overwrite").parquet("/output/transactions_parquet") # append: adds new data to existing files df.write.mode("append").parquet("/output/transactions_parquet") # ignore: does nothing if data already exists df.write.mode("ignore").parquet("/output/transactions_parquet") # errorIfExists (default): throws error if data already exists df.write.mode("error").parquet("/output/transactions_parquet") # ------------------------------------------------------- # Control output file count # ------------------------------------------------------- # Repartition to control number of output files # 1 partition = 1 output file, useful for small datasets df.repartition(1).write \ .mode("overwrite") \ .parquet("/output/transactions_single_file") # coalesce reduces partitions without a full shuffle # more efficient than repartition when reducing partition count df.coalesce(2).write \ .mode("overwrite") \ .parquet("/output/transactions_two_files")
What to say: I always write production data in Parquet or Delta rather than CSV or JSON. Parquet is columnar which means when a query only needs three columns from a fifty column table, Spark reads only those three columns from disk rather than reading all fifty and discarding forty seven, which can reduce IO by ninety percent or more. Partitioning by a column like region or date on write allows partition pruning on read, where Spark skips entire directories of files based on the filter condition without even opening them.
Follow-up Questions & Answers
Q1. What is the difference between Parquet and Delta Lake? Why would you choose Delta over plain Parquet?
Parquet is a file format, a way of storing data on disk in a columnar compressed layout. It has no concept of transactions, versioning, or updates. Delta Lake is a storage layer built on top of Parquet files that adds a transaction log, which is a folder called _delta_log containing JSON files that record every operation performed on the table. This transaction log is what gives Delta its most important capabilities. ACID transactions mean multiple writers can write to the same table concurrently without corrupting each other's data. Time travel means you can query any previous version of the table by version number or timestamp. Schema enforcement means Delta rejects writes that do not match the table schema rather than silently accepting wrong data. Upserts using merge allow you to update existing rows and insert new ones in a single atomic operation, which plain Parquet cannot do at all. I choose Delta whenever the pipeline involves concurrent writes, needs audit history, requires upserts, or runs in an environment that supports it like Databricks, Azure Synapse, or open source Delta on any Hadoop compatible storage.
Q2. What is the difference between repartition and coalesce and when would you use each?
Both repartition and coalesce change the number of partitions in a DataFrame but they do so very differently.
# repartition(n): performs a full shuffle to redistribute data # into exactly n partitions, evenly distributed # use when increasing partitions or when you need even distribution df_repartitioned = df.repartition(10) # repartition by column: shuffles data so all rows with the # same column value go to the same partition # useful before a join or groupBy on that column df_by_region = df.repartition(10, "region") # coalesce(n): merges existing partitions without a full shuffle # can only reduce partitions, cannot increase them # more efficient than repartition when reducing df_coalesced = df.coalesce(2)
What to say: repartition performs a full shuffle across the network which is expensive but produces perfectly balanced partitions and can both increase and decrease the partition count. coalesce avoids the full shuffle by merging existing partitions on the same executor where possible, making it much cheaper when you just want to reduce partitions, for example before writing a small result to a single output file. The rule I follow is use coalesce when reducing partition count, use repartition when increasing or when you need even distribution by a specific key.
Q3. What is partition pruning and how does partitioning on write enable it?
When you write a Parquet or Delta table partitioned by a column like region, Spark creates a separate subdirectory for each distinct value of that column. The directory structure looks like region equals North, region equals South, and so on. When a subsequent query filters on that column, Spark inspects the directory names rather than opening any files and immediately knows which subdirectories to skip entirely. This is partition pruning, the ability to eliminate entire partitions from consideration before reading a single byte of actual data.
# Write partitioned by region creates this folder structure: # /output/transactions/region=North/part-00000.parquet # /output/transactions/region=South/part-00000.parquet df.write.partitionBy("region").parquet("/output/transactions") # This query only reads the North folder, South is never touched spark.read.parquet("/output/transactions") \ .filter(F.col("region") == "North") \ .show() # Check if Spark is actually pruning by looking at the execution plan spark.read.parquet("/output/transactions") \ .filter(F.col("region") == "North") \ .explain() # Look for PartitionFilters in the plan output
What to say: Partition pruning is one of the most impactful performance optimisations available at zero code cost. Choosing the right partition column is critical though. You want a column with low to medium cardinality, like date, region, or department, not a high cardinality column like customer_id or transaction_id, since partitioning by a column with millions of distinct values creates millions of tiny directories which actually harms performance rather than helping it.
Q4. What are the different write modes in Spark and when would each be appropriate in a production pipeline?
# overwrite: deletes all existing data and writes fresh # appropriate for a full reload pipeline where source is always complete df.write.mode("overwrite").parquet("/output/path") # append: adds new data alongside existing data without touching it # appropriate for incremental pipelines loading new partitions df.write.mode("append").parquet("/output/path") # ignore: skips the write entirely if data already exists # appropriate for idempotent pipelines where re-running should be safe df.write.mode("ignore").parquet("/output/path") # error (default): throws an exception if any data already exists # appropriate as a safety guard during development df.write.mode("error").parquet("/output/path")
What to say: In production pipelines I almost always use overwrite for full reloads and append for incremental loads. The danger with append is that re-running a failed job without cleaning up the partial output from the previous run leads to duplicate data, which is why Delta Lake's merge operation is so valuable in upsert scenarios since it handles the re-run case atomically. For pure append pipelines I make sure the pipeline is truly idempotent, meaning re-running produces the same result as running once, either by deduplicating after append or by using Delta's merge to handle duplicates.
Q5. How would you read only the latest partition from a date partitioned Parquet table without reading older data?
A very common production pattern where you only need today's data rather than the full history.
from datetime import date # Get today's date as a string today = str(date.today()) print(f"Reading partition for: {today}") # Option 1: Read specific partition directory directly df_today = spark.read.parquet(f"/output/transactions/txn_date={today}") # Option 2: Read full table and filter, letting partition pruning handle it df_today = spark.read \ .parquet("/output/transactions") \ .filter(F.col("txn_date") == today) # Option 3: Find the latest available partition dynamically import subprocess # List partition directories and find the max date partitions = spark.read.parquet("/output/transactions") \ .select("txn_date") \ .distinct() \ .orderBy(F.col("txn_date").desc()) latest_date = partitions.first()["txn_date"] print(f"Latest available partition: {latest_date}") df_latest = spark.read \ .parquet("/output/transactions") \ .filter(F.col("txn_date") == latest_date) df_latest.show()
What to say: Option 1 is the most efficient since it reads exactly one partition directory with no scanning overhead at all. Option 2 is cleaner code that relies on Spark's partition pruning to achieve the same result. Option 3 is the most robust for a production pipeline since it dynamically discovers the latest available partition rather than hardcoding today's date, which protects against missing partitions if the upstream pipeline failed to load data for a particular day. I would use Option 3 in any pipeline that needs to gracefully handle upstream data delivery delays.
UDFs and Pandas UDFs
The Interviewer Says
Sometimes the built in PySpark functions are not enough and you need to apply custom Python logic to a DataFrame column. Walk me through what a UDF is, how to create one, what the performance implications are, and how a Pandas UDF improves on a regular UDF. I want to see you write both types.
How to Handle This in the Interview
Before writing anything, say: UDF stands for User Defined Function. It lets me apply arbitrary Python logic to a Spark DataFrame column. However regular UDFs come with a significant performance cost that I always flag in an interview before writing one, and I would explain why Pandas UDFs are almost always the better choice.
Step 1: Explain why regular UDFs are expensive
- Say: A regular Python UDF breaks Spark's internal Java and Scala execution engine. For each row, Spark has to serialise the data from JVM memory into Python objects, pass them to the Python process, run the function, serialise the result back to JVM memory, and continue. This serialisation round trip happens for every single row and is the main performance bottleneck.
Step 2: Explain how Pandas UDFs solve this
- Pandas UDFs use Apache Arrow for serialisation which is a columnar in-memory format that transfers data between JVM and Python in batches rather than row by row. This eliminates the per-row serialisation overhead and makes Pandas UDFs typically ten to one hundred times faster than regular UDFs.
Step 3: Know when to still use a regular UDF
- A regular UDF is acceptable when the DataFrame is small, when the logic cannot be expressed as a Pandas operation, or when the team is not familiar with Pandas and simplicity matters more than performance.
Step 4: Always try built-in functions first
- Say: Before writing any UDF I always check whether a built-in PySpark function can do the job, since built-in functions run entirely in the JVM with no Python serialisation overhead at all. UDFs should be the last resort, not the first instinct.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import StringType, IntegerType, DoubleType import pandas as pd spark = SparkSession.builder.appName("UDFDemo").getOrCreate() data = [ ("T01", "C01", "laptop computer", 75000), ("T02", "C02", "mobile phone", 25000), ("T03", "C03", "tablet device", 30000), ("T04", "C04", "computer monitor", 15000), ("T05", "C05", "wireless keyboard", 5000), ] df = spark.createDataFrame( data, ["txn_id", "customer_id", "product", "amount"] ) # ------------------------------------------------------- # PART 1: Regular Python UDF # ------------------------------------------------------- # Define a Python function def capitalise_words(text): if text is None: return None return " ".join(word.capitalize() for word in text.split()) # Register as a UDF with return type capitalise_udf = F.udf(capitalise_words, StringType()) # Apply UDF to a column df_with_clean_product = df.withColumn( "product_clean", capitalise_udf(F.col("product")) ) print("After applying regular UDF:") df_with_clean_product.show(truncate=False) # Register UDF for use in Spark SQL as well spark.udf.register("capitalise_words_sql", capitalise_words, StringType()) df.createOrReplaceTempView("transactions") spark.sql("SELECT txn_id, capitalise_words_sql(product) FROM transactions").show() # ------------------------------------------------------- # PART 2: UDF using decorator syntax # ------------------------------------------------------- @F.udf(returnType=StringType()) def categorise_amount(amount): if amount is None: return "Unknown" if amount >= 50000: return "High Value" elif amount >= 20000: return "Mid Value" else: return "Low Value" df_with_category = df.withColumn( "amount_category", categorise_amount(F.col("amount")) ) print("After applying decorator UDF:") df_with_category.show() # ------------------------------------------------------- # PART 3: Pandas UDF (Series to Series) # ------------------------------------------------------- from pyspark.sql.functions import pandas_udf # Pandas UDF processes an entire column batch as a Pandas Series # Much faster than regular UDF due to Apache Arrow serialisation @pandas_udf(StringType()) def capitalise_words_pandas(series: pd.Series) -> pd.Series: return series.str.title() df_with_pandas_udf = df.withColumn( "product_clean_pandas", capitalise_words_pandas(F.col("product")) ) print("After applying Pandas UDF:") df_with_pandas_udf.show(truncate=False) # ------------------------------------------------------- # PART 4: Pandas UDF for numerical transformation # ------------------------------------------------------- @pandas_udf(DoubleType()) def apply_tax(amount: pd.Series) -> pd.Series: return amount * 1.18 # Apply 18% GST df_with_tax = df.withColumn( "amount_with_tax", apply_tax(F.col("amount").cast(DoubleType())) ) print("After applying tax Pandas UDF:") df_with_tax.show() # ------------------------------------------------------- # PART 5: Pandas UDF GroupedMap (applyInPandas) # ------------------------------------------------------- # applyInPandas applies a Pandas function to each group # useful for complex group-wise operations not possible with groupBy agg def normalise_amount(pdf: pd.DataFrame) -> pd.DataFrame: pdf["normalised_amount"] = ( (pdf["amount"] - pdf["amount"].min()) / (pdf["amount"].max() - pdf["amount"].min()) ).round(4) return pdf from pyspark.sql.types import StructType, StructField result_schema = StructType( df.schema.fields + [ StructField("normalised_amount", DoubleType(), True) ] ) df_normalised = df.groupBy("customer_id").applyInPandas( normalise_amount, schema=result_schema ) print("After applyInPandas normalisation:") df_normalised.show()
What to say: I always try to solve the problem with built-in PySpark functions first since they run entirely in the JVM with no Python overhead. If I genuinely need custom Python logic I reach for a Pandas UDF over a regular UDF because Pandas UDFs use Apache Arrow to batch the data transfer between JVM and Python, eliminating the row-by-row serialisation cost of regular UDFs. The applyInPandas pattern is useful for complex group-wise operations like normalisation or custom aggregations that cannot be expressed with the standard groupBy agg functions.
Follow-up Questions & Answers
Q1. What are the main performance problems with regular Python UDFs in PySpark?
A regular Python UDF breaks out of Spark's native JVM execution engine for every single row. Spark serialises each row's data from JVM internal format into Python pickle format, sends it across a socket to a Python subprocess, runs the function, serialises the result back from Python pickle format to JVM format, and then continues with the next row. This serialisation and inter-process communication overhead happens once per row. On a DataFrame with one hundred million rows that is one hundred million serialise-call-deserialise cycles. Additionally regular UDFs are opaque to Spark's Catalyst optimiser, meaning the optimiser cannot look inside the function to apply any optimisations, which means predicate pushdown and other plan optimisations stop working at the UDF boundary.
Q2. How does Apache Arrow make Pandas UDFs faster?
Apache Arrow is an in-memory columnar data format that both the JVM and Python can read directly without any conversion. When Spark passes data to a Pandas UDF it serialises an entire batch of rows into Arrow columnar format once, passes the Arrow buffer to Python which reads it directly as a Pandas DataFrame or Series without any further conversion, runs the Pandas function on the whole batch, then passes the result Arrow buffer back to JVM. The key difference from a regular UDF is that the serialisation and transfer happens once per batch of thousands of rows rather than once per individual row. Batch sizes are configurable through spark.sql.execution.arrow.maxRecordsPerBatch which defaults to ten thousand rows per batch.
# Configure Arrow batch size spark.conf.set("spark.sql.execution.arrow.maxRecordsPerBatch", "50000") # Enable Arrow for Pandas UDFs (usually enabled by default in modern Spark) spark.conf.set("spark.sql.execution.arrow.pyspark.enabled", "true")
Q3. When would you use applyInPandas versus a regular groupBy agg?
groupBy agg is the right choice when the aggregation you need is expressible as a combination of standard functions like sum, avg, count, min, max, collect_list, and so on. applyInPandas is the right choice when the group-level computation requires custom logic that cannot be expressed with standard aggregation functions.
# applyInPandas use cases that groupBy cannot handle # 1. Fitting a model per group and returning predictions def fit_and_predict(pdf): from sklearn.linear_model import LinearRegression model = LinearRegression() model.fit(pdf[["feature"]], pdf["target"]) pdf["prediction"] = model.predict(pdf[["feature"]]) return pdf # 2. Computing rolling statistics with a custom window logic def custom_rolling(pdf): pdf = pdf.sort_values("date") pdf["custom_7day_avg"] = pdf["amount"].rolling(7, min_periods=1).mean() return pdf # 3. Percentile normalisation within each group def percentile_rank(pdf): pdf["pct_rank"] = pdf["amount"].rank(pct=True) return pdf
What to say: The practical rule is that if I find myself writing a Python function that takes a Pandas DataFrame as input and returns a Pandas DataFrame as output with group-level logic that is more than two or three lines, applyInPandas is probably the right tool. If the logic is a simple element-wise transformation on a single column, a Pandas UDF Series to Series is better. If it can be expressed with built-in functions, avoid UDFs entirely.
Q4. How would you test a UDF before deploying it in a production pipeline?
Testing UDFs in isolation before applying them to a full Spark DataFrame is important because UDF errors surface at execution time and can be hard to debug once they are running across hundreds of partitions.
# Test the underlying Python function directly first # without involving Spark at all def capitalise_words(text): if text is None: return None return " ".join(word.capitalize() for word in text.split()) assert capitalise_words("hello world") == "Hello World" assert capitalise_words(None) is None assert capitalise_words("") == "" assert capitalise_words("UPPER CASE") == "Upper Case" print("All unit tests passed") # Then test on a small sample DataFrame before running on full data sample_df = spark.createDataFrame( [("hello world",), (None,), ("",), ("UPPER CASE",)], ["product"] ) capitalise_udf = F.udf(capitalise_words, StringType()) sample_df.withColumn("cleaned", capitalise_udf(F.col("product"))).show()
What to say: I always test the raw Python function with unit tests before registering it as a UDF, since a pure Python function is much easier to test and debug than a UDF running inside Spark. Once the Python function passes all edge cases including None inputs, empty strings, and unexpected data types, I register it as a UDF and test on a small sample DataFrame before applying it to the full production dataset. This catches any type mismatch or serialisation issues that only appear when the function runs inside Spark's execution engine.
Q5. Can you register a UDF and use it in Spark SQL? How?
Yes, UDFs can be registered in the Spark session's function registry and then called by name in any Spark SQL query, which is useful when the team uses SQL heavily alongside Python code.
# Register with a SQL-friendly name spark.udf.register("capitalise", capitalise_words, StringType()) spark.udf.register("categorise_amount", lambda x: "High" if x and x >= 50000 else "Low", StringType()) # Create a temp view to query df.createOrReplaceTempView("transactions") # Use registered UDFs in Spark SQL result = spark.sql(""" SELECT txn_id, capitalise(product) AS product_clean, categorise_amount(amount) AS amount_category, amount FROM transactions WHERE amount > 10000 """) result.show() # Pandas UDFs can also be registered for SQL use @pandas_udf(StringType()) def title_case(s: pd.Series) -> pd.Series: return s.str.title() spark.udf.register("title_case", title_case) spark.sql("SELECT title_case(product) FROM transactions").show()
What to say: Registering UDFs for SQL use is particularly valuable in organisations where data analysts write Spark SQL but the custom logic was written by a data engineer in Python. The engineer registers the UDF once at session startup and analysts can call it by name in any SQL query without needing to know it is a Python function underneath. In a Databricks or Hive metastore environment you can also persist UDF registrations so they survive across sessions, though for production pipelines I prefer re-registering them explicitly in the job initialisation code to avoid depending on state that may or may not exist in the metastore.
Working with Nested and Complex Data Types
The Interviewer Says
Real world data from APIs, Kafka streams, and JSON sources almost always contains nested structures like arrays of objects or key-value maps embedded within rows. Walk me through how you handle complex data types in PySpark, specifically arrays, structs, and maps. Show me how to read nested data, access fields within it, flatten it, and explode arrays into separate rows.
How to Handle This in the Interview
Before writing anything, say: Complex data types are one of the places where PySpark really shines compared to traditional SQL databases, since it has native support for arrays, structs, and maps as first class column types. The main operations I use are dot notation for struct field access, getItem for map and array element access, explode to turn array rows into individual rows, and from_json and to_json for working with JSON strings embedded in columns.
Step 1: Know the three complex types
- StructType is a nested row within a row, accessed using dot notation. ArrayType is an ordered list of values, accessed using getItem or explode. MapType is a key value dictionary, accessed using getItem with the key name.
Step 2: Know when to explode versus when to access elements directly
- Say: explode is destructive in the sense that it multiplies rows, one row per array element. If a customer has five orders in an array, explode produces five rows for that customer. Use explode when you need to operate on individual elements. Use getItem or element_at when you just need a specific position.
Step 3: Know from_json for JSON strings
- In many real pipelines, nested data arrives as a JSON string in a VARCHAR column rather than as a native struct. from_json with an explicit schema converts those strings into proper struct columns that can be accessed with dot notation.
Step 4: Know to_json for the reverse
- to_json converts a struct or array column back into a JSON string, useful when writing to a target system that expects JSON format.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import ( StructType, StructField, StringType, IntegerType, ArrayType, MapType, DoubleType ) spark = SparkSession.builder.appName("ComplexDataTypes").getOrCreate() # ------------------------------------------------------- # PART 1: StructType (nested row within a row) # ------------------------------------------------------- struct_data = [ ("C01", ("Karan", "Mumbai", "India")), ("C02", ("Asha", "Delhi", "India")), ("C03", ("Ravi", "London", "UK")), ] struct_schema = StructType([ StructField("customer_id", StringType(), True), StructField("address", StructType([ StructField("name", StringType(), True), StructField("city", StringType(), True), StructField("country", StringType(), True), ]), True) ]) df_struct = spark.createDataFrame(struct_data, struct_schema) print("Struct DataFrame:") df_struct.show(truncate=False) df_struct.printSchema() # Access struct fields using dot notation df_struct_flat = df_struct.select( F.col("customer_id"), F.col("address.name").alias("name"), F.col("address.city").alias("city"), F.col("address.country").alias("country") ) print("After flattening struct:") df_struct_flat.show() # ------------------------------------------------------- # PART 2: ArrayType (list of values) # ------------------------------------------------------- array_data = [ ("C01", "Karan", [5000, 7000, 3000]), ("C02", "Asha", [8000, 2000]), ("C03", "Ravi", [9000, 6000, 4000, 1000]), ] array_schema = StructType([ StructField("customer_id", StringType(), True), StructField("name", StringType(), True), StructField("purchases", ArrayType(IntegerType()), True) ]) df_array = spark.createDataFrame(array_data, array_schema) print("Array DataFrame:") df_array.show(truncate=False) # Access specific element by index df_array_access = df_array.withColumn( "first_purchase", F.col("purchases").getItem(0) ).withColumn( "last_purchase", F.element_at(F.col("purchases"), -1) ).withColumn( "purchase_count", F.size(F.col("purchases")) ).withColumn( "total_purchase", F.aggregate(F.col("purchases"), F.lit(0), lambda acc, x: acc + x) ) print("After array operations:") df_array_access.show(truncate=False) # explode: one row per array element df_exploded = df_array.select( "customer_id", "name", F.explode("purchases").alias("purchase_amount") ) print("After explode:") df_exploded.show() # posexplode: explode with position index df_posexploded = df_array.select( "customer_id", "name", F.posexplode("purchases").alias("position", "purchase_amount") ) print("After posexplode:") df_posexploded.show() # explode_outer: keeps rows with empty or null arrays df_explode_outer = df_array.select( "customer_id", F.explode_outer("purchases").alias("purchase_amount") ) print("After explode_outer:") df_explode_outer.show() # ------------------------------------------------------- # PART 3: MapType (key-value dictionary) # ------------------------------------------------------- map_data = [ ("C01", {"Jan": 5000, "Feb": 7000, "Mar": 3000}), ("C02", {"Jan": 8000, "Feb": 2000}), ("C03", {"Feb": 9000, "Mar": 6000}), ] map_schema = StructType([ StructField("customer_id", StringType(), True), StructField("monthly_sales", MapType(StringType(), IntegerType()), True) ]) df_map = spark.createDataFrame(map_data, map_schema) print("Map DataFrame:") df_map.show(truncate=False) # Access specific key df_map_access = df_map.withColumn( "jan_sales", F.col("monthly_sales").getItem("Jan") ).withColumn( "map_keys", F.map_keys(F.col("monthly_sales")) ).withColumn( "map_values", F.map_values(F.col("monthly_sales")) ) print("After map access:") df_map_access.show(truncate=False) # Explode map into key-value rows df_map_exploded = df_map.select( "customer_id", F.explode("monthly_sales").alias("month", "sales") ) print("After exploding map:") df_map_exploded.show() # ------------------------------------------------------- # PART 4: from_json and to_json for JSON string columns # ------------------------------------------------------- json_data = [ ("T01", '{"product": "Laptop", "amount": 75000, "region": "North"}'), ("T02", '{"product": "Phone", "amount": 25000, "region": "South"}'), ("T03", '{"product": "Tablet", "amount": 30000, "region": "North"}'), ] df_json_str = spark.createDataFrame(json_data, ["txn_id", "payload"]) print("JSON string DataFrame:") df_json_str.show(truncate=False) # Define the schema of the JSON string payload_schema = StructType([ StructField("product", StringType(), True), StructField("amount", IntegerType(), True), StructField("region", StringType(), True) ]) # Parse the JSON string into a struct column df_parsed = df_json_str.withColumn( "payload_parsed", F.from_json(F.col("payload"), payload_schema) ) # Access fields from the parsed struct df_final = df_parsed.select( "txn_id", F.col("payload_parsed.product").alias("product"), F.col("payload_parsed.amount").alias("amount"), F.col("payload_parsed.region").alias("region") ) print("After parsing JSON string:") df_final.show() # Convert struct back to JSON string df_to_json = df_final.withColumn( "payload_json", F.to_json(F.struct("product", "amount", "region")) ) print("After converting back to JSON string:") df_to_json.show(truncate=False)
What to say: The most important thing to know about complex data types in PySpark is that they are first class citizens, not workarounds. Struct columns are accessed with dot notation just like class attributes in Python. Array columns have a full set of built-in functions like size, element_at, array_contains, and aggregate. The from_json and to_json functions are critical for Kafka and API based pipelines where data arrives as a raw JSON string in a single column and needs to be parsed into a proper schema before any field level processing can happen.
Follow-up Questions & Answers
Q1. What is the difference between explode and explode_outer?
explode converts each element of an array or each key-value pair of a map into a separate row. If the array is empty or null, the entire row is dropped from the result, which can silently lose customer records if some customers have no purchase history. explode_outer does the same thing but keeps rows with null or empty arrays, producing a single row with a null in the exploded column rather than dropping the row entirely.
data_with_empty = [ ("C01", [5000, 7000]), ("C02", []), # empty array ("C03", None), # null array ] df_empty = spark.createDataFrame(data_with_empty, ["customer_id", "purchases"]) # explode drops C02 and C03 entirely df_empty.select("customer_id", F.explode("purchases")).show() # Returns only C01 rows # explode_outer keeps C02 and C03 with null purchase df_empty.select("customer_id", F.explode_outer("purchases")).show() # Returns C01 rows plus C02 and C03 with null
What to say: I always use explode_outer in production pipelines unless I am certain that dropping empty array rows is intentional, because silently losing records from a pipeline is one of the hardest bugs to detect. A customer with no purchases is still a valid customer that should appear in many types of analysis, and explode would simply erase them from the result without any error or warning.
Q2. How would you flatten a deeply nested JSON structure that has arrays of structs within structs?
This is a very common real world problem when working with API responses or Kafka event payloads that have complex hierarchical structures.
# Example: an order with a list of line items, each with product details nested_data = [ ("O01", "C01", [ {"product": "Laptop", "qty": 1, "price": 75000}, {"product": "Mouse", "qty": 2, "price": 1500}, ]), ] nested_schema = StructType([ StructField("order_id", StringType(), True), StructField("customer_id", StringType(), True), StructField("items", ArrayType(StructType([ StructField("product", StringType(), True), StructField("qty", IntegerType(), True), StructField("price", IntegerType(), True), ])), True) ]) df_nested = spark.createDataFrame(nested_data, nested_schema) df_nested.printSchema() df_nested.show(truncate=False) # Step 1: Explode the items array into individual rows df_exploded = df_nested.select( "order_id", "customer_id", F.explode("items").alias("item") ) # Step 2: Flatten the struct fields from the exploded item df_flat = df_exploded.select( "order_id", "customer_id", F.col("item.product").alias("product"), F.col("item.qty").alias("qty"), F.col("item.price").alias("price"), (F.col("item.qty") * F.col("item.price")).alias("line_total") ) print("Fully flattened:") df_flat.show()
What to say: The pattern is always the same regardless of how deeply nested the structure is, explode the array first to bring each element into its own row as a struct column, then use dot notation to extract the individual fields from that struct column. For very deeply nested structures I sometimes write a recursive flattening function that automatically traverses the schema and generates the select expressions, rather than manually writing every dot notation path.
Q3. How would you collect individual rows back into an array after processing them?
The inverse of explode, collecting rows back into an array per group, uses collect_list or collect_set in a groupBy aggregation.
# After processing individual purchase rows, collect back per customer df_recollected = df_exploded_purchases.groupBy("customer_id", "name").agg( F.collect_list("purchase_amount").alias("purchases"), F.sort_array(F.collect_list("purchase_amount")).alias("sorted_purchases"), F.collect_set("purchase_amount").alias("unique_purchases") ) df_recollected.show(truncate=False) # Create an array column from multiple scalar columns df_as_array = df.select( "customer_id", F.array( F.col("amount_jan"), F.col("amount_feb"), F.col("amount_mar") ).alias("quarterly_amounts") )
What to say: collect_list is the groupBy equivalent of explode in reverse. The common pattern in real pipelines is to explode an array to process elements individually, apply transformations at the row level, then collect back into an array per group to restore the original shape. This explode-transform-collect pattern is one of the most frequently used patterns when working with nested event data from Kafka or API sources.
Q4. How would you handle a situation where a JSON payload column has inconsistent or unknown schema across different rows?
When different rows have different fields in the JSON, the from_json approach requires a fixed schema which may not cover all possible fields. The solution depends on whether you need all fields or just some.
# Option 1: Define a schema covering the known fields # Unknown fields are silently ignored, known fields may be null if absent known_schema = StructType([ StructField("product", StringType(), True), StructField("amount", IntegerType(), True), ]) df.withColumn("parsed", F.from_json(F.col("payload"), known_schema)) # Option 2: Use schema_of_json to infer schema from a sample row sample_json = df.select("payload").first()[0] inferred_schema = F.schema_of_json(sample_json) df.withColumn("parsed", F.from_json(F.col("payload"), inferred_schema)) # Option 3: Parse into a MapType for fully flexible key-value access # when the keys themselves vary unpredictably df.withColumn( "parsed_map", F.from_json(F.col("payload"), MapType(StringType(), StringType())) ).withColumn( "product", F.col("parsed_map").getItem("product") )
What to say: Option 3 using MapType is the most flexible approach for truly variable JSON since it treats every key-value pair as a dynamic entry rather than requiring a fixed schema. The trade off is that all values become strings since MapType requires a uniform value type, so any subsequent use of numeric fields requires an explicit cast. In practice I prefer defining an explicit schema covering the known required fields and simply accepting that optional fields not in the schema will not be accessible, since this gives type safety and Catalyst optimisation for the fields I do need.
Q5. What happens to the original row when you explode an array with multiple elements, and how do you keep track of which element came from which original row?
When you explode an array with three elements, the original row is duplicated three times with one array element per row. All non-array columns from the original row are copied identically to all three new rows. posexplode adds a position index alongside each element so you always know which position in the original array each row came from.
# posexplode gives you the original array index alongside the value df_with_position = df_array.select( "customer_id", "name", F.posexplode("purchases").alias("array_index", "purchase_amount") ) df_with_position.show() # After processing, you can use array_index to reconstruct the original order # or to filter for specific positions like the first purchase only df_first_only = df_with_position.filter(F.col("array_index") == 0) print("First purchase per customer:") df_first_only.show()
What to say: posexplode is particularly useful in pipelines where order within the original array is meaningful, for example if an array represents a sequence of events in time order and you need to process only the third event, or where you need to reassemble results back into the original array order after processing. Without position tracking you lose the ability to distinguish which element was which in the original array, which can make reconstruction impossible for ordered sequences.
Spark Optimisation and Performance Tuning
The Interviewer Says
This is a senior level question. In production a Spark job is running much slower than expected or failing with out of memory errors. Walk me through your systematic approach to diagnosing and fixing Spark performance problems. I want to hear about caching, partitioning, broadcast joins, skew handling, and any other techniques you would reach for.
How to Handle This in the Interview
Before writing anything, say: Performance tuning in Spark is about understanding where time and memory are actually being spent before trying any fix. The Spark UI is the most important tool I have for this, and I always look there first rather than guessing and trying random configuration changes.
Step 1: Check the Spark UI first
- Look at the SQL tab for the query plan to see which join strategy was chosen and whether any filters are being pushed down. Look at the Stages tab for stages with unusually high data shuffle read and write. Look at the Tasks tab within a stage for skew, meaning most tasks finish quickly but a few outliers take much longer.
Step 2: Know the four most common performance problems
- Too many small files causing overhead. Data skew causing straggler tasks. Missing broadcast join on a small table causing unnecessary shuffle. Recomputing the same DataFrame multiple times instead of caching it.
Step 3: Know the configuration levers
- spark.sql.shuffle.partitions, spark.sql.autoBroadcastJoinThreshold, spark.executor.memory, spark.executor.cores, spark.default.parallelism. Know what each one does and when changing it helps.
Step 4: Mention Adaptive Query Execution
- Say: In Spark 3.0 and above, Adaptive Query Execution is enabled by default and it automatically handles some of these problems at runtime, like coalescing shuffle partitions and switching join strategies based on actual data sizes rather than estimates.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.window import Window spark = SparkSession.builder \ .appName("PerformanceTuning") \ .config("spark.sql.adaptive.enabled", "true") \ .config("spark.sql.adaptive.coalescePartitions.enabled", "true") \ .config("spark.sql.shuffle.partitions", "200") \ .config("spark.sql.autoBroadcastJoinThreshold", str(10 * 1024 * 1024)) \ .getOrCreate() # ------------------------------------------------------- # TECHNIQUE 1: Caching and Persisting # ------------------------------------------------------- df_raw = spark.read.parquet("/data/large_transactions") # cache() stores in memory, spills to disk if not enough memory # use when the DataFrame fits in memory and is reused multiple times df_raw.cache() # persist() gives control over storage level from pyspark import StorageLevel # MEMORY_AND_DISK: memory first, spill to disk if needed df_raw.persist(StorageLevel.MEMORY_AND_DISK) # DISK_ONLY: always store on disk, slower but no memory pressure df_raw.persist(StorageLevel.DISK_ONLY) # Trigger materialisation with an action df_raw.count() # Use the cached DataFrame multiple times without recomputing agg1 = df_raw.groupBy("region").agg(F.sum("amount")) agg2 = df_raw.groupBy("product").agg(F.count("*")) agg1.show() agg2.show() # Always unpersist when done to free memory df_raw.unpersist() # ------------------------------------------------------- # TECHNIQUE 2: Controlling Shuffle Partitions # ------------------------------------------------------- # Default is 200 which is too high for small data and too low for large data # Rule of thumb: target 100-200MB per partition after shuffle # For small datasets reduce shuffle partitions spark.conf.set("spark.sql.shuffle.partitions", "10") # For very large datasets increase it spark.conf.set("spark.sql.shuffle.partitions", "2000") # AQE automatically coalesces small shuffle partitions (Spark 3.0+) spark.conf.set("spark.sql.adaptive.enabled", "true") spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true") # ------------------------------------------------------- # TECHNIQUE 3: Broadcast Join # ------------------------------------------------------- large_df = spark.read.parquet("/data/transactions") # billions of rows small_df = spark.read.parquet("/data/product_catalog") # thousands of rows # Force broadcast hint for the small DataFrame result = large_df.join( F.broadcast(small_df), on="product_id", how="inner" ) # Or raise the auto broadcast threshold spark.conf.set( "spark.sql.autoBroadcastJoinThreshold", str(100 * 1024 * 1024) # 100 MB ) # ------------------------------------------------------- # TECHNIQUE 4: Handling Data Skew with Salting # ------------------------------------------------------- # Diagnose skew: check partition sizes df_raw.groupBy(F.spark_partition_id()) \ .count() \ .orderBy(F.col("count").desc()) \ .show(10) # Salt the skewed join key to distribute load import random n_salts = 10 # Add random salt to the large skewed DataFrame large_df_salted = large_df.withColumn( "salted_key", F.concat( F.col("customer_id"), F.lit("_"), (F.rand() * n_salts).cast("int") ) ) # Explode the small DataFrame to match all salt values from pyspark.sql.functions import array, explode, lit small_df_salted = small_df.withColumn( "salt", explode(array([lit(i) for i in range(n_salts)])) ).withColumn( "salted_key", F.concat(F.col("customer_id"), F.lit("_"), F.col("salt")) ) # Join on salted key result_salted = large_df_salted.join( small_df_salted, on="salted_key", how="inner" ).drop("salted_key", "salt") # ------------------------------------------------------- # TECHNIQUE 5: Avoid Wide Transformations Where Possible # ------------------------------------------------------- # Push filters as early as possible before any joins or aggregations df_filtered = spark.read.parquet("/data/transactions") \ .filter(F.col("txn_date") >= "2024-01-01") \ .filter(F.col("region") == "North") # Select only required columns before joining to reduce data volume df_slim = df_filtered.select("txn_id", "customer_id", "amount") # Join with the slimmed DataFrame result = df_slim.join(F.broadcast(small_df), on="customer_id") # ------------------------------------------------------- # TECHNIQUE 6: Repartition Strategically # ------------------------------------------------------- # Repartition on the join key before a sort merge join # to avoid a full shuffle at join time df_repartitioned = large_df \ .repartition(200, "customer_id") \ .sortWithinPartitions("customer_id", "txn_date") # Check current partition count and sizes print("Partition count:", df_repartitioned.rdd.getNumPartitions()) # ------------------------------------------------------- # TECHNIQUE 7: Use explain() to Verify the Query Plan # ------------------------------------------------------- result.explain(mode="formatted") # Look for: # BroadcastHashJoin instead of SortMergeJoin for small table joins # PartitionFilters for partition pruning # PushedFilters for predicate pushdown # No unnecessary exchanges (shuffles)
What to say: I approach Spark performance problems in a specific order. First I look at the Spark UI to understand what is actually happening before changing anything. Then I address the biggest impact problems first, which are almost always either a missing broadcast join on a small table, data skew causing straggler tasks, or recomputing the same DataFrame multiple times without caching. Configuration tuning like shuffle partitions comes after structural fixes since changing configuration without fixing the root cause just moves the bottleneck rather than solving it.
Follow-up Questions & Answers
Q1. When should you cache a DataFrame and when should you not?
Cache a DataFrame when it is used more than once in the same job and computing it is expensive, meaning it involves reading from storage, joining, or aggregating large amounts of data. The benefit of caching is that the second and subsequent uses read from memory rather than recomputing from scratch.
# Good candidate for caching: expensive computation used multiple times df_aggregated = df_raw \ .groupBy("customer_id") \ .agg(F.sum("amount").alias("total_spend")) \ .cache() # Used multiple times top_spenders = df_aggregated.filter(F.col("total_spend") > 100000) low_spenders = df_aggregated.filter(F.col("total_spend") < 10000) median_spenders = df_aggregated.filter( (F.col("total_spend") >= 10000) & (F.col("total_spend") <= 100000) )
Do not cache when the DataFrame is used only once since caching has its own cost of writing to memory and reading back. Do not cache very large DataFrames that do not fit in memory since the spill to disk actually makes things slower than just recomputing. Always unpersist when done.
Q2. What is Adaptive Query Execution in Spark 3 and what problems does it solve automatically?
Adaptive Query Execution is a framework introduced in Spark 3.0 that allows Spark to re-optimise the query plan at runtime based on actual data statistics collected during execution, rather than relying solely on pre-execution estimates which are often wrong. It solves three specific problems automatically. First it coalesces small shuffle partitions, if Spark creates two hundred shuffle partitions but most of them are tiny after the shuffle, AQE merges them into fewer larger partitions automatically. Second it switches join strategies at runtime, if Spark planned a sort merge join but discovers during execution that one side is actually small enough to broadcast, AQE switches to a broadcast join automatically. Third it handles skew joins, if AQE detects that some partitions have significantly more data than others, it splits those partitions to distribute the work more evenly.
# Enable AQE (default in Spark 3.2+) spark.conf.set("spark.sql.adaptive.enabled", "true") spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true") spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true") # AQE skew join threshold: partitions larger than this are considered skewed spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "256mb")
Q3. What is the difference between cache and persist in PySpark?
cache is a shorthand for persist with the default storage level of MEMORY_AND_DISK_DESER, meaning Spark stores the DataFrame in memory as deserialized Java objects, which is the fastest for access but uses the most memory. persist gives you control over the storage level.
from pyspark import StorageLevel # MEMORY_ONLY: fastest access, fails if data does not fit in memory df.persist(StorageLevel.MEMORY_ONLY) # MEMORY_AND_DISK: memory first, spill remaining to disk # most commonly used, safe default df.persist(StorageLevel.MEMORY_AND_DISK) # MEMORY_ONLY_SER: store as serialized bytes, uses less memory # slightly slower access due to deserialization cost df.persist(StorageLevel.MEMORY_ONLY_SER) # DISK_ONLY: always store on disk, slowest access # use only when memory is severely constrained df.persist(StorageLevel.DISK_ONLY) # OFF_HEAP: store outside JVM heap in off-heap memory # reduces garbage collection pressure df.persist(StorageLevel.OFF_HEAP)
What to say: In practice I use cache for most situations since MEMORY_AND_DISK is a safe default that handles both the case where the data fits in memory and the case where it does not. I use DISK_ONLY or MEMORY_ONLY_SER only in specific situations where memory is severely constrained and I need to control exactly how much heap space the cached data consumes.
Q4. How would you diagnose and fix a job that is failing with an out of memory error on the executor?
Out of memory errors on executors have several possible causes and each requires a different fix.
# DIAGNOSIS STEP 1: Check what stage is failing and how much data it processes # Look at the Spark UI Stages tab for the failing stage # DIAGNOSIS STEP 2: Check partition sizes df.groupBy(F.spark_partition_id()) \ .count() \ .orderBy(F.col("count").desc()) \ .show() # FIX 1: Increase shuffle partitions to reduce data per partition spark.conf.set("spark.sql.shuffle.partitions", "2000") # FIX 2: Increase executor memory if the cluster has capacity # Set in SparkSession config or spark-submit # --executor-memory 8g # FIX 3: Reduce memory pressure from caching df.unpersist() # free any cached DataFrames no longer needed # FIX 4: Use serialized storage level to reduce memory footprint df.persist(StorageLevel.MEMORY_AND_DISK_SER) # FIX 5: If caused by a skewed join, apply salting # to distribute skewed partitions across more executors # FIX 6: Reduce the broadcast join threshold # if a DataFrame being broadcast is too large and causing OOM spark.conf.set( "spark.sql.autoBroadcastJoinThreshold", str(5 * 1024 * 1024) # reduce to 5MB )
What to say: The most common causes of executor OOM in my experience are a shuffle with too few partitions meaning too much data lands on each executor, a broadcast join where the broadcasted DataFrame is larger than assumed and fills executor memory, or a collect or collect_list on a large array that accumulates too much data in one executor's memory. I always diagnose before applying fixes because OOM from a skewed partition requires a different solution than OOM from an oversized broadcast, and applying the wrong fix wastes time.
Q5. What is speculative execution in Spark and when would you enable it?
Speculative execution is a Spark feature where if a task is running significantly slower than the median task in the same stage, Spark launches a duplicate copy of that task on a different executor and takes whichever copy finishes first. This protects against stragglers caused by temporary hardware problems like a slow disk or a noisy neighbour on a shared cluster.
# Enable speculative execution spark.conf.set("spark.speculation", "true") # Task must be running longer than this fraction of the median task time # to be considered a straggler spark.conf.set("spark.speculation.multiplier", "1.5") # Minimum number of tasks that must complete before speculation kicks in spark.conf.set("spark.speculation.quantile", "0.75")
What to say: Speculative execution is helpful for straggler tasks caused by transient infrastructure problems but it is not the right solution for data skew. If one task is slow because it has ten times more data than the others, launching a duplicate copy of that task on a different executor just moves the same oversized data to a different location, the duplicate takes just as long as the original. For skew the correct fix is salting or repartitioning to split the large partition. Speculative execution is only valuable when the slowness is caused by the executor environment, not by the data volume itself.
Streaming Data with Structured Streaming
The Interviewer Says
Our platform receives real time transaction events from Kafka. We need to process these events as they arrive, compute running aggregations like total spend per customer in the last hour, and write results to a Delta table. Walk me through how Structured Streaming works in PySpark and how you would build this pipeline.
How to Handle This in the Interview
Before writing anything, say: Structured Streaming is PySpark's model for processing data streams using the same DataFrame API as batch processing. The key mental model is to think of a stream as an unbounded table that continuously grows as new data arrives. Every trigger interval Spark processes the new rows that arrived since the last trigger and updates the result.
Step 1: Understand the source, processing, and sink
- Every streaming pipeline has three parts. A source where data comes from, like Kafka, a file directory, or a socket. Processing which applies transformations and aggregations using the same DataFrame API as batch. A sink where results are written, like Delta, Parquet files, Kafka, or a console for development.
Step 2: Understand output modes
- Say: There are three output modes. Complete mode rewrites the entire result table on every trigger, useful for aggregations where you always want the full picture. Append mode only writes new rows that are final and will never change, used for non-aggregation queries. Update mode only writes rows that changed since the last trigger, most efficient for aggregations.
Step 3: Know why watermarking matters
- Say: In real streaming systems, events arrive late. A transaction that happened at 10:00 might arrive at 10:15 due to network delays. Without watermarking, Spark would have to keep state for every possible event time forever in case a late event arrives. Watermarking tells Spark that any event more than N minutes late can be safely dropped, allowing Spark to clean up old state and control memory usage.
Step 4: Know the difference between processing time and event time
- Processing time is when Spark receives the event. Event time is when the event actually happened, stored in a field in the data itself. For meaningful business aggregations like total spend in the last hour you almost always want event time, not processing time.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import ( StructType, StructField, StringType, IntegerType, TimestampType ) spark = SparkSession.builder \ .appName("StructuredStreaming") \ .config("spark.sql.streaming.checkpointLocation", "/checkpoints") \ .getOrCreate() spark.sparkContext.setLogLevel("WARN") # ------------------------------------------------------- # PART 1: Define the schema of incoming Kafka messages # ------------------------------------------------------- # Kafka messages arrive as binary key and value # The value is a JSON string we need to parse transaction_schema = StructType([ StructField("txn_id", StringType(), True), StructField("customer_id", StringType(), True), StructField("product", StringType(), True), StructField("amount", IntegerType(), True), StructField("txn_time", TimestampType(), True), StructField("region", StringType(), True), ]) # ------------------------------------------------------- # PART 2: Read from Kafka # ------------------------------------------------------- kafka_stream = spark.readStream \ .format("kafka") \ .option("kafka.bootstrap.servers", "broker1:9092,broker2:9092") \ .option("subscribe", "transactions") \ .option("startingOffsets", "latest") \ .option("maxOffsetsPerTrigger", "10000") \ .load() # Kafka value is binary, cast to string then parse as JSON parsed_stream = kafka_stream.select( F.from_json( F.col("value").cast(StringType()), transaction_schema ).alias("data"), F.col("timestamp").alias("kafka_timestamp") ).select( "data.*", "kafka_timestamp" ) print("Parsed stream schema:") parsed_stream.printSchema() # ------------------------------------------------------- # PART 3: Apply watermark for late data handling # ------------------------------------------------------- # Tell Spark: any event more than 10 minutes late can be dropped # This allows Spark to clean up old state for completed windows watermarked_stream = parsed_stream.withWatermark( "txn_time", # event time column "10 minutes" # maximum lateness we will tolerate ) # ------------------------------------------------------- # PART 4: Windowed aggregations on event time # ------------------------------------------------------- # Total spend per customer in tumbling 1-hour windows windowed_agg = watermarked_stream \ .groupBy( F.window(F.col("txn_time"), "1 hour"), # tumbling window F.col("customer_id") ) \ .agg( F.sum("amount").alias("total_spend"), F.count("*").alias("txn_count"), F.max("amount").alias("max_txn") ) \ .select( F.col("window.start").alias("window_start"), F.col("window.end").alias("window_end"), F.col("customer_id"), F.col("total_spend"), F.col("txn_count"), F.col("max_txn") ) # Sliding window: 1-hour window sliding every 15 minutes sliding_agg = watermarked_stream \ .groupBy( F.window(F.col("txn_time"), "1 hour", "15 minutes"), F.col("region") ) \ .agg(F.sum("amount").alias("regional_spend")) # ------------------------------------------------------- # PART 5: Write to Delta with update mode # ------------------------------------------------------- # Update mode: only write rows that changed since last trigger query_delta = windowed_agg.writeStream \ .outputMode("update") \ .format("delta") \ .option("checkpointLocation", "/checkpoints/windowed_spend") \ .trigger(processingTime="1 minute") \ .start("/output/windowed_customer_spend") # ------------------------------------------------------- # PART 6: Write to console for development and testing # ------------------------------------------------------- query_console = windowed_agg.writeStream \ .outputMode("update") \ .format("console") \ .option("truncate", "false") \ .trigger(processingTime="30 seconds") \ .start() # ------------------------------------------------------- # PART 7: Stateful streaming without aggregation # using foreachBatch for custom sink logic # ------------------------------------------------------- def write_to_delta(batch_df, batch_id): print(f"Processing batch {batch_id} with {batch_df.count()} rows") batch_df.write \ .format("delta") \ .mode("append") \ .save("/output/raw_transactions_delta") raw_query = parsed_stream.writeStream \ .outputMode("append") \ .foreachBatch(write_to_delta) \ .option("checkpointLocation", "/checkpoints/raw_transactions") \ .trigger(processingTime="1 minute") \ .start() # ------------------------------------------------------- # PART 8: Monitor streaming queries # ------------------------------------------------------- # Check all active queries for q in spark.streams.active: print(f"Query: {q.name}, Status: {q.status}") print(f"Recent progress: {q.recentProgress}") # Wait for all queries to terminate spark.streams.awaitAnyTermination() # Or wait for a specific query query_delta.awaitTermination() # Stop a specific query query_console.stop()
What to say: The most important concept to get right in structured streaming is watermarking. Without a watermark, any stateful operation like a windowed aggregation keeps state forever in memory because Spark cannot know when it is safe to finalise a window, in case a very late event arrives and changes the result. The watermark is my promise to Spark that I will not care about events arriving more than N minutes after their event time, which lets Spark finalise and clean up old window state regularly to control memory usage. In production I always set a watermark before any stateful aggregation.
Follow-up Questions & Answers
Q1. What is the difference between a tumbling window and a sliding window in Structured Streaming?
A tumbling window divides time into fixed non-overlapping buckets. Every event belongs to exactly one window. A one hour tumbling window from 10:00 covers 10:00 to 11:00, the next window covers 11:00 to 12:00, and so on with no overlap. A sliding window also has a fixed duration but it slides forward by a smaller step interval so windows overlap. A one hour window sliding every fifteen minutes means the window covering 10:00 to 11:00 is followed by a window covering 10:15 to 11:15, then 10:30 to 11:30. Every event belongs to multiple overlapping windows simultaneously.
# Tumbling window: 1 hour, no overlap F.window(F.col("txn_time"), "1 hour") # Sliding window: 1 hour duration, slides every 15 minutes # each event belongs to 4 overlapping windows F.window(F.col("txn_time"), "1 hour", "15 minutes") # Session window: closes after N minutes of inactivity # Spark 3.2+ F.session_window(F.col("txn_time"), "30 minutes")
What to say: Tumbling windows are appropriate for reporting periods like hourly or daily totals where each period should be counted independently. Sliding windows are appropriate for moving averages or metrics that should reflect activity over the last N minutes regardless of when you check them. Session windows are the most sophisticated and model user sessions naturally, the window stays open as long as events keep arriving and closes only when there is a gap of more than N minutes, exactly like a web session timeout.
Q2. What is a checkpoint in Structured Streaming and why is it mandatory for production?
A checkpoint is a directory where Structured Streaming continuously saves its progress metadata, including the current offset in Kafka or file source, the state of any stateful operations like windowed aggregations, and the query configuration. When a streaming job fails and restarts, it reads the checkpoint to know exactly where it left off and resumes from that point rather than restarting from the beginning of the stream or reprocessing data it already handled.
# Checkpoint is mandatory for any stateful operation or exactly-once guarantee query = df.writeStream \ .format("delta") \ .option("checkpointLocation", "/checkpoints/my_query") \ .outputMode("update") \ .start("/output/results") # Each streaming query needs its own unique checkpoint location # sharing checkpoints between queries causes failures query_1 = df.writeStream.option("checkpointLocation", "/checkpoints/q1").start() query_2 = df.writeStream.option("checkpointLocation", "/checkpoints/q2").start()
What to say: In production I treat the checkpoint directory as critical infrastructure, the same way I treat a database. If the checkpoint directory is deleted or corrupted, the streaming job cannot resume safely and may reprocess data leading to duplicates, or skip data leading to gaps. I store checkpoints on durable storage like ADLS, S3, or HDFS rather than local disk, and I never share a checkpoint location between two different streaming queries since they would overwrite each other's progress metadata and cause failures.
Q3. What are the three output modes and when would you use each?
# APPEND mode: only new rows that will never be updated are written # appropriate for: non-aggregation queries, or windowed aggregations # with watermarking where windows are finalised before writing df.writeStream.outputMode("append") # UPDATE mode: only rows that changed since the last trigger are written # appropriate for: aggregations where partial results are acceptable # most efficient for aggregations since only changed rows are written df.writeStream.outputMode("update") # COMPLETE mode: the entire result is rewritten on every trigger # appropriate for: global aggregations where you always need the full picture # most expensive since it rewrites everything each time df.writeStream.outputMode("complete")
What to say: Update mode is almost always what I use for aggregations in production because it only writes the changed rows on each trigger rather than the full result set, which is much more efficient when the result table is large. Complete mode would be appropriate for something like a live leaderboard where you need to always show the entire ranking rather than just the changed entries. Append mode is appropriate for stateless transformations on event streams where you are simply enriching or filtering events without maintaining any state across batches.
Q4. How would you handle exactly-once processing in a Kafka to Delta streaming pipeline?
Exactly-once means each event is processed and written exactly once, with no duplicates and no missed events, even if the job fails and restarts mid-batch.
# Delta Lake + Structured Streaming provides exactly-once semantics # through a combination of: # 1. Kafka offset tracking in the checkpoint (exactly-once read) # 2. Delta's transactional writes (exactly-once write) query = parsed_stream.writeStream \ .format("delta") \ .outputMode("append") \ .option("checkpointLocation", "/checkpoints/exactly_once") \ .trigger(processingTime="1 minute") \ .start("/output/transactions_delta") # For non-Delta sinks that don't support transactions, # use foreachBatch with idempotent write logic def idempotent_write(batch_df, batch_id): # Use batch_id to detect and skip already-processed batches batch_df.withColumn("batch_id", F.lit(batch_id)) \ .write \ .format("delta") \ .mode("append") \ .save("/output/transactions") # Deduplicate after append using merge from delta.tables import DeltaTable target = DeltaTable.forPath(spark, "/output/transactions") target.alias("t").merge( batch_df.alias("s"), "t.txn_id = s.txn_id" ).whenNotMatchedInsertAll().execute() stream = parsed_stream.writeStream \ .foreachBatch(idempotent_write) \ .option("checkpointLocation", "/checkpoints/idempotent") \ .start()
What to say: Exactly-once end to end in a streaming pipeline requires exactly-once at both the read and write stages independently. Structured Streaming with Kafka guarantees exactly-once reads by tracking committed Kafka offsets in the checkpoint. Delta Lake guarantees exactly-once writes through its transactional log which uses optimistic concurrency control to detect and reject duplicate writes. The combination of Kafka as source, Structured Streaming with checkpointing as the processing engine, and Delta as the sink is the standard architecture for exactly-once streaming pipelines in the Databricks and open source Spark ecosystem.
Q5. How would you monitor a production streaming job and detect if it is falling behind?
A streaming job falling behind means new events are arriving faster than the job is processing them, causing the consumer lag to grow continuously.
# Monitor via streaming query progress query = windowed_agg.writeStream \ .format("delta") \ .option("checkpointLocation", "/checkpoints/monitor") \ .start("/output/results") import time while query.isActive: progress = query.lastProgress if progress: input_rate = progress.get("inputRowsPerSecond", 0) process_rate = progress.get("processedRowsPerSecond", 0) batch_duration = progress.get("durationMs", {}).get("triggerExecution", 0) print(f"Input rate: {input_rate:.1f} rows/sec") print(f"Processing rate: {process_rate:.1f} rows/sec") print(f"Batch duration: {batch_duration} ms") # Alert if processing rate is below input rate if process_rate < input_rate * 0.9: print("WARNING: Job is falling behind") # Alert if batch is taking longer than the trigger interval trigger_ms = 60 * 1000 # 1 minute trigger if batch_duration > trigger_ms: print(f"WARNING: Batch duration {batch_duration}ms exceeds trigger interval {trigger_ms}ms") time.sleep(30)
What to say: The two most important metrics for a streaming job are input rate versus processing rate, and batch duration versus trigger interval. If processing rate consistently falls below input rate the consumer lag grows, meaning the pipeline is getting progressively further behind real time. If batch duration consistently exceeds the trigger interval it means each batch is not finishing before the next one is supposed to start, which eventually causes the job to stall. Common fixes for a falling behind job are increasing the number of executors, increasing shuffle partitions, switching from complete output mode to update mode to reduce write volume, or increasing the trigger interval to give each batch more time to complete.
Spark Architecture
The Interviewer Says
Before we go into more PySpark coding, I want to understand how well you know what happens under the hood when you run a Spark job. Explain Spark architecture to me, and then walk me through exactly what happens internally from the moment you submit a job to the moment results are written. I want to hear about the driver, executors, cluster manager, DAG, stages, and tasks.
How to Handle This in the Interview
Before answering, say: Spark architecture has two layers worth explaining clearly, the physical deployment layer which is about how processes are organised across the cluster, and the logical execution layer which is about how your code gets translated into actual work. Let me walk through both.
Step 1: Start with the big picture
- Say: A Spark application always has exactly one Driver process and one or more Executor processes. The Driver is the brain and the Executors are the muscles. Everything else in the architecture exists to coordinate between them.
Step 2: Explain the three cluster modes
- Spark can run in three modes, local for development on a single machine, standalone using Spark's own built-in cluster manager, and on top of an external cluster manager like YARN, Kubernetes, or Mesos. The architecture of the Spark application itself is the same across all three, only the cluster manager changes.
Step 3: Walk through the logical execution path
- Your code creates a logical plan. Catalyst optimises it. The optimised plan is compiled into a physical plan. The physical plan is broken into stages separated by shuffle boundaries. Each stage is broken into tasks. Tasks run on executor cores.
Step 4: Make it concrete with a real example
- The best way to impress an interviewer is to walk through a specific operation like a groupBy followed by a write and explain exactly what Spark does at each layer for that specific operation.
The Architecture Diagram in Words
Your PySpark Code
|
v
SparkSession (entry point, lives in Driver)
|
v
Catalyst Optimizer
(parses, analyzes, optimizes your logical plan)
|
v
Physical Plan (converted to RDD operations)
|
v
DAG Scheduler
(breaks physical plan into Stages at shuffle boundaries)
|
v
Task Scheduler
(breaks each Stage into Tasks, one Task per partition)
|
v
Cluster Manager (YARN / Kubernetes / Standalone)
(allocates resources, launches Executor JVMs on worker nodes)
|
v
Executors (JVM processes on worker nodes)
(run Tasks, store cached data, write output)
Solution — What Happens When You Run This Code
from pyspark.sql import SparkSession from pyspark.sql import functions as F # STEP 1: SparkSession creation # The Driver process starts # SparkContext is created inside the Driver # The Driver contacts the Cluster Manager to request Executor resources # Cluster Manager launches Executor JVMs on worker nodes # Executors register themselves back with the Driver spark = SparkSession.builder \ .appName("ArchitectureDemo") \ .config("spark.executor.instances", "4") \ .config("spark.executor.cores", "2") \ .config("spark.executor.memory", "4g") \ .getOrCreate() # STEP 2: Reading data (TRANSFORMATION - LAZY) # Nothing actually happens here # Spark just records "I need to read this file" in the logical plan # No data is read, no Executors are contacted df = spark.read \ .option("header", "true") \ .schema(schema) \ .parquet("/data/transactions") # STEP 3: Filter (TRANSFORMATION - LAZY, NARROW) # Still nothing happens # A filter node is added to the logical plan # This is a NARROW transformation because each partition # can be processed independently with no data movement df_filtered = df.filter(F.col("amount") > 10000) # STEP 4: GroupBy + Aggregation (TRANSFORMATION - LAZY, WIDE) # Still nothing happens # A groupBy node is added to the logical plan # This is a WIDE transformation because rows with the same # customer_id from different partitions need to come together # This will cause a SHUFFLE when executed df_agg = df_filtered.groupBy("customer_id") \ .agg(F.sum("amount").alias("total_spend")) # STEP 5: This is where EVERYTHING actually happens - ACTION # df_agg.show() triggers execution # Here is exactly what Spark does: # # 1. Catalyst Optimizer takes the logical plan and: # a. Resolves column names and types (Analysis phase) # b. Applies optimization rules like filter pushdown, # column pruning, constant folding (Optimization phase) # c. Generates one or more physical plan candidates # and picks the best one using a cost model # # 2. DAG Scheduler looks at the physical plan and: # a. Identifies SHUFFLE BOUNDARIES # b. Splits the plan into STAGES at each boundary # c. Stage 1: Read parquet + apply filter (narrow, no shuffle) # d. Stage 2: groupBy shuffle - redistribute rows by customer_id # e. Stage 3: Aggregate within each partition after shuffle # # 3. Task Scheduler takes each Stage and: # a. Splits it into TASKS, one Task per partition # b. If the parquet file has 8 partitions, Stage 1 has 8 Tasks # c. Sends Tasks to available Executor slots # # 4. Executors receive Tasks and: # a. Each Executor runs its assigned Tasks on its cores # b. For Stage 1: each Task reads its partition of the parquet file, # applies the filter, and writes shuffle output to local disk # c. For Stage 2: each Task reads shuffle data from other Executors # over the network (this is the SHUFFLE) # d. For Stage 3: each Task aggregates its portion of data # and sends results back to the Driver # # 5. Driver collects the results and displays them df_agg.show() # STEP 6: Writing results (ACTION) # Triggers another execution # Spark reads the lineage from df_filtered again # unless df_filtered is cached df_agg.write \ .mode("overwrite") \ .parquet("/output/customer_spend")
What to say: Every single line before show or write is lazy, Spark just builds up a logical plan but does not execute anything. The moment an action is called, Catalyst optimises the plan, the DAG Scheduler identifies stage boundaries at shuffle points, the Task Scheduler distributes tasks to executors, and executors run the actual computation in parallel. Understanding this flow is what allows you to reason about why a job is slow and where to look in the Spark UI to diagnose it.
Key Concepts Summary
# ------------------------------------------------------- # DRIVER # ------------------------------------------------------- # Single JVM process per Spark application # Runs your main Python or Scala program # Contains SparkContext and SparkSession # Builds the logical plan # Hosts the DAG Scheduler and Task Scheduler # Collects results from Executors (show, collect) # DOES NOT process data itself # If Driver runs out of memory, the entire application fails # ------------------------------------------------------- # EXECUTOR # ------------------------------------------------------- # One or more JVM processes on worker nodes # Each Executor has a fixed number of cores and memory # Each core runs one Task at a time # Executors store cached data in memory # Executors write shuffle output to local disk # Executors communicate with each other during shuffles # If an Executor fails, Spark relaunches lost Tasks on other Executors # ------------------------------------------------------- # CLUSTER MANAGER # ------------------------------------------------------- # External service that allocates resources # YARN: standard in Hadoop clusters, CDH, HDP # Kubernetes: container-native, increasingly common # Databricks: proprietary cluster manager built on top of cloud VMs # Standalone: Spark's own built-in manager, simple but limited # ------------------------------------------------------- # DAG (Directed Acyclic Graph) # ------------------------------------------------------- # A graph of all transformations from source to result # Directed: each step flows in one direction # Acyclic: no circular dependencies, no going backward # Spark builds this graph lazily and executes it on action # ------------------------------------------------------- # STAGE # ------------------------------------------------------- # A group of Tasks that can run without a shuffle in between # Stage boundary = a wide transformation (shuffle point) # All Tasks in a Stage must complete before the next Stage starts # Example: filter + select = same stage (no shuffle needed) # groupBy = new stage boundary (shuffle required) # ------------------------------------------------------- # TASK # ------------------------------------------------------- # The smallest unit of work in Spark # One Task processes exactly one partition # Runs on one Executor core # If a Task fails, Spark retries it on another Executor (up to 4 times by default) # ------------------------------------------------------- # PARTITION # ------------------------------------------------------- # A chunk of data processed by one Task # Default partition size for reads is 128MB (matches HDFS block size) # Number of shuffle partitions defaults to 200 # Rule of thumb: 100 to 200 MB of data per partition after shuffle
Follow-up Questions & Answers
Q1. What is the difference between the Driver and an Executor? What happens if each one fails?
The Driver is a single JVM process that runs your application's main function. It hosts the SparkContext, builds the logical and physical execution plan, schedules tasks, and collects results for actions like show and collect. It does not process any data itself. The Executors are JVM processes running on worker nodes that actually do the computation. Each Executor has a fixed number of CPU cores and a fixed amount of memory. Each core runs one Task at a time.
If the Driver fails, the entire Spark application dies immediately since there is no way to recover, the Driver is the single point of coordination and its state cannot be transferred elsewhere. If an Executor fails, Spark automatically recovers by relaunching the Tasks that were running on that Executor on other available Executors. Spark can do this because transformations are deterministic and the data lineage is always known, so any lost computation can be recomputed from the last stable checkpoint.
What to say: This is why you must never run memory-intensive operations on the Driver. If you call collect on a large DataFrame, all that data flows back to the Driver's memory. If it exceeds the Driver's memory limit, the entire application crashes with no recovery. show is safe because it brings only a limited number of rows. collect is dangerous on anything larger than a few megabytes.
Q2. What is the DAG and how does Spark use it to recover from failures?
The DAG, Directed Acyclic Graph, is Spark's internal representation of all the transformations that need to happen from the source data to the final result. Every transformation you apply adds a node to this graph. Since the graph is acyclic there are no circular dependencies, meaning the lineage always traces cleanly back to the original source. When an Executor fails and loses some partitions of an intermediate result, Spark does not need to restart the entire job. It looks at the DAG, identifies which partitions were lost, traces back through the lineage to find where those partitions came from, and recomputes only those specific partitions from the last stable point. This is called lineage-based fault tolerance and it is fundamentally different from how traditional databases handle failures through transaction logs and write ahead logs.
Q3. What is a shuffle and exactly when does it happen?
A shuffle is the process of redistributing data across the cluster so that rows that need to be processed together end up on the same Executor. It involves three expensive steps, first each Executor writes its shuffle output to local disk organised by the target partition, then Executors read the relevant shuffle blocks from other Executors over the network, then each Executor sorts and processes the data it received. A shuffle happens whenever you have a wide transformation.
# These operations ALWAYS cause a shuffle (wide transformations) df.groupBy("customer_id").agg(F.sum("amount")) # shuffle by customer_id df.join(other_df, on="key") # shuffle both sides by key df.distinct() # shuffle to find duplicates df.repartition(10) # shuffle to redistribute df.orderBy("amount") # shuffle to sort globally # These operations NEVER cause a shuffle (narrow transformations) df.filter(F.col("amount") > 1000) # each partition independent df.withColumn("tax", F.col("amount") * 0.18) # each row independent df.select("customer_id", "amount") # each partition independent df.map(lambda x: x) # each partition independent df.coalesce(5) # merges partitions, no shuffle
What to say: Every shuffle writes data to disk and transfers it over the network, which is why wide transformations are expensive and narrow transformations are cheap. When I am looking at a slow Spark job in the Spark UI, the first thing I look for is the shuffle read and write bytes in the Stage metrics. An unexpectedly large shuffle is almost always the bottleneck, and the fix is usually either a broadcast join to eliminate the shuffle entirely, repartitioning the data on the join key before the operation to reduce the shuffle volume, or salting to handle skew that makes some shuffle partitions much larger than others.
Q4. What is the role of the Catalyst Optimizer and what optimisations does it apply automatically?
Catalyst is Spark's query optimisation engine. It sits between your logical plan and the physical execution plan and applies a set of rule-based and cost-based optimisations automatically before any computation begins.
# Example: Catalyst applies filter pushdown automatically # You write this df = spark.read.parquet("/data/transactions") \ .join(customers_df, on="customer_id") \ .filter(F.col("region") == "North") # Catalyst rewrites this internally as: # 1. Apply the filter BEFORE the join to reduce data volume # 2. Only read columns actually needed (column pruning) # 3. Push the filter down to the Parquet reader to skip row groups # The result is dramatically less data being joined # You can inspect the optimised plan df.explain(mode="formatted") # Look for: # "PushedFilters" in the scan node = filter pushed into storage layer # "Project" nodes showing only selected columns = column pruning # "BroadcastHashJoin" instead of "SortMergeJoin" = join strategy
What to say: The most impactful optimisation Catalyst applies is predicate pushdown, moving filter conditions as early as possible in the plan so that less data flows through subsequent expensive operations like joins. Column pruning is the second most impactful, if your query only uses three columns from a fifty column Parquet file, Catalyst tells the Parquet reader to skip the other forty seven entirely, which can reduce IO by ninety percent. These optimisations happen automatically without any code change on my part, which is one of the reasons the DataFrame and SQL APIs are much faster than the raw RDD API where Catalyst cannot optimise anything.
Q5. What is the difference between a Job, Stage, and Task in Spark, and how are they related?
These three terms form a hierarchy that describes how Spark breaks down work.
A Job is created every time an action is called, meaning show, count, write, collect, and so on. One action equals one Job. A single Spark application can have many Jobs running sequentially or sometimes in parallel.
A Stage is a subdivision of a Job. The DAG Scheduler splits each Job into Stages at shuffle boundaries. All the work within a single Stage can be done without any data movement between Executors. A Job always has at least one Stage, and a Job with one groupBy has at least two Stages, one before the shuffle and one after.
A Task is the smallest unit of work. The Task Scheduler splits each Stage into Tasks, with one Task per partition. If Stage 1 processes a DataFrame with twenty partitions, Stage 1 has twenty Tasks. Each Task runs on exactly one Executor core and processes exactly one partition of data.
Application
|
+-- Job 1 (triggered by show())
| |
| +-- Stage 1 (read + filter, 8 Tasks for 8 partitions)
| +-- Stage 2 (shuffle write, 8 Tasks)
| +-- Stage 3 (shuffle read + aggregate, 200 Tasks)
|
+-- Job 2 (triggered by write())
|
+-- Stage 4 (read lineage again or from cache)
+-- Stage 5 (write output)
What to say: Understanding this hierarchy is directly useful for reading the Spark UI. The Jobs tab shows all Jobs. Clicking a Job shows its Stages. Clicking a Stage shows all its Tasks with timing and data size per Task. When I see one Task in a Stage taking ten times longer than all others, that immediately tells me I have data skew in that partition and I need to investigate which key value is causing one partition to be much larger than the rest.
Lazy Evaluation, Transformations, Actions, and Shuffling
The Interviewer Says
I want to test your conceptual understanding before diving into code. Explain lazy evaluation to me, then tell me the difference between a transformation and an action, and then explain the difference between narrow and wide transformations. Finally tell me when shuffling happens and why it is expensive. These are fundamentals I expect every data engineer to know cold.
How to Handle This in the Interview
Before answering, say: These four concepts are deeply connected. Lazy evaluation is the reason transformations can be optimised before execution. The narrow versus wide distinction is what determines whether a shuffle happens. And shuffles are expensive because they are the most costly consequence of wide transformations. Let me walk through each one and connect them together.
Step 1: Start with lazy evaluation as the foundation
- Every transformation in Spark is lazy, meaning it does not execute immediately. Spark builds a logical plan and waits. This waiting is deliberate because it gives Catalyst the complete picture of what you want to do before deciding how to do it efficiently.
Step 2: Separate transformations from actions clearly
- A transformation takes a DataFrame and returns a new DataFrame. An action triggers actual computation and returns a result to the Driver or writes to storage. The boundary between them is critical.
Step 3: Distinguish narrow from wide
- The key question is whether processing one output partition requires data from more than one input partition. If yes it is wide and causes a shuffle. If no it is narrow and stays local.
Step 4: Explain why shuffles are expensive
- Shuffle involves writing to disk, network transfer, and reading from disk across multiple nodes. It is the single most expensive operation in Spark and the most common bottleneck.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = SparkSession.builder \ .appName("LazyEvaluationDemo") \ .getOrCreate() data = [ ("T01", "C01", "North", 5000), ("T02", "C02", "South", 7000), ("T03", "C01", "North", 3000), ("T04", "C03", "South", 8000), ("T05", "C02", "North", 6000), ] df = spark.createDataFrame( data, ["txn_id", "customer_id", "region", "amount"] ) #### DEMONSTRATING LAZY EVALUATION ```python import time
- These lines execute instantly because nothing actually runs
- Spark just adds nodes to the logical plan
start = time.time() step1 = df.filter(F.col("amount") > 4000) step2 = step1.withColumn("tax", F.col("amount") * 0.18) step3 = step2.groupBy("region").agg(F.sum("amount").alias("total")) end = time.time() print(f"Time to define 3 transformations: {end - start:.4f} seconds")
-
This prints something like 0.0012 seconds
-
Nothing has run, Spark has only built the plan
-
This line triggers actual execution
-
Now Spark reads data, applies filter, adds column,
-
shuffles for groupBy, aggregates, and returns results
start = time.time() step3.show() end = time.time() print(f"Time to actually execute: {end - start:.4f} seconds")
- This prints something like 2.3 seconds
TRANSFORMATIONS vs ACTIONS
- TRANSFORMATIONS: return a new DataFrame, lazy, build the plan
- Never trigger execution on their own
# select: picks specific columns t1 = df.select("customer_id", "amount") # filter / where: keeps rows matching condition t2 = df.filter(F.col("amount") > 5000) t3 = df.where(F.col("region") == "North") # withColumn: adds or replaces a column t4 = df.withColumn("doubled", F.col("amount") * 2) # withColumnRenamed: renames a column t5 = df.withColumnRenamed("amount", "transaction_amount") # drop: removes a column t6 = df.drop("txn_id") # groupBy + agg: groups rows and computes aggregates t7 = df.groupBy("region").agg(F.sum("amount")) # join: combines two DataFrames on a key t8 = df.join(df, on="customer_id", how="inner") # orderBy / sort: sorts the DataFrame t9 = df.orderBy(F.col("amount").desc()) # distinct: removes duplicate rows t10 = df.distinct() # limit: keeps only first N rows t11 = df.limit(3) # union / unionAll: stacks two DataFrames t12 = df.union(df) # repartition / coalesce: changes number of partitions t13 = df.repartition(10) t14 = df.coalesce(2) print("All transformations defined, nothing has executed yet")
- ACTIONS: trigger execution, return a result to Driver or write to storage
print("Now calling actions...") # count: returns total number of rows as a Python integer row_count = df.count() print(f"count(): {row_count}") # show: prints first N rows to console (default 20) df.show(3) # collect: returns ALL rows as a Python list of Row objects rows = df.collect() print(f"collect() returned {len(rows)} rows") # first: returns the first row first_row = df.first() print(f"first(): {first_row}") # take: returns first N rows as a Python list first_3 = df.take(3) print(f"take(3): {first_3}") # head: alias for take first_2 = df.head(2) # describe: computes summary statistics (count, mean, stddev, min, max) df.describe("amount").show() # write: writes DataFrame to storage df.write.mode("overwrite").parquet("/output/demo") # toPandas: converts to Pandas DataFrame (brings all data to Driver) pandas_df = df.toPandas() print(f"toPandas() returned type: {type(pandas_df)}")
NARROW TRANSFORMATIONS
- Narrow = each output partition depends on only ONE input partition
- No data movement between partitions needed
- All work stays local to each Executor
- FAST, no shuffle
# filter: each partition is filtered independently narrow_1 = df.filter(F.col("amount") > 5000) # withColumn: each row transformed independently narrow_2 = df.withColumn("tax", F.col("amount") * 0.18) # select: picks columns from each row independently narrow_3 = df.select("customer_id", "amount") # map / flatMap on RDD: each record processed independently narrow_4 = df.rdd.map(lambda row: (row.customer_id, row.amount * 2)) # coalesce: merges adjacent partitions on same executor # avoids full shuffle, only moves data that needs to merge narrow_5 = df.coalesce(2) # union: simply concatenates partitions from both DataFrames # no data movement needed between partitions narrow_6 = df.union(df) print("Narrow transformations: no shuffle, each partition independent")
WIDE TRANSFORMATIONS (cause SHUFFLE)
- Wide = each output partition may need data from MULTIPLE input partitions
- Requires redistributing data across the cluster
- SLOW, involves disk write + network transfer + disk read
# groupBy + agg: all rows with same key must go to same partition # Shuffle: rows redistributed by hash of the groupBy key wide_1 = df.groupBy("region").agg(F.sum("amount")) # join: matching keys from both DataFrames must be on same partition # Shuffle: both DataFrames redistributed by hash of the join key other_df = spark.createDataFrame([("C01", "Karan")], ["customer_id", "name"]) wide_2 = df.join(other_df, on="customer_id") # distinct: must compare all rows to find duplicates # Shuffle: rows redistributed so duplicates land together wide_3 = df.distinct() # orderBy / sort: must see all data to sort globally # Shuffle: all data redistributed and sorted wide_4 = df.orderBy("amount") # repartition: explicitly redistributes all data # Full shuffle regardless of current partition layout wide_5 = df.repartition(10) print("Wide transformations cause shuffle, triggering actual execution...") wide_1.show() # this triggers the shuffle
VISUALISING THE SHUFFLE PROCESS
- When groupBy("region") is executed on 5 partitions:
# BEFORE SHUFFLE (map phase): # Partition 0: [T01/North/5000, T02/South/7000] # Partition 1: [T03/North/3000, T04/South/8000] # Partition 2: [T05/North/6000] # # SHUFFLE (each row sent to partition based on hash of "region"): # hash("North") % shuffle_partitions = partition X # hash("South") % shuffle_partitions = partition Y # Data written to local disk, then transferred over network # # AFTER SHUFFLE (reduce phase): # Partition X: [T01/North/5000, T03/North/3000, T05/North/6000] # Partition Y: [T02/South/7000, T04/South/8000] # # AGGREGATION (each partition aggregates independently): # Partition X: North -> 14000 # Partition Y: South -> 15000 # Check how many shuffle partitions are configured print("Shuffle partitions:", spark.conf.get("spark.sql.shuffle.partitions")) # Change shuffle partitions based on data size spark.conf.set("spark.sql.shuffle.partitions", "10")
What to say: Lazy evaluation is not just a technical curiosity, it is the foundation that makes Spark efficient. Because Spark waits before executing, Catalyst can look at the entire plan and apply optimisations like pushing filters before joins and eliminating unused columns before anything runs. The narrow versus wide distinction is what I use to reason about where shuffle boundaries will appear in my job, which directly tells me where stage boundaries will be in the Spark UI. Any time I see a wide transformation in my code I know there will be a shuffle, and any shuffle is a potential performance bottleneck worth examining.
Expected Behaviour
Time to define 3 transformations: 0.0012 seconds # lazy, nothing ran Time to actually execute: 2.3471 seconds # action triggered everything Narrow transformations: no shuffle, each partition independent Wide transformations cause shuffle...
| Region | Total |
|---|---|
| North | 14000 |
| South | 15000 |
Shuffle partitions: 200
Follow-up Questions & Answers
Q1. Why does Spark use lazy evaluation instead of executing each transformation immediately like Pandas does?
Pandas executes every operation immediately and eagerly, which means it cannot look ahead to optimise the sequence of operations. If you filter a DataFrame after joining it, Pandas performs the full join first and then filters, even though filtering before the join would have been much cheaper. Spark's lazy evaluation means it collects the entire sequence of transformations into a logical plan first, then Catalyst can rewrite that plan to apply the filter before the join, prune unused columns before reading from storage, and combine multiple simple operations into a single pass over the data. Additionally lazy evaluation means that defining ten transformations on a DataFrame that you never actually use costs nothing at all since no execution ever happens. In Pandas those ten operations would each execute and consume time and memory immediately.
Q2. What is the difference between collect and show, and when is each dangerous?
# show prints N rows to console, data stays distributed in the cluster # safe for any size DataFrame # default is 20 rows, can be changed df.show() # 20 rows df.show(100) # 100 rows df.show(100, truncate=False) # 100 rows, full column values # collect brings ALL rows from every partition to the Driver as a Python list # dangerous on large DataFrames, can crash the Driver with OOM all_rows = df.collect() for row in all_rows: print(row["customer_id"], row["amount"]) # take brings only first N rows to the Driver, much safer first_10 = df.take(10) # toPandas brings everything to Driver as a Pandas DataFrame # same risk as collect, use only on small DataFrames pandas_df = df.toPandas()
What to say: I use show for quick inspection during development and never use collect on production DataFrames without first confirming the row count is small. A collect on a DataFrame with a billion rows will attempt to load all that data into the Driver JVM, which typically has only a few gigabytes of memory, and the resulting OOM error kills the entire application. If I genuinely need data in Python I use take with a specific limit or write to a file and read it locally.
Q3. How many jobs, stages, and tasks would the following code create?
df = spark.read.parquet("/data/transactions") # 4 partitions result = df.filter(F.col("amount") > 1000) \ .groupBy("region") \ .agg(F.sum("amount")) result.show()
One action equals one job, so show creates one Job. The filter is a narrow transformation on 4 partitions, no shuffle needed. The groupBy causes a shuffle, creating a stage boundary. So there are two Stages. Stage 1 reads the 4 parquet partitions and applies the filter, creating 4 Tasks. Stage 2 reads the shuffle output, one task per shuffle partition, default is 200, though most will be empty so AQE would coalesce them. In total approximately 204 Tasks across 2 Stages in 1 Job.
Q4. What happens to the execution plan if you define a transformation but never call an action?
Absolutely nothing happens. Spark never executes a single byte of computation. The transformation calls build up a logical plan object in the Driver's memory, which is essentially a tree data structure describing what you want to do. The moment your program ends or the variable goes out of scope, that plan object is garbage collected by the JVM. No Executor is ever contacted, no data is ever read, and no CPU cycles are spent on the cluster. This is a key difference from Pandas where every operation is executed immediately and consumes resources even if you never use the result.
Q5. How does Spark achieve fault tolerance without replicating data the way HDFS does?
HDFS achieves fault tolerance by storing three copies of every data block on different nodes. If one node fails, the other two copies are still available. Spark takes a completely different approach called lineage based fault tolerance. Instead of replicating data, Spark remembers exactly how every partition of every DataFrame was produced, the complete sequence of transformations from the original source. If an Executor fails and some partitions are lost, Spark does not need another copy of that data. It looks at the lineage graph, identifies which transformations produced the lost partitions, and recomputes only those specific partitions by re-executing the relevant portion of the plan. This works because Spark transformations are deterministic, meaning the same input always produces the same output. The trade-off is that recomputation takes time, whereas HDFS replication provides instant failover. For cached DataFrames, Spark does store the data in memory but with no replication by default, meaning if the Executor holding a cached partition fails, Spark recomputes it from scratch by replaying the lineage rather than reading a replica.
CASE WHEN in PySpark Using when/otherwise
The Interviewer Says
I have a dataset with employee information including salary. I want you to write PySpark code to add a Level column where the value is L1 when salary is greater than or equal to 30000 and L2 when salary is less than 30000. Then extend it, I also want a Grade column with multiple conditions, A for salary above 50000, B for salary between 30000 and 50000, and C below 30000. Walk me through how you handle conditional column creation in PySpark.
How to Handle This in the Interview
Before writing anything, say: In PySpark the equivalent of SQL CASE WHEN is the when and otherwise functions from pyspark.sql.functions. They chain together exactly like SQL CASE WHEN THEN ELSE END and can be nested or combined with any other column expressions.
Step 1: Know the syntax pattern
F.when(condition, value).when(condition, value).otherwise(default_value)is the pattern. Each when adds another condition. otherwise is the final fallback, equivalent to ELSE in SQL. If otherwise is omitted, unmatched rows get NULL.
Step 2: Know when to use it with withColumn vs select
- Use withColumn when adding the new column alongside all existing columns. Use select when you want to control exactly which columns appear in the result at the same time as adding the new one.
Step 3: Handle NULLs in the condition
- If the salary column itself has NULL values, none of the when conditions will match a NULL since NULL comparisons return UNKNOWN. The row would fall through to otherwise. Mention this proactively.
Step 4: Know the SQL equivalent for Spark SQL users
- The same logic can be written as pure SQL using spark.sql with a CASE WHEN statement on a temp view, which is worth mentioning for teams that prefer SQL over the DataFrame API.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = SparkSession.builder \ .appName("CaseWhenDemo") \ .getOrCreate() data = [ ("Karan", 32, "Finance", 55000), ("Asha", 28, "IT", 28000), ("Ravi", 35, "Finance", 42000), ("Meena", 29, "HR", 31000), ("Sunil", 41, "IT", 62000), ("Divya", 26, "HR", 25000), ("Prakash",38, "Finance", None), ] df = spark.createDataFrame( data, ["name", "age", "department", "salary"] ) print("Raw DataFrame:") df.show() # ------------------------------------------------------- # BASIC: Two condition Level column # ------------------------------------------------------- df_with_level = df.withColumn( "level", F.when(F.col("salary") >= 30000, "L1") .otherwise("L2") ) print("After adding Level column:") df_with_level.show() # ------------------------------------------------------- # MULTIPLE CONDITIONS: Grade column # ------------------------------------------------------- df_with_grade = df.withColumn( "grade", F.when(F.col("salary") > 50000, "A") .when(F.col("salary") >= 30000, "B") .otherwise("C") ) print("After adding Grade column:") df_with_grade.show() # ------------------------------------------------------- # HANDLING NULL SALARY EXPLICITLY # ------------------------------------------------------- df_with_grade_null_safe = df.withColumn( "grade", F.when(F.col("salary").isNull(), "Unknown") .when(F.col("salary") > 50000, "A") .when(F.col("salary") >= 30000, "B") .otherwise("C") ) print("After handling NULL salary:") df_with_grade_null_safe.show() # ------------------------------------------------------- # COMBINED: Add both Level and Grade in one select # ------------------------------------------------------- df_final = df.select( "name", "age", "department", "salary", F.when(F.col("salary").isNull(), "Unknown") .when(F.col("salary") >= 30000, "L1") .otherwise("L2") .alias("level"), F.when(F.col("salary").isNull(), "Unknown") .when(F.col("salary") > 50000, "A") .when(F.col("salary") >= 30000, "B") .otherwise("C") .alias("grade") ) print("Final DataFrame with both columns:") df_final.show() # ------------------------------------------------------- # NUMERIC OUTPUT: Bonus percentage based on grade # ------------------------------------------------------- df_with_bonus = df.withColumn( "bonus_pct", F.when(F.col("salary").isNull(), F.lit(0.0)) .when(F.col("salary") > 50000, F.lit(0.20)) .when(F.col("salary") >= 30000, F.lit(0.10)) .otherwise(F.lit(0.05)) ).withColumn( "bonus_amount", F.round(F.col("salary") * F.col("bonus_pct"), 2) ) print("After adding bonus columns:") df_with_bonus.show() # ------------------------------------------------------- # CONDITION ON MULTIPLE COLUMNS # ------------------------------------------------------- df_with_designation = df.withColumn( "designation", F.when( (F.col("salary") > 50000) & (F.col("department") == "Finance"), "Senior Finance Manager" ).when( (F.col("salary") > 50000) & (F.col("department") == "IT"), "Senior Tech Lead" ).when( F.col("salary") >= 30000, "Mid Level" ).otherwise("Junior") ) print("After adding designation based on multiple columns:") df_with_designation.show() # ------------------------------------------------------- # SPARK SQL EQUIVALENT # ------------------------------------------------------- df.createOrReplaceTempView("employees") df_sql = spark.sql(""" SELECT name, age, department, salary, CASE WHEN salary IS NULL THEN 'Unknown' WHEN salary >= 30000 THEN 'L1' ELSE 'L2' END AS level, CASE WHEN salary IS NULL THEN 'Unknown' WHEN salary > 50000 THEN 'A' WHEN salary >= 30000 THEN 'B' ELSE 'C' END AS grade FROM employees """) print("SQL equivalent result:") df_sql.show()
What to say: The when and otherwise pattern in PySpark maps directly to CASE WHEN THEN ELSE END in SQL, so anyone familiar with SQL can read it immediately. The conditions are evaluated top to bottom and the first matching condition wins, exactly like SQL. I always handle NULL explicitly as the first condition rather than relying on it falling through to otherwise, because NULL behaviour in conditional logic is a common source of silent bugs that produce wrong classifications without any error.
Expected Output
| Name | Age | Department | Salary | Level | Grade |
|---|---|---|---|---|---|
| Karan | 32 | Finance | 55000 | L1 | A |
| Asha | 28 | IT | 28000 | L2 | C |
| Ravi | 35 | Finance | 42000 | L1 | B |
| Meena | 29 | HR | 31000 | L1 | B |
| Sunil | 41 | IT | 62000 | L1 | A |
| Divya | 26 | HR | 25000 | L2 | C |
| Prakash | 38 | Finance | null | Unknown | Unknown |
### Follow-up Questions & Answers
**Q1. What happens if you do not include otherwise at the end of a when chain?**
If you omit otherwise, any row that does not match any of the when conditions gets NULL in the resulting column. This is equivalent to not having an ELSE clause in a SQL CASE statement. It is not an error, Spark will happily produce a column with NULL values for unmatched rows, which can cause problems downstream if that column is used in aggregations or joins that do not expect NULLs.
```python
# Without otherwise: unmatched rows get NULL
df.withColumn(
"grade",
F.when(F.col("salary") > 50000, "A")
.when(F.col("salary") >= 30000, "B")
# no otherwise: salary below 30000 gets NULL
).show()
# With otherwise: no row is ever NULL from the condition itself
df.withColumn(
"grade",
F.when(F.col("salary") > 50000, "A")
.when(F.col("salary") >= 30000, "B")
.otherwise("C") # explicit default for all remaining rows
).show()
What to say: I always include otherwise explicitly in production code rather than relying on the implicit NULL fallback, because a NULL in a classification column is almost never the intended business result. If a row genuinely should produce an Unknown value, I write otherwise Unknown explicitly so the intent is clear to anyone reading the code. A NULL appearing unexpectedly in a dimension column can silently break dashboards and reports that group by or filter on that column.
Q2. How would you use when/otherwise to replace fillna for more complex null filling logic?
fillna is convenient for filling all nulls in a column with a single fixed value. when/otherwise is more powerful when the replacement value depends on another column or on a condition.
# fillna: simple fixed value replacement df.fillna({"salary": 0, "department": "Unknown"}) # when/otherwise: conditional replacement based on other columns df.withColumn( "salary_filled", F.when( F.col("salary").isNull() & (F.col("department") == "Finance"), F.lit(45000) # default for Finance ).when( F.col("salary").isNull() & (F.col("department") == "IT"), F.lit(55000) # default for IT ).when( F.col("salary").isNull(), F.lit(30000) # generic default for all other departments ).otherwise(F.col("salary")) )
What to say: The key difference is that when/otherwise can reference other columns to determine the replacement value, while fillna only takes scalar constants. For department-specific defaults, age-based adjustments, or any logic where the right fill value depends on the row's other attributes, when/otherwise is the only tool that works. I also tend to prefer when/otherwise over fillna in production code because the intent is more explicit and easier to audit during a code review.
Q3. How would you compute the average salary per department and then add a column flagging employees who earn above their department average?
This combines a window function with a when/otherwise to create a contextual flag.
from pyspark.sql.window import Window # Compute department average using a window function w_dept = Window.partitionBy("department") df_with_flag = df.withColumn( "dept_avg_salary", F.avg("salary").over(w_dept) ).withColumn( "above_avg_flag", F.when( F.col("salary") > F.col("dept_avg_salary"), "Above Average" ).when( F.col("salary") == F.col("dept_avg_salary"), "At Average" ).otherwise("Below Average") ) df_with_flag.show()
What to say: This is the pattern of compute first using a window function then classify using when/otherwise, which comes up constantly in real reporting work. The window function attaches the department average to every row without collapsing the DataFrame, and then when/otherwise uses both the individual salary and the group average in the same condition. You cannot do this with a simple groupBy because groupBy loses the individual row detail.
Q4. What is the difference between when/otherwise and iif or ternary expressions in other languages?
when/otherwise in PySpark is a Column expression, meaning it can be used anywhere a Column is expected, inside select, withColumn, filter, agg, and even nested inside other when expressions. It is not a Python level ternary expression, it operates on entire columns in a distributed, JVM-optimised way. Python ternary expressions like value_if_true if condition else value_if_false operate on single Python values and cannot be applied directly to Spark Column objects.
# WRONG: Python ternary on a Column object does not work # This would throw an error or give wrong results df.withColumn("level", "L1" if F.col("salary") >= 30000 else "L2") # CORRECT: PySpark when/otherwise operates on the full column df.withColumn("level", F.when(F.col("salary") >= 30000, "L1").otherwise("L2") ) # when/otherwise can be nested df.withColumn("complex_flag", F.when( F.col("salary") > 50000, F.when(F.col("department") == "Finance", "Senior Finance") .otherwise("Senior Other") ).otherwise("Junior") )
Q5. How would you write this using Spark SQL and a CASE statement, and when would you prefer SQL over the DataFrame API?
df.createOrReplaceTempView("employees") result = spark.sql(""" SELECT name, department, salary, CASE WHEN salary IS NULL THEN 'Unknown' WHEN salary > 50000 THEN 'A' WHEN salary >= 30000 THEN 'B' ELSE 'C' END AS grade, CASE WHEN salary > AVG(salary) OVER (PARTITION BY department) THEN 'Above Average' ELSE 'Below Average' END AS dept_comparison FROM employees """) result.show()
What to say: I prefer the Spark SQL approach when the team has strong SQL backgrounds and the logic involves complex CASE expressions that read more naturally in SQL, or when the transformation needs to be shared with data analysts who are more comfortable with SQL than Python. I prefer the DataFrame API when the logic needs to be generated programmatically, for example building different conditions based on configuration parameters, since Python makes it easier to construct Column expressions dynamically than to build SQL strings. Both approaches compile to the same physical plan through Catalyst, so there is no performance difference between them.
Date Formatting, Type Casting, and Data Type Conversions
The Interviewer Says
In real pipelines data almost never arrives in the right format or type. Dates come as strings, amounts come as strings with currency symbols, integers are stored as decimals, and you need to handle all of this before the data is useful. Walk me through how you handle date formatting, type casting from string to numeric types, and the common data type conversions you would use in PySpark. Also tell me what the PySpark equivalent of SQL VARCHAR is.
How to Handle This in the Interview
Before writing anything, say: Type casting is one of the most fundamental data cleaning operations and also one of the most common sources of silent bugs in pipelines. When you cast a string to an integer and the string contains a non-numeric character, Spark does not throw an error by default, it silently returns NULL. Knowing this behaviour and handling it explicitly is what separates a careful data engineer from one who ships pipelines with silent data loss.
Step 1: Know PySpark data types and their SQL equivalents
- Say: In PySpark, StringType is the equivalent of VARCHAR in SQL. Unlike SQL VARCHAR which takes a length parameter like VARCHAR 100, PySpark StringType has no length limit, it simply holds any string of any length. If you need length validation you have to apply it explicitly as a filter or UDF.
Step 2: Know the casting functions
- cast is the primary method for converting column types. to_date, to_timestamp, date_format are the main date functions. regexp_replace is useful for cleaning strings before casting.
Step 3: Know date format patterns
- Java date format patterns are used in PySpark, yyyy for four digit year, MM for two digit month, dd for two digit day, HH for 24 hour hour, mm for minutes, ss for seconds.
Step 4: Mention try_cast for safe casting
- In Spark 3.2 and above, try_cast returns NULL instead of throwing an error for invalid cast attempts, which is safer than cast for untrusted data.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import ( StringType, IntegerType, LongType, DoubleType, DecimalType, DateType, TimestampType, BooleanType, FloatType ) spark = SparkSession.builder \ .appName("DataTypeConversions") \ .getOrCreate() # ------------------------------------------------------- # PYSPARK DATA TYPES vs SQL EQUIVALENTS # ------------------------------------------------------- ''' StringType() = VARCHAR / NVARCHAR / TEXT in SQL No length limit in PySpark To enforce length: use filter or UDF IntegerType() = INT in SQL (32-bit, -2.1B to 2.1B) LongType() = BIGINT in SQL (64-bit, much larger range) DoubleType() = FLOAT / DOUBLE in SQL (64-bit floating point) FloatType() = REAL in SQL (32-bit floating point) DecimalType(p,s) = DECIMAL(p,s) in SQL (exact precision) BooleanType() = BIT / BOOLEAN in SQL DateType() = DATE in SQL TimestampType()= DATETIME / TIMESTAMP in SQL ''' # ------------------------------------------------------- # PART 1: Creating DataFrame with string columns for conversion demo # ------------------------------------------------------- raw_data = [ ("C01", "Karan", "55000", "2024-01-15", "North", "1"), ("C02", "Asha", "28000.50", "15/02/2024", "South", "0"), ("C03", "Ravi", "42,000", "2024-03-10 09:30:00", "North", "1"), ("C04", "Meena", "31000", "10-Apr-2024", "South", "0"), ("C05", "Sunil", "ABC", "2024-05-01", "North", "1"), ("C06", "Divya", None, None, "South", "0"), ] df = spark.createDataFrame( raw_data, ["customer_id", "name", "salary_str", "date_str", "region", "is_active_str"] ) print("Raw DataFrame (all strings):") df.show(truncate=False) df.printSchema() # ------------------------------------------------------- # PART 2: String to Numeric Casting # ------------------------------------------------------- # Simple cast: works for clean numeric strings # Returns NULL silently for non-numeric strings df_cast = df.withColumn( "salary_int", F.col("salary_str").cast(IntegerType()) ).withColumn( "salary_double", F.col("salary_str").cast(DoubleType()) ) print("After simple cast (note NULL for ABC and comma format):") df_cast.select("customer_id", "salary_str", "salary_int", "salary_double").show() # Safe cast for untrusted data (Spark 3.2+) # Same as cast but explicit about returning NULL for failures df_try_cast = df.withColumn( "salary_safe", F.expr("try_cast(salary_str as INT)") ) # Clean string before casting: remove commas, currency symbols df_cleaned = df.withColumn( "salary_cleaned", F.regexp_replace(F.col("salary_str"), "[,₹$]", "") ).withColumn( "salary_decimal", F.col("salary_cleaned").cast(DecimalType(10, 2)) ) print("After cleaning and casting:") df_cleaned.select( "customer_id", "salary_str", "salary_cleaned", "salary_decimal" ).show() # Detect rows where cast failed (NULL in result but not NULL in source) df_cast_failures = df_cleaned.filter( F.col("salary_decimal").isNull() & F.col("salary_str").isNotNull() ) print("Rows where cast failed:") df_cast_failures.show() # ------------------------------------------------------- # PART 3: String to Boolean # ------------------------------------------------------- df_bool = df.withColumn( "is_active", F.col("is_active_str").cast(BooleanType()) ).withColumn( "is_active_v2", F.when(F.col("is_active_str") == "1", True) .when(F.col("is_active_str") == "0", False) .otherwise(None) ) print("After boolean conversion:") df_bool.select("customer_id", "is_active_str", "is_active", "is_active_v2").show() # ------------------------------------------------------- # PART 4: Date Formatting and Conversion # ------------------------------------------------------- # to_date: converts string to DateType using a format pattern # Java date format: yyyy=year, MM=month, dd=day df_dates = df.withColumn( "date_standard", F.coalesce( F.to_date(F.col("date_str"), "yyyy-MM-dd"), # 2024-01-15 F.to_date(F.col("date_str"), "dd/MM/yyyy"), # 15/02/2024 F.to_date(F.col("date_str"), "dd-MMM-yyyy") # 10-Apr-2024 ) ).withColumn( "date_with_time", F.to_timestamp(F.col("date_str"), "yyyy-MM-dd HH:mm:ss") ) print("After date conversion:") df_dates.select( "customer_id", "date_str", "date_standard", "date_with_time" ).show(truncate=False) # ------------------------------------------------------- # PART 5: Date Formatting (DateType back to String) # ------------------------------------------------------- df_formatted = df_dates.withColumn( "date_display", F.date_format(F.col("date_standard"), "dd-MMM-yyyy") # 15-Jan-2024 ).withColumn( "date_yyyymm", F.date_format(F.col("date_standard"), "yyyyMM") # 202401 ).withColumn( "date_month_name", F.date_format(F.col("date_standard"), "MMMM yyyy") # January 2024 ) print("After date formatting:") df_formatted.select( "customer_id", "date_standard", "date_display", "date_yyyymm", "date_month_name" ).show(truncate=False) # ------------------------------------------------------- # PART 6: Date Arithmetic # ------------------------------------------------------- from datetime import date df_date_calc = df_dates.withColumn( "days_since_date", F.datediff(F.current_date(), F.col("date_standard")) ).withColumn( "date_plus_30", F.date_add(F.col("date_standard"), 30) ).withColumn( "date_minus_7", F.date_sub(F.col("date_standard"), 7) ).withColumn( "month_of_date", F.month(F.col("date_standard")) ).withColumn( "year_of_date", F.year(F.col("date_standard")) ).withColumn( "day_of_week", F.dayofweek(F.col("date_standard")) ) print("After date arithmetic:") df_date_calc.select( "customer_id", "date_standard", "days_since_date", "date_plus_30", "month_of_date", "year_of_date" ).show() # ------------------------------------------------------- # PART 7: StringType vs VARCHAR length enforcement # ------------------------------------------------------- # PySpark StringType has NO length limit unlike SQL VARCHAR(100) # To enforce a max length you must apply it manually df_varchar_like = df.withColumn( "name_validated", F.when( F.length(F.col("name")) <= 50, F.col("name") ).otherwise(F.substring(F.col("name"), 1, 50)) ).withColumn( "name_length", F.length(F.col("name")) ) print("After length validation:") df_varchar_like.select("customer_id", "name", "name_length", "name_validated").show() # ------------------------------------------------------- # PART 8: Complete type safe DataFrame from raw strings # ------------------------------------------------------- df_typed = df.select( F.col("customer_id").cast(StringType()), F.col("name").cast(StringType()), F.regexp_replace(F.col("salary_str"), "[,]", "") .cast(DecimalType(12, 2)).alias("salary"), F.coalesce( F.to_date(F.col("date_str"), "yyyy-MM-dd"), F.to_date(F.col("date_str"), "dd/MM/yyyy"), F.to_date(F.col("date_str"), "dd-MMM-yyyy") ).alias("transaction_date"), F.col("region").cast(StringType()), F.col("is_active_str").cast(BooleanType()).alias("is_active") ) print("Final typed DataFrame:") df_typed.show() df_typed.printSchema()
What to say: The most important habit I have with type casting is always checking for NULL values that appear in the cast result but were not NULL in the source. This tells me exactly which rows failed the cast, which I then investigate, log, and either fix or quarantine rather than letting them silently propagate as NULLs into downstream tables. A pipeline that silently drops data is far more dangerous than one that fails loudly, because silent data loss goes undetected and corrupts downstream reports.
Expected Output — Schema Before and After
Before (all StringType): root |-- customer_id: string |-- name: string |-- salary_str: string |-- date_str: string |-- region: string |-- is_active_str: string After (typed): root |-- customer_id: string |-- name: string |-- salary: decimal(12,2) |-- transaction_date: date |-- region: string |-- is_active: boolean
Follow-up Questions & Answers
Q1. What is the difference between StringType in PySpark and VARCHAR in SQL?
In SQL, VARCHAR takes a length parameter like VARCHAR(100) which enforces that no stored value can exceed one hundred characters. The database engine automatically rejects or truncates any insert that exceeds this limit. PySpark StringType has no length parameter at all. It stores any string of any length without any validation or truncation. If you need to enforce a maximum length in PySpark you must do it explicitly using F.length to check and either filter out violating rows, truncate them using F.substring, or raise an error using a custom validation step. This is worth mentioning in interviews because it shows you understand the difference between a schema constraint enforced by the engine versus a validation you have to implement yourself.
Q2. What happens when you cast a non-numeric string to IntegerType in PySpark?
Spark returns NULL for the failed cast row without throwing any error or warning. This is called a permissive cast and it is the default behaviour. A string value like ABC cast to IntegerType produces NULL. A string like 42.5 cast to IntegerType also produces NULL since it is not a valid integer string even though it is a valid number.
# Demonstrating silent cast failure data = [("123",), ("ABC",), ("42.5",), (None,)] df_test = spark.createDataFrame(data, ["value"]) df_test.withColumn("as_int", F.col("value").cast(IntegerType())).show() # Output: # +-----+------+ # |value|as_int| # +-----+------+ # |123 |123 | <- success # |ABC |null | <- silent failure # |42.5 |null | <- silent failure # |null |null | <- null in null out # +-----+------+ # Always detect cast failures before proceeding df_failures = df_test.withColumn("as_int", F.col("value").cast(IntegerType())) \ .filter(F.col("as_int").isNull() & F.col("value").isNotNull()) print("Cast failures:") df_failures.show()
What to say: This silent NULL behaviour is one of the most dangerous aspects of casting in PySpark because the job completes successfully, no error is raised, and the data is silently wrong. In a sales pipeline, if ten percent of amount values fail to cast because they contain currency symbols, the sum of sales would be understated by ten percent with no indication anything went wrong. I always add a cast failure check immediately after any cast operation in production pipelines and log or quarantine the failed rows.
Q3. How do you handle multiple date formats in the same column, since real data often has inconsistent date formats?
# Use coalesce with multiple to_date attempts # coalesce returns the first non-null result df_multi_format = df.withColumn( "parsed_date", F.coalesce( F.to_date(F.col("date_str"), "yyyy-MM-dd"), # 2024-01-15 F.to_date(F.col("date_str"), "dd/MM/yyyy"), # 15/01/2024 F.to_date(F.col("date_str"), "MM-dd-yyyy"), # 01-15-2024 F.to_date(F.col("date_str"), "dd-MMM-yyyy"), # 15-Jan-2024 F.to_date(F.col("date_str"), "yyyy-MM-dd HH:mm:ss") # 2024-01-15 09:00:00 ) ) # Any date_str that does not match any of these formats gets NULL # Detect and log them df_unparsed = df_multi_format.filter( F.col("parsed_date").isNull() & F.col("date_str").isNotNull() ) print("Dates that could not be parsed:") df_unparsed.show()
What to say: The coalesce approach is clean and readable but the order of formats matters since coalesce returns the first non-null result. If a date string is 01-02-2024, it could match both dd-MM-yyyy meaning the 1st of February and MM-dd-yyyy meaning the 2nd of January. The format listed first in the coalesce wins, so I always order formats from most specific to least specific and confirm the ordering with the data team based on the actual source system.
Q4. What is the difference between DecimalType and DoubleType, and when would you use each?
DoubleType is a 64-bit IEEE 754 floating point number. It can represent very large or very small numbers but is subject to floating point precision errors, for example 0.1 plus 0.2 does not equal exactly 0.3 in floating point arithmetic. DecimalType takes two parameters, precision and scale, where precision is the total number of significant digits and scale is the number of digits after the decimal point. DecimalType stores exact decimal values with no floating point rounding errors.
# DoubleType: fast, approximate, floating point errors possible df.withColumn("amount_double", F.col("salary_str").cast(DoubleType())) # DecimalType(10, 2): exact, up to 10 digits total, 2 after decimal # Perfect for financial amounts where precision is critical df.withColumn("amount_decimal", F.col("salary_str").cast(DecimalType(10, 2))) # Demonstrating floating point error with DoubleType spark.createDataFrame([(0.1, 0.2)], ["a", "b"]) \ .withColumn("sum", F.col("a") + F.col("b")) \ .show() # Shows 0.30000000000000004, not 0.3
What to say: For any financial or monetary column I always use DecimalType rather than DoubleType because the precision matters exactly. A difference of a fraction of a penny compounded across millions of transactions produces meaningful errors in financial reconciliation. I typically use DecimalType 18 comma 2 for currency amounts, which gives up to sixteen digits before the decimal point and exactly two after, covering virtually any real world monetary value.
Q5. How would you convert a Unix timestamp stored as a long integer into a readable datetime in PySpark?
Unix timestamps are a common format in event driven systems and Kafka streams where timestamps are stored as the number of seconds or milliseconds since January 1 1970.
data = [ ("E01", 1704067200), # seconds since epoch ("E02", 1706745600), ("E03", 1709251200), ] df_unix = spark.createDataFrame(data, ["event_id", "unix_ts"]) # Convert seconds since epoch to timestamp df_unix.withColumn( "event_time", F.from_unixtime(F.col("unix_ts")) # default format yyyy-MM-dd HH:mm:ss ).withColumn( "event_date", F.to_date(F.from_unixtime(F.col("unix_ts"))) ).withColumn( "event_time_formatted", F.from_unixtime(F.col("unix_ts"), "dd-MMM-yyyy HH:mm:ss") ).show(truncate=False) # If the timestamp is in milliseconds divide by 1000 first df_unix.withColumn( "event_time_from_ms", F.from_unixtime(F.col("unix_ts") / 1000) ).show() # Convert datetime back to Unix timestamp df_unix.withColumn( "back_to_unix", F.unix_timestamp(F.from_unixtime(F.col("unix_ts"))) ).show()
What to say: Unix timestamps in milliseconds versus seconds is a very common gotcha in Kafka based pipelines. Kafka stores event timestamps in milliseconds by default, so if you apply from_unixtime directly to a Kafka timestamp without dividing by 1000, you get a date far in the future rather than the actual event time. I always confirm with the source team whether the timestamp is in seconds or milliseconds before writing the conversion, since both look like plausible long integers and the error does not cause any exception.
Removing Duplicates in PySpark
The Interviewer Says
Duplicate records are one of the most common data quality problems in real pipelines. I have a customer transactions DataFrame with several types of duplicates. Some rows are completely identical across all columns. Others share the same transaction ID but have different timestamps due to retry logic. Walk me through all the ways you would handle duplicates in PySpark and how you would decide which record to keep.
How to Handle This in the Interview
Before writing anything, say: Deduplication in PySpark requires me to first clarify what a duplicate actually means in the business context, because the answer changes the approach completely. Exact duplicates across all columns are handled differently from logical duplicates where one key column repeats but other columns differ.
Step 1: Understand the two types of duplicates
- Exact duplicates mean every column value is identical across multiple rows. dropDuplicates or distinct handles these. Logical duplicates mean the key column repeats but other columns may differ, here you need to decide which version to keep using a window function with ROW_NUMBER.
Step 2: Know distinct vs dropDuplicates
- distinct removes rows that are completely identical across all columns. dropDuplicates lets you specify which subset of columns define a duplicate, keeping the first occurrence of each unique combination.
Step 3: Use window functions for keep latest or keep earliest
- When logical duplicates exist and you need to keep the most recent or earliest record, ROW_NUMBER with a window ordered by the timestamp column gives you precise control over which row survives.
Step 4: Always quantify before removing
- Say: Before removing any duplicates I always run a count to understand how many exist and which columns are causing them. Removing duplicates without understanding them first is risky.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.window import Window spark = SparkSession.builder \ .appName("DeduplicationDemo") \ .getOrCreate() data = [ # Exact duplicates (T01 appears 3 times identically) ("T01", "C01", "Laptop", 75000, "2024-01-05 09:00:00"), ("T01", "C01", "Laptop", 75000, "2024-01-05 09:00:00"), ("T01", "C01", "Laptop", 75000, "2024-01-05 09:00:00"), # Logical duplicates: same txn_id different timestamps (retry pattern) ("T02", "C02", "Phone", 25000, "2024-01-06 10:00:00"), ("T02", "C02", "Phone", 25000, "2024-01-06 10:05:00"), ("T02", "C02", "Phone", 25000, "2024-01-06 10:10:00"), # Clean records: no duplicates ("T03", "C03", "Tablet", 30000, "2024-01-07 11:00:00"), ("T04", "C04", "Monitor", 15000, "2024-01-08 12:00:00"), # Customer level duplicate: same customer different products ("T05", "C01", "Mouse", 2000, "2024-01-09 09:00:00"), ] df = spark.createDataFrame( data, ["txn_id", "customer_id", "product", "amount", "txn_time"] ) print("Raw DataFrame:") df.show(truncate=False) print(f"Total rows: {df.count()}") # ------------------------------------------------------- # STEP 1: Quantify duplicates before removing # ------------------------------------------------------- print("Duplicate count analysis:") df.groupBy(df.columns).count() \ .filter(F.col("count") > 1) \ .show(truncate=False) # Count by txn_id to see logical duplicates print("Rows per txn_id:") df.groupBy("txn_id") \ .count() \ .orderBy(F.col("count").desc()) \ .show() # ------------------------------------------------------- # APPROACH 1: distinct() - removes exact duplicates across ALL columns # ------------------------------------------------------- df_distinct = df.distinct() print("After distinct() - removes only exact duplicates:") df_distinct.show(truncate=False) print(f"Rows after distinct: {df_distinct.count()}") # T01 becomes 1 row (exact duplicate removed) # T02 stays as 3 rows (different timestamps = not exact duplicates) # ------------------------------------------------------- # APPROACH 2: dropDuplicates() on all columns # Same as distinct() but more explicit # ------------------------------------------------------- df_drop_all = df.dropDuplicates() print("After dropDuplicates() (same as distinct):") df_drop_all.show(truncate=False) # ------------------------------------------------------- # APPROACH 3: dropDuplicates on specific columns # Keeps first occurrence of each unique combination # "First" is non-deterministic without an explicit sort # ------------------------------------------------------- df_drop_txn = df.dropDuplicates(["txn_id"]) print("After dropDuplicates(['txn_id']) - keeps first txn_id occurrence:") df_drop_txn.show(truncate=False) print(f"Rows after dropDuplicates on txn_id: {df_drop_txn.count()}") df_drop_customer = df.dropDuplicates(["customer_id", "product"]) print("After dropDuplicates on customer_id + product:") df_drop_customer.show(truncate=False) # ------------------------------------------------------- # APPROACH 4: Window function - keep LATEST record per txn_id # Deterministic: explicitly choose which row survives # ------------------------------------------------------- w_latest = Window.partitionBy("txn_id").orderBy(F.col("txn_time").desc()) df_keep_latest = df.withColumn( "row_num", F.row_number().over(w_latest) ).filter(F.col("row_num") == 1) \ .drop("row_num") print("After keeping LATEST record per txn_id:") df_keep_latest.show(truncate=False) print(f"Rows after keeping latest: {df_keep_latest.count()}") # ------------------------------------------------------- # APPROACH 5: Window function - keep EARLIEST record per txn_id # ------------------------------------------------------- w_earliest = Window.partitionBy("txn_id").orderBy(F.col("txn_time").asc()) df_keep_earliest = df.withColumn( "row_num", F.row_number().over(w_earliest) ).filter(F.col("row_num") == 1) \ .drop("row_num") print("After keeping EARLIEST record per txn_id:") df_keep_earliest.show(truncate=False) # ------------------------------------------------------- # APPROACH 6: Writing deduplicated data back to Delta # using MERGE to handle upserts safely # ------------------------------------------------------- df_keep_latest.write \ .format("delta") \ .mode("overwrite") \ .save("/output/transactions_deduped") print("Deduplicated data written to Delta table") # ------------------------------------------------------- # APPROACH 7: Identify and separate duplicates for audit # ------------------------------------------------------- w_audit = Window.partitionBy("txn_id").orderBy(F.col("txn_time").desc()) df_with_rank = df.withColumn( "row_num", F.row_number().over(w_audit) ) df_survivors = df_with_rank.filter(F.col("row_num") == 1).drop("row_num") df_duplicates = df_with_rank.filter(F.col("row_num") > 1).drop("row_num") print("Surviving records:") df_survivors.show(truncate=False) print("Duplicate records (for audit log):") df_duplicates.show(truncate=False) df_duplicates.write \ .mode("overwrite") \ .parquet("/output/duplicate_audit_log")
What to say: The choice between distinct, dropDuplicates, and the window function approach depends entirely on what the business wants to keep. distinct and dropDuplicates are fine for exact duplicates but are non-deterministic when applied to a subset of columns since the first occurrence they keep is not guaranteed to be the same across multiple runs without a prior sort. The window function approach with ROW_NUMBER is the most robust because it explicitly defines which row wins based on a business rule like keep latest or keep earliest, making the result fully deterministic and auditable.
Expected Output
- After keeping LATEST record per txn_id:
| Txn ID | Customer ID | Product | Amount | Txn Time |
|---|---|---|---|---|
| T01 | C01 | Laptop | 75000 | 2024-01-05 09:00:00 |
| T02 | C02 | Phone | 25000 | 2024-01-06 10:10:00 |
| T03 | C03 | Tablet | 30000 | 2024-01-07 11:00:00 |
| T04 | C04 | Monitor | 15000 | 2024-01-08 12:00:00 |
| T05 | C01 | Mouse | 2000 | 2024-01-09 09:00:00 |
Follow-up Questions & Answers
Q1. What is the difference between distinct and dropDuplicates in PySpark?
distinct removes rows that are completely identical across every single column in the DataFrame. It is equivalent to SELECT DISTINCT star in SQL. dropDuplicates without any arguments does exactly the same thing as distinct. The difference is that dropDuplicates also accepts a list of column names, in which case it identifies duplicates based only on those specific columns and keeps the first occurrence of each unique combination of those columns, while ignoring the values in all other columns. So distinct is a special case of dropDuplicates with no column subset specified.
# These two are identical df.distinct() df.dropDuplicates() # This is only possible with dropDuplicates # removes rows with duplicate txn_id, keeps first occurrence df.dropDuplicates(["txn_id"]) # distinct() does not accept column arguments # df.distinct(["txn_id"]) # This throws an error
Q2. Why is dropDuplicates on a column subset non-deterministic and how do you make it deterministic?
When you call dropDuplicates on a subset of columns like txn_id, Spark keeps the first occurrence of each unique txn_id value. But first is defined by the physical ordering of rows within Spark's distributed partitions, which can vary between runs depending on how data was read, partitioned, and shuffled. Two runs of the same job on the same data might keep different rows for a given txn_id if the partition layout changes between runs.
# Non-deterministic: which T02 row is kept is unpredictable df.dropDuplicates(["txn_id"]).show() # Deterministic: explicitly sort before dropping # Now the first row seen by Spark is always the latest txn_time df.orderBy(F.col("txn_time").desc()) \ .dropDuplicates(["txn_id"]) \ .show() # Even more deterministic: use window ROW_NUMBER # This is the production-grade approach w = Window.partitionBy("txn_id").orderBy(F.col("txn_time").desc()) df.withColumn("rn", F.row_number().over(w)) \ .filter(F.col("rn") == 1) \ .drop("rn") \ .show()
What to say: In production pipelines I never rely on orderBy followed by dropDuplicates because the sort order is not guaranteed to be preserved across the distributed partition boundaries in all cases. The window function with ROW_NUMBER is the only approach that is truly deterministic because it explicitly assigns a rank to every row within each group before filtering, and the ranking is always based on the explicitly defined ordering regardless of how the data is physically distributed.
Q3. How would you remove duplicates from a DataFrame that is partitioned across hundreds of files in a Delta table without reading all data into memory?
For large Delta tables the most efficient deduplication approach avoids a full table scan by using Delta MERGE to process only new or changed data.
from delta.tables import DeltaTable # Load existing Delta table target_table = DeltaTable.forPath(spark, "/output/transactions_delta") # New batch of data that may contain duplicates new_data = spark.read.parquet("/input/new_transactions") # Deduplicate within the new batch first w = Window.partitionBy("txn_id").orderBy(F.col("txn_time").desc()) new_data_deduped = new_data.withColumn("rn", F.row_number().over(w)) \ .filter(F.col("rn") == 1) \ .drop("rn") # MERGE: upsert into target, handling cross-batch duplicates target_table.alias("target").merge( new_data_deduped.alias("source"), "target.txn_id = source.txn_id" ).whenMatchedUpdateAll() \ .whenNotMatchedInsertAll() \ .execute()
What to say: The two step approach, deduplicate within the incoming batch first using a window function, then merge into the target table using Delta MERGE, is the standard production pattern for handling duplicates in an incremental load pipeline. The within-batch deduplication ensures we do not try to merge two rows with the same key in a single operation, which Delta MERGE does not support. The MERGE itself handles cross-batch deduplication by updating existing records if they already exist in the target.
Q4. How would you find and count all duplicate groups to understand the scale of the problem before removing anything?
# Count rows per key to find duplicates duplicate_summary = df.groupBy("txn_id") \ .count() \ .withColumnRenamed("count", "occurrences") \ .filter(F.col("occurrences") > 1) \ .orderBy(F.col("occurrences").desc()) print("Duplicate summary:") duplicate_summary.show() total_duplicate_keys = duplicate_summary.count() total_duplicate_rows = duplicate_summary \ .agg(F.sum(F.col("occurrences") - 1).alias("excess_rows")) \ .collect()[0]["excess_rows"] print(f"Total txn_ids with duplicates: {total_duplicate_keys}") print(f"Total excess rows to remove: {total_duplicate_rows}") # Percentage of data that is duplicated total_rows = df.count() print(f"Duplication rate: {total_duplicate_rows / total_rows * 100:.2f}%")
What to say: Running this diagnostic before any deletion gives me three important numbers, how many unique keys have duplicates, how many total excess rows will be removed, and what percentage of the total data is duplicated. A five percent duplication rate on a table with a hundred million rows means five million rows being removed, which is significant and worth flagging to the pipeline owner before proceeding. A fifty percent duplication rate might indicate a systemic bug in the upstream system that needs to be fixed at the source rather than just cleaned up repeatedly at the ingestion layer.
Q5. How would you handle deduplication when the same logical event needs to be deduplicated across different days of data in a date-partitioned table?
Cross-partition deduplication is expensive because it requires reading multiple partitions together. The most efficient approach is to maintain a lookup table of seen keys.
# Maintain a seen_keys table with the latest txn_time per txn_id seen_keys = spark.read.parquet("/metadata/seen_txn_ids") # Load new batch new_batch = spark.read.parquet(f"/input/transactions/date={today}") # Left anti join to find only genuinely new transactions truly_new = new_batch.join( seen_keys, on="txn_id", how="left_anti" ) # Deduplicate within the truly new batch w = Window.partitionBy("txn_id").orderBy(F.col("txn_time").desc()) truly_new_deduped = truly_new \ .withColumn("rn", F.row_number().over(w)) \ .filter(F.col("rn") == 1) \ .drop("rn") # Write deduplicated new records truly_new_deduped.write.mode("append").parquet("/output/transactions_clean") # Update seen_keys with newly processed txn_ids updated_seen_keys = seen_keys.union( truly_new_deduped.select("txn_id", "txn_time") ).dropDuplicates(["txn_id"]) updated_seen_keys.write.mode("overwrite").parquet("/metadata/seen_txn_ids")
What to say: The seen_keys pattern maintains a compact lookup table containing only the keys and timestamps of already processed records. On each new batch, a left anti join against this lookup filters out any transaction that was already processed in a previous batch. This avoids reading the entire historical table for deduplication, which would be prohibitively expensive on a large date-partitioned table. The seen_keys table itself only needs to store the key and timestamp columns, not the full record, keeping it small enough to broadcast as the join expands over time.
Word Frequency Analysis from a Text Column
The Interviewer Says
We have a customer feedback table where each row contains a free text feedback comment. The product team wants to know the most frequently mentioned words across all feedback to understand what customers care about most. Write PySpark code to find the top ten most frequently repeating words in the feedback column, excluding common stop words like the, is, a, and, not. Walk me through your approach.
How to Handle This in the Interview
Before writing anything, say: This is essentially the classic word count problem but applied to a DataFrame column rather than a raw text file. The pattern is always the same: split the text into individual words, explode the resulting array into one row per word, clean the words, filter out stop words, then group and count.
Step 1: Identify the split-explode-count pattern
- Say: split takes a string and splits it into an array of substrings based on a delimiter or regex. explode takes an array column and turns each element into a separate row. After exploding, a standard groupBy and count gives the word frequency.
Step 2: Plan the text cleaning steps
- Real feedback text has punctuation, mixed case, extra spaces, and special characters. Before counting words you need to lowercase everything, remove punctuation, and trim whitespace. Otherwise Python and python are counted as different words.
Step 3: Define stop words
- Stop words are common words that carry no meaningful signal, like the, is, a, and, in. Filtering them out gives more meaningful results. Mention that there are libraries like NLTK that have comprehensive stop word lists, but for an interview a small hardcoded list works fine.
Step 4: Think about the output format
- Ask: Should I return just the word and count, or also the percentage of feedback rows that mention each word? The percentage gives a better sense of how widespread the concern is.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import ArrayType, StringType spark = SparkSession.builder \ .appName("WordFrequencyAnalysis") \ .getOrCreate() data = [ (1, "The product is great and delivery was fast"), (2, "Great product fast delivery loved it"), (3, "Not bad could be better product quality"), (4, "Product quality is poor and delivery was slow"), (5, "Fast delivery great packaging product arrived safe"), (6, "The quality of product is excellent great value"), (7, "Delivery was late product was damaged not good"), (8, "Amazing product great quality fast shipping"), (9, "Product broke after one week poor quality"), (10,"Great product and great customer service fast"), ] df = spark.createDataFrame(data, ["id", "feedback"]) print("Raw feedback DataFrame:") df.show(truncate=False) # ------------------------------------------------------- # STEP 1: Clean the text # ------------------------------------------------------- df_clean = df.withColumn( "feedback_clean", F.lower( # convert to lowercase F.regexp_replace( F.col("feedback"), "[^a-zA-Z\\s]", # remove all non-letter characters "" # replace with empty string ) ) ) print("After cleaning:") df_clean.select("id", "feedback_clean").show(truncate=False) # ------------------------------------------------------- # STEP 2: Split into words (array column) # ------------------------------------------------------- df_split = df_clean.withColumn( "words", F.split(F.col("feedback_clean"), "\\s+") # split on one or more spaces ) print("After splitting into word arrays:") df_split.select("id", "words").show(truncate=False) # ------------------------------------------------------- # STEP 3: Explode array into individual rows # ------------------------------------------------------- df_exploded = df_split.select( "id", F.explode("words").alias("word") ) print("After exploding (one row per word):") df_exploded.show(20, truncate=False) print(f"Total word occurrences: {df_exploded.count()}") # ------------------------------------------------------- # STEP 4: Remove empty strings and stop words # ------------------------------------------------------- stop_words = [ "the", "is", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "was", "it", "be", "not", "this", "that", "i", "my", "me", "we", "you", "he", "she", "after", "could", "one", "would", "been", "are" ] df_filtered = df_exploded.filter( (F.col("word") != "") & # remove empty strings (F.length(F.col("word")) > 1) & # remove single letters (~F.col("word").isin(stop_words)) # remove stop words ) print("After removing stop words:") df_filtered.show(20, truncate=False) # ------------------------------------------------------- # STEP 5: Count word frequencies # ------------------------------------------------------- word_counts = df_filtered.groupBy("word") \ .count() \ .withColumnRenamed("count", "frequency") \ .orderBy(F.col("frequency").desc()) print("All word frequencies:") word_counts.show(20, truncate=False) # ------------------------------------------------------- # STEP 6: Get top 10 most frequent words # ------------------------------------------------------- top_10 = word_counts.limit(10) print("Top 10 most frequent words:") top_10.show() # ------------------------------------------------------- # STEP 7: Add percentage of feedback rows mentioning each word # ------------------------------------------------------- total_feedback_rows = df.count() word_counts_with_pct = df_filtered \ .groupBy("word") \ .agg( F.count("*").alias("frequency"), F.countDistinct("id").alias("feedback_count") ) \ .withColumn( "pct_of_feedback", F.round(F.col("feedback_count") * 100.0 / total_feedback_rows, 1) ) \ .orderBy(F.col("frequency").desc()) print("Word frequency with percentage of feedback rows:") word_counts_with_pct.show(10, truncate=False) # ------------------------------------------------------- # STEP 8: Full pipeline as a single chain # ------------------------------------------------------- stop_words_broadcast = spark.sparkContext.broadcast(set(stop_words)) result = df \ .withColumn( "words", F.split( F.lower(F.regexp_replace(F.col("feedback"), "[^a-zA-Z\\s]", "")), "\\s+" ) ) \ .select("id", F.explode("words").alias("word")) \ .filter( (F.col("word") != "") & (F.length(F.col("word")) > 1) & (~F.col("word").isin(stop_words)) ) \ .groupBy("word") \ .agg( F.count("*").alias("frequency"), F.countDistinct("id").alias("feedback_rows") ) \ .orderBy(F.col("frequency").desc()) \ .limit(10) print("Final top 10 words (single chain):") result.show(truncate=False)
What to say: The split-explode-groupBy pattern is the standard approach for any kind of array to row transformation followed by aggregation in PySpark, not just word frequency. The same pattern applies to exploding a list of product tags, expanding a comma separated category column, or any situation where one cell contains multiple values that need to be counted individually. The cleaning step before splitting is critical in real data because mixed case, punctuation, and extra whitespace turn the same semantic word into multiple distinct tokens that get counted separately.
Expected Output
Top 10 most frequent words:
| Word | Frequency |
|---|---|
| product | 10 |
| great | 6 |
| quality | 5 |
| delivery | 5 |
| fast | 4 |
| poor | 2 |
| slow | 1 |
| damaged | 1 |
| safe | 1 |
| value | 1 |
Follow-up Questions & Answers
Q1. What is the difference between split and explode and why do you need both for this problem?
split is a string function that takes a single string value and returns an array of substrings by dividing the string wherever the specified delimiter or regex pattern matches. After split, each row still has one cell but that cell now contains an array of words rather than a single string. explode is a DataFrame function that takes an array column and generates one new row for each element in the array, duplicating all other columns from the original row. You need both because split gives you the array and explode gives you the one-row-per-word structure needed for a groupBy count. Without explode you would have arrays in cells which cannot be directly counted with groupBy. Without split you would be trying to explode a plain string, which is not an array type and would fail.
Q2. How would you handle this if the feedback column had NULL values in some rows?
NULL values in the feedback column would produce NULL arrays after split, and explode drops NULL arrays by default using the standard explode function, meaning those feedback rows disappear from the word count entirely. Using explode_outer would keep the NULL rows with a NULL word value, which then needs to be filtered out.
df_with_null = df.withColumn( "words", F.split( F.lower(F.regexp_replace(F.col("feedback"), "[^a-zA-Z\\s]", "")), "\\s+" ) ) # explode drops rows where words array is null or empty df_with_null.select("id", F.explode("words").alias("word")) # explode_outer keeps null array rows, need to filter null word after df_with_null.select("id", F.explode_outer("words").alias("word")) \ .filter(F.col("word").isNotNull() & (F.col("word") != ""))
What to say: In production I always use explode rather than explode_outer for the word frequency use case since a NULL feedback row contributes no words and should legitimately not affect the word count. But I would log how many feedback rows had NULL values separately so the product team knows what percentage of feedback could not be analysed, since a high NULL rate might indicate a data collection problem in the feedback form.
Q3. How would you make this case-insensitive and handle special characters in different languages?
# For standard ASCII English text df.withColumn("feedback_clean", F.lower(F.regexp_replace(F.col("feedback"), "[^a-zA-Z\\s]", "")) ) # For multilingual text including accented characters # keep unicode letters and spaces, remove everything else df.withColumn("feedback_clean", F.lower(F.regexp_replace(F.col("feedback"), "[^\\p{L}\\s]", "")) ) # \p{L} matches any Unicode letter from any language # This preserves é, ü, ñ, and characters from non-Latin scripts # For text with embedded HTML tags df.withColumn("feedback_clean", F.lower( F.regexp_replace( F.regexp_replace(F.col("feedback"), "<[^>]+>", " "), # remove HTML tags first "[^a-zA-Z\\s]", "" # then remove punctuation ) ) )
What to say: The regex pattern matters a lot for real feedback data. Using [^a-zA-Z\s] removes all non-ASCII characters, which works for English text but would incorrectly strip accented characters in French, Spanish, or German feedback and completely destroy text in languages like Hindi, Chinese, or Arabic. For multilingual pipelines I use \p{L} which is a Unicode property class matching any letter from any language, making the cleaning logic language agnostic.
Q4. How would you extend this to compute bigrams, meaning pairs of consecutive words, instead of single words?
Bigrams capture phrase level patterns, for example fast delivery and poor quality are more informative as pairs than their individual words alone.
from pyspark.sql.functions import array, slice as spark_slice # Generate bigrams from the word arrays df_bigrams = df \ .withColumn( "words", F.split( F.lower(F.regexp_replace(F.col("feedback"), "[^a-zA-Z\\s]", "")), "\\s+" ) ) \ .withColumn( "bigrams", F.expr(""" transform( sequence(0, size(words) - 2), i -> concat(words[i], ' ', words[i + 1]) ) """) ) \ .select("id", F.explode("bigrams").alias("bigram")) \ .filter(F.col("bigram").isNotNull() & (F.col("bigram") != "")) \ .groupBy("bigram") \ .count() \ .orderBy(F.col("count").desc()) df_bigrams.show(10, truncate=False)
What to say: The sequence and transform functions available in Spark SQL generate index arrays and apply a transformation to each index, which is how you slide a window of size two across the word array to create consecutive word pairs. Bigrams and trigrams give significantly more useful signal than single words for product feedback analysis because fast on its own is ambiguous but fast delivery and fast response time represent completely different customer experiences.
Q5. How would you scale this to process one billion feedback rows efficiently?
At large scale the main concern is the shuffle in the groupBy count step, which redistributes all word-row pairs across the cluster.
# OPTIMISATION 1: Filter early before explode # Remove feedback rows that are clearly empty or very short df_filtered_early = df \ .filter(F.col("feedback").isNotNull()) \ .filter(F.length(F.col("feedback")) > 3) # OPTIMISATION 2: Repartition on no meaningful key # Let Spark decide the optimal partition size spark.conf.set("spark.sql.shuffle.partitions", "500") # OPTIMISATION 3: Cache intermediate result if used multiple times df_words = df_filtered_early \ .withColumn("words", F.split(F.lower(F.regexp_replace(F.col("feedback"), "[^a-zA-Z\\s]", "")), "\\s+")) \ .select(F.explode("words").alias("word")) \ .filter((F.col("word") != "") & (~F.col("word").isin(stop_words))) \ .cache() df_words.count() # materialise cache top_words = df_words.groupBy("word").count().orderBy(F.col("count").desc()) top_words.show(20) df_words.unpersist() # OPTIMISATION 4: For approximate results use approx_count_distinct # to avoid exact distinct counting overhead approximate_result = df_words \ .groupBy("word") \ .agg(F.approx_count_distinct("word").alias("approx_freq")) \ .orderBy(F.col("approx_freq").desc())
What to say: For a billion feedback rows the explode step itself generates many billions of word rows before the groupBy, which is where the data volume explodes and performance can suffer. The most important optimisations are filtering empty or trivially short feedback before exploding to reduce the total word count, tuning shuffle partitions to keep the post-shuffle partition size around 100 to 200 megabytes, and caching the exploded word DataFrame if it needs to be analysed in multiple ways, for example both unigrams and bigrams. If the result only needs to be approximately correct, approx_count_distinct uses a HyperLogLog sketch that is dramatically faster than exact counting for very high cardinality word sets.
display vs show vs print vs collect
The Interviewer Says
This is a question I ask to every candidate who mentions Databricks or Microsoft Fabric in their resume. Tell me the difference between display, show, print, and collect in PySpark. When would you use each one and what are the risks of using the wrong one? This sounds simple but I have seen experienced engineers make dangerous mistakes here.
How to Handle This in the Interview
Before answering, say: These four look similar on the surface since they all produce some kind of output, but they work at completely different levels and have very different performance and safety implications. Let me walk through each one clearly.
Step 1: Separate them by where the data goes
- print is a Python function that works on Python objects already in the Driver. show and display are Spark functions that trigger execution and bring a limited number of rows to the Driver for display. collect brings ALL rows to the Driver as a Python list.
Step 2: Separate show from display
- show is the standard PySpark function available everywhere. display is a Databricks and Microsoft Fabric specific function that renders a richer interactive table with charts and pagination in the notebook UI. display does not exist in plain PySpark outside of those platforms.
Step 3: Emphasise the danger of collect
- collect is the most dangerous of the four and the one most likely to cause an out of memory error on the Driver in production. It brings every row from every partition to the Driver regardless of how large the DataFrame is.
Step 4: Know when each is appropriate
- print for Python variables and simple values. show for quick inspection of a few rows during development. display for richer notebook exploration in Databricks or Fabric. collect only when the data is confirmed small and you need the rows as Python objects.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = SparkSession.builder \ .appName("DisplayShowPrintDemo") \ .getOrCreate() data = [ ("C01", "Karan", "Finance", 55000), ("C02", "Asha", "IT", 28000), ("C03", "Ravi", "Finance", 42000), ("C04", "Meena", "HR", 31000), ("C05", "Sunil", "IT", 62000), ] df = spark.createDataFrame( data, ["customer_id", "name", "dept", "salary"] ) # ------------------------------------------------------- # print(): Python built-in, works on Python objects # ------------------------------------------------------- # print works on Python variables, not on Spark DataFrames directly total_rows = df.count() print(f"Total rows: {total_rows}") # prints: Total rows: 5 print(f"Type: {type(df)}") # prints the class name print(f"Columns: {df.columns}") # prints list of column names print(f"Data types: {df.dtypes}") # prints list of (name, type) tuples # If you print a DataFrame object directly you get a schema description # not the actual data print(df) # Output: DataFrame[customer_id: string, name: string, dept: string, salary: bigint] # This is NOT the data, just the schema metadata # print on a Row object works fine since Row is a Python object first_row = df.first() print(first_row) # Output: Row(customer_id='C01', name='Karan', dept='Finance', salary=55000) print(first_row["name"]) # Output: Karan # ------------------------------------------------------- # show(): PySpark action, triggers execution, prints to console # ------------------------------------------------------- # Default: shows first 20 rows, truncates long values at 20 chars df.show() # Show specific number of rows df.show(3) # Show without truncation df.show(truncate=False) # Show with custom truncation length df.show(10, truncate=50) # Show vertically (one column per line, useful for wide DataFrames) df.show(3, vertical=True) # show returns None, you cannot chain it or store its result result = df.show(3) print(result) # prints: None # show is an ACTION: triggers full Spark job execution # but only returns the first N rows to the Driver # safe for any DataFrame size since it limits data transfer # ------------------------------------------------------- # display(): Databricks and Microsoft Fabric specific # NOT available in plain PySpark # ------------------------------------------------------- # In Databricks or Microsoft Fabric notebooks only: # display(df) # # What display does differently from show: # 1. Renders an interactive table with sortable columns in the notebook UI # 2. Allows switching between Table, Chart, and Dashboard views # 3. Has pagination so you can browse through rows # 4. Shows column data types with icons # 5. Allows downloading the result as CSV directly from the UI # 6. Can render Pandas DataFrames as well as Spark DataFrames # 7. Works on Matplotlib and Plotly charts too # # display(df) # renders interactive table # display(df.toPandas()) # also works with Pandas # display(chart_figure) # also works with matplotlib/plotly # Simulating what display does conceptually in plain PySpark # (this is not what display actually does internally, just for illustration) def simulate_display(df, n=1000): print("=== Interactive Table (Databricks display simulation) ===") df.show(n, truncate=False) print(f"Showing up to {n} rows") print(f"Schema: {df.dtypes}") simulate_display(df) # ------------------------------------------------------- # collect(): brings ALL rows to Driver as Python list # ------------------------------------------------------- # collect returns a Python list of Row objects all_rows = df.collect() print(f"Type of collect result: {type(all_rows)}") # Output: <class 'list'> print(f"Number of rows: {len(all_rows)}") # Output: 5 # Each element is a Row object, accessed like a dict or by attribute for row in all_rows: print(f"ID: {row['customer_id']}, Name: {row.name}, Salary: {row['salary']}") # You can convert to a list of dicts rows_as_dicts = [row.asDict() for row in all_rows] print(rows_as_dicts) # collect is DANGEROUS on large DataFrames # Brings EVERY row from EVERY partition to the Driver # Driver typically has 2-8GB memory # A 10GB DataFrame collected will crash the Driver with OOM # SAFE pattern: always check size before collecting MAX_SAFE_ROWS = 100000 row_count = df.count() if row_count <= MAX_SAFE_ROWS: all_rows = df.collect() print(f"Safely collected {row_count} rows") else: print(f"WARNING: DataFrame has {row_count} rows, too large to collect safely") print("Using take() instead to get a sample") sample_rows = df.take(100) # ------------------------------------------------------- # take() and head(): safer alternatives to collect # ------------------------------------------------------- # take(n): returns first N rows as a Python list, safe first_3 = df.take(3) print(f"take(3) result type: {type(first_3)}") # Output: <class 'list'> # head(n): alias for take first_2 = df.head(2) # first(): returns only the very first row as a Row object one_row = df.first() print(f"first() result: {one_row}") # ------------------------------------------------------- # Summary comparison table # ------------------------------------------------------- summary = """ Function | Platform | Returns | Data location | Risk -----------+---------------+-----------------+---------------+-------- print() | Everywhere | None | Already in | None | | | Driver | -----------+---------------+-----------------+---------------+-------- show() | Everywhere | None | First N rows | None | | | in Driver | -----------+---------------+-----------------+---------------+-------- display() | Databricks | None | First N rows | None | Fabric only | | in Driver | -----------+---------------+-----------------+---------------+-------- collect() | Everywhere | List[Row] | ALL rows in | HIGH | | | Driver | OOM risk """ print(summary)
What to say: The most important distinction to make clearly in an interview is that show and display are safe because they always limit the number of rows transferred to the Driver, while collect is dangerous because it transfers everything regardless of size. In production code I treat collect as a code smell and question whether it is really necessary. If the goal is to iterate over rows in Python, I almost always find a better approach using Spark transformations that keep the work distributed, rather than pulling everything to the Driver.
Expected Output
Total rows: 5
Columns: ['customer_id', 'name', 'dept', 'salary']
+-----------+-----+-------+------+
|customer_id|name |dept |salary|
+-----------+-----+-------+------+
|C01 |Karan|Finance|55000 |
|C02 |Asha |IT |28000 |
|C03 |Ravi |Finance|42000 |
|C04 |Meena|HR |31000 |
|C05 |Sunil|IT |62000 |
+-----------+-----+-------+------+
Type of collect result: <class 'list'>
Number of rows: 5
ID: C01, Name: Karan, Salary: 55000
...
Follow-up Questions & Answers
Q1. What exactly happens inside Spark when you call show versus collect?
Both show and collect trigger a Spark action which causes the full execution plan to run across all Executors. The difference is what happens after the Executors finish computing their partitions.
With show, the Executors send only a small subset of rows back to the Driver, specifically the first N rows from the first partitions that finish. The Driver receives this small subset, formats it as a text table, and prints it. The rest of the data stays on the Executors and is discarded or garbage collected. The total data transferred to the Driver is tiny regardless of how large the full DataFrame is.
With collect, every Executor sends every row from every partition back to the Driver over the network. The Driver accumulates all of them into a single Python list in its JVM heap memory. If the total data volume exceeds the Driver's available memory, the JVM throws an OutOfMemoryError and the entire Spark application crashes with no recovery.
What to say: There is also a subtle difference in what show actually reads. Because of lazy evaluation and partition-level execution, show typically reads only enough partitions to find the first N rows rather than reading the entire dataset. This means show on a large DataFrame is often much faster than you might expect, since it short-circuits once it has enough rows. collect never short-circuits, it always reads and transfers everything.
Q2. What is the difference between display in Databricks and show in plain PySpark?
show is a plain PySpark method available in any Spark environment including local development, AWS EMR, Google Dataproc, and Azure HDInsight. It prints a plain text ASCII table to the console or notebook output. It has no interactivity, you cannot sort by a column or switch to a chart view. display is a function provided by the Databricks Runtime and Microsoft Fabric Notebook environments specifically. It renders a rich interactive HTML table in the notebook UI with the ability to sort columns by clicking headers, switch between table and chart views, paginate through results, and download the output as CSV. display also accepts Pandas DataFrames, Matplotlib figures, and Plotly charts, making it a unified display function for any kind of output rather than just Spark DataFrames.
# show: works everywhere, plain text table df.show(10) # display: Databricks/Fabric only, rich interactive table # display(df) # Spark DataFrame # display(pandas_df) # Pandas DataFrame # display(plt_figure) # Matplotlib figure # In plain PySpark if you call display it throws NameError # NameError: name 'display' is not defined
Q3. When would you ever legitimately use collect in a production pipeline?
There are a small number of legitimate use cases for collect where the data being collected is guaranteed to be small.
# Use case 1: Collecting a list of distinct values to use as a Python variable # Only valid when the number of distinct values is known to be small distinct_regions = [ row["region"] for row in df.select("region").distinct().collect() ] print(f"Regions: {distinct_regions}") # Safe because distinct regions is always a small list # Use case 2: Collecting a single aggregated value total_amount = df.agg(F.sum("salary")).collect()[0][0] print(f"Total salary: {total_amount}") # Safe because the result is a single row with one column # Use case 3: Collecting lookup data for a Python dict # Only valid when the lookup table is small, like a code-to-description mapping lookup = { row["dept_code"]: row["dept_name"] for row in dept_lookup_df.collect() } # Safe if dept_lookup_df has hundreds or low thousands of rows # Better alternative in most cases: use broadcast variable lookup_broadcast = spark.sparkContext.broadcast(lookup)
What to say: The common thread across all legitimate collect uses is that the data being collected is either a single aggregated value, a small distinct list, or a small lookup table. I always add a mental check before writing collect: can I guarantee this will never exceed a few thousand rows even as data grows over time? If there is any doubt I use take with a limit instead, or rethink the approach to keep the computation distributed.
Q4. What is the difference between show and toPandas and when would you use toPandas?
show prints a text representation to the console and returns None. toPandas brings all rows to the Driver and converts them into a Pandas DataFrame object that you can then work with using the full Pandas API. toPandas has the same OOM risk as collect since it moves all data to the Driver, but it has the additional overhead of converting from Spark's internal columnar format to Pandas format. The conversion uses Apache Arrow if available, which makes it faster, but the data volume limitation is the same.
# show: text output to console, returns None, safe df.show() # toPandas: returns a Pandas DataFrame, same OOM risk as collect pandas_df = df.toPandas() print(type(pandas_df)) # <class 'pandas.core.frame.DataFrame'> # With toPandas you can use the full Pandas API print(pandas_df.describe()) pandas_df.plot.bar(x="name", y="salary") # Enable Arrow for faster conversion if not already enabled spark.conf.set("spark.sql.execution.arrow.pyspark.enabled", "true") # Safe pattern: only use toPandas on small aggregated results summary_df = df.groupBy("dept").agg(F.avg("salary").alias("avg_salary")) # summary_df has only a few rows (one per department), safe to convert pandas_summary = summary_df.toPandas()
What to say: I use toPandas when I need to use a Pandas-specific library like Matplotlib for visualisation, Seaborn for statistical plots, or Scikit-learn for machine learning on a small aggregated result. The key discipline is to always aggregate or filter to a small result set in Spark first, then convert to Pandas, rather than converting a large Spark DataFrame to Pandas and then aggregating in Pandas. The former keeps the heavy lifting distributed. The latter pulls all the data to one machine unnecessarily.
Q5. In Microsoft Fabric or Databricks, a colleague ran display(large_df) and the notebook became very slow and eventually crashed. What happened and how would you fix it?
display in Databricks and Fabric by default renders up to one thousand rows in the interactive table, which is usually safe. However if the DataFrame has not been properly filtered or limited before calling display, Spark still needs to execute the full query plan to find those rows, including any expensive joins, aggregations, or shuffles. If the execution itself is expensive, the notebook appears slow or unresponsive while Spark computes the result.
# Problematic: display on a complex unbounded DataFrame # display(df_with_billion_rows_and_complex_joins) # Spark runs the full execution plan which might take 30 minutes # Fix 1: limit before display to short-circuit execution display(df.limit(100)) # Fix 2: cache an intermediate result if you need to display it multiple times df_result = df.groupBy("dept").agg(F.sum("salary")).cache() df_result.count() # materialise cache display(df_result) # fast, reads from cache # Fix 3: use sample to display a representative subset display(df.sample(fraction=0.01, seed=42)) # 1% sample # Fix 4: check what the query plan looks like before displaying df.explain(mode="formatted") # If the plan shows expensive operations, fix those first # before calling display
What to say: The crash most likely happened because the notebook ran out of Driver memory, either from display trying to hold too many rows, or from a collect or toPandas call somewhere in the notebook that was fetching far more data than the Driver could hold. I would check the notebook for any collect, toPandas, or display calls on large DataFrames and replace them with show with a row limit, take with a small number, or an aggregated summary before displaying. I would also check the Spark UI to see how much data each stage is processing and whether any stage is shuffling an unexpectedly large volume of data.
Delta Lake Concepts, ACID Transactions, and Transaction Log
The Interviewer Says
Delta Lake is mentioned on your resume so I want to go deep on it. Explain what Delta Lake is, how ACID transactions work in a distributed file system context where ACID was traditionally impossible, what the transaction log is and how it works, and then walk me through the DESCRIBE HISTORY command and time travel. I want to understand whether you know these things deeply or just know the buzzwords.
How to Handle This in the Interview
Before answering, say: Delta Lake solves one of the fundamental problems with data lakes, which is that plain file systems like S3 and ADLS have no concept of transactions, consistency, or isolation. Two writers can overwrite each other's files. A reader can see a half-written table mid-write. There is no way to roll back a failed write. Delta solves all of this by adding a transaction log on top of the Parquet files, and that transaction log is the core of everything Delta does.
Step 1: Explain what Delta Lake is in one sentence
- Delta Lake is an open source storage layer that adds ACID transactions, schema enforcement, time travel, and DML operations to a data lake by maintaining a transaction log alongside Parquet data files.
Step 2: Explain the transaction log clearly
- The transaction log is a folder called _delta_log that lives alongside the Parquet data files. Every operation that changes the table, whether a write, delete, update, or schema change, is recorded as a JSON file in this log before it is considered committed. This is the source of truth for what the table contains.
Step 3: Explain ACID in the Delta context
- Atomicity means an operation either fully commits or fully rolls back, no partial writes are visible. Consistency means the schema is enforced on every write. Isolation means concurrent readers see only committed versions. Durability means once committed the data persists even if the system crashes.
Step 4: Connect DESCRIBE HISTORY and time travel to the transaction log
- DESCRIBE HISTORY reads the transaction log and returns metadata about every past operation. Time travel reads a specific historical version of the log to reconstruct what the table looked like at that point.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from delta.tables import DeltaTable spark = SparkSession.builder \ .appName("DeltaLakeDemo") \ .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") \ .config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog") \ .getOrCreate() delta_path = "/output/delta_demo" # ------------------------------------------------------- # PART 1: Create a Delta Table # ------------------------------------------------------- data_v1 = [ ("C01", "Karan", "Finance", 55000), ("C02", "Asha", "IT", 28000), ("C03", "Ravi", "Finance", 42000), ] df_v1 = spark.createDataFrame( data_v1, ["customer_id", "name", "dept", "salary"] ) # Write as Delta - this creates version 0 in the transaction log df_v1.write \ .format("delta") \ .mode("overwrite") \ .save(delta_path) print("Version 0 written") # ------------------------------------------------------- # PART 2: Understanding the Transaction Log # ------------------------------------------------------- # After writing, the folder structure looks like: # # /output/delta_demo/ # _delta_log/ # 00000000000000000000.json <- version 0 commit file # 00000000000000000001.json <- version 1 commit file (after next operation) # part-00000-xxxx.parquet <- actual data files # part-00001-xxxx.parquet # Each commit JSON contains: # - commitInfo: who made the change, when, what operation # - add: list of Parquet files added in this commit # - remove: list of Parquet files removed (for updates/deletes) # - metaData: schema, partitioning info (on first write) # Read the transaction log to see what was recorded log_df = spark.read.json(f"{delta_path}/_delta_log/00000000000000000000.json") log_df.show(truncate=False) # ------------------------------------------------------- # PART 3: Append data (creates version 1) # ------------------------------------------------------- data_v2 = [ ("C04", "Meena", "HR", 31000), ("C05", "Sunil", "IT", 62000), ] df_v2 = spark.createDataFrame( data_v2, ["customer_id", "name", "dept", "salary"] ) df_v2.write \ .format("delta") \ .mode("append") \ .save(delta_path) print("Version 1 written (append)") # ------------------------------------------------------- # PART 4: Update data (creates version 2) # ------------------------------------------------------- delta_table = DeltaTable.forPath(spark, delta_path) # Update Karan's salary delta_table.update( condition=F.col("customer_id") == "C01", set={"salary": F.lit(60000)} ) print("Version 2 written (update)") # ------------------------------------------------------- # PART 5: Delete data (creates version 3) # ------------------------------------------------------- delta_table.delete( condition=F.col("dept") == "HR" ) print("Version 3 written (delete)") # ------------------------------------------------------- # PART 6: DESCRIBE HISTORY # ------------------------------------------------------- # SQL approach spark.sql(f"DESCRIBE HISTORY delta.`{delta_path}`").show(truncate=False) # PySpark approach history_df = delta_table.history() history_df.show(truncate=False) # Select specific columns from history history_df.select( "version", "timestamp", "operation", "operationParameters", "operationMetrics" ).show(truncate=False) # ------------------------------------------------------- # PART 7: Time Travel - read previous versions # ------------------------------------------------------- # Read a specific version by version number df_v0 = spark.read \ .format("delta") \ .option("versionAsOf", 0) \ .load(delta_path) print("Version 0 (original write):") df_v0.show() df_v1_read = spark.read \ .format("delta") \ .option("versionAsOf", 1) \ .load(delta_path) print("Version 1 (after append):") df_v1_read.show() # Read as of a specific timestamp df_by_time = spark.read \ .format("delta") \ .option("timestampAsOf", "2024-01-15 12:00:00") \ .load(delta_path) print("Version as of timestamp:") df_by_time.show() # Using SQL syntax for time travel spark.sql(f""" SELECT * FROM delta.`{delta_path}` VERSION AS OF 0 """).show() spark.sql(f""" SELECT * FROM delta.`{delta_path}` TIMESTAMP AS OF '2024-01-15' """).show() # ------------------------------------------------------- # PART 8: Schema Enforcement # ------------------------------------------------------- # Delta enforces schema on write # Trying to write a DataFrame with an extra column throws an error data_wrong_schema = [("C06", "Divya", "HR", 25000, "Extra Column")] df_wrong = spark.createDataFrame( data_wrong_schema, ["customer_id", "name", "dept", "salary", "extra"] ) try: df_wrong.write \ .format("delta") \ .mode("append") \ .save(delta_path) except Exception as e: print(f"Schema enforcement error: {e}") # Schema evolution: explicitly allow adding new columns df_wrong.write \ .format("delta") \ .mode("append") \ .option("mergeSchema", "true") \ .save(delta_path) print("Schema evolved to include extra column") # ------------------------------------------------------- # PART 9: Optimise and Vacuum # ------------------------------------------------------- # OPTIMIZE: compacts small Parquet files into larger ones # improves read performance by reducing the number of files to scan spark.sql(f"OPTIMIZE delta.`{delta_path}`") # OPTIMIZE with ZORDER: co-locates related data in the same files # improving predicate pushdown performance for specific columns spark.sql(f"OPTIMIZE delta.`{delta_path}` ZORDER BY (customer_id)") # VACUUM: removes old Parquet files no longer referenced by any version # Default retention is 7 days (168 hours) # Files older than retention period are permanently deleted spark.sql(f"VACUUM delta.`{delta_path}` RETAIN 168 HOURS") # WARNING: vacuuming with 0 hours breaks time travel # Never do this in production unless you are absolutely sure # spark.sql(f"VACUUM delta.`{delta_path}` RETAIN 0 HOURS") # DANGEROUS # ------------------------------------------------------- # PART 10: ACID Properties demonstrated # ------------------------------------------------------- # ATOMICITY: write either fully succeeds or fully fails # No partial writes are visible to readers # If the Spark job fails mid-write, the commit JSON is never written # so the transaction log does not include the partial write # CONSISTENCY: schema is enforced on every write # A write with wrong columns fails before any data is written # ISOLATION: readers always see a consistent snapshot # Even while a write is happening, readers see the last committed version # Implemented through optimistic concurrency control # DURABILITY: once the commit JSON is written to _delta_log # the data is permanent even if nodes fail afterward print("ACID properties demonstrated through Delta Lake transaction log")
What to say: The transaction log is the key insight that makes everything else in Delta possible. Before Delta, writing to a data lake was like writing to a shared folder with no coordination, two writers could overwrite each other and a reader might see half a write in progress. Delta solves this by treating the _delta_log folder as the authoritative record of what the table contains. A write is not considered committed until the commit JSON file is successfully written to _delta_log. Everything, time travel, schema enforcement, concurrent access, auditing through DESCRIBE HISTORY, is built on top of this single source of truth.
Expected Output: DESCRIBE HISTORY
| Version | Timestamp | Operation | Operation Parameters |
|---|---|---|---|
| 3 | 2024-01-15 12:03:00 | DELETE | {condition: dept=HR} |
| 2 | 2024-01-15 12:02:00 | UPDATE | {predicate: C01} |
| 1 | 2024-01-15 12:01:00 | WRITE | {mode: Append} |
| 0 | 2024-01-15 12:00:00 | WRITE | {mode: Overwrite} |
Follow-up Questions & Answers
Q1. How does Delta Lake achieve ACID transactions on a file system that has no native transaction support?
Traditional databases achieve ACID through a write-ahead log managed by the database engine which has complete control over storage. A distributed file system like S3 or ADLS has no such coordination mechanism, any process can read or write any file at any time. Delta achieves ACID by treating the _delta_log directory as the transaction log and using cloud storage's atomic file creation guarantee as the coordination primitive.
When a writer wants to commit, it first writes all the Parquet data files to storage. These files are not yet part of the table since no commit file points to them. The writer then attempts to create the next commit JSON file in _delta_log with a specific version number like 00000000000000000001.json. Cloud storage guarantees that only one writer can successfully create a file with a given name. If two writers both try to create version 1 simultaneously, only one succeeds and the other detects the conflict and retries with version 2. This optimistic concurrency control turns the file system's atomic create operation into a transaction coordination mechanism. Readers always read the highest committed version by scanning the _delta_log directory for the latest commit file, so they always see a consistent snapshot regardless of what writers are doing concurrently.
Q2. What does DESCRIBE HISTORY show and what can you use it for in practice?
DESCRIBE HISTORY returns one row per committed operation on the Delta table, with metadata including the version number, timestamp, the type of operation like WRITE, UPDATE, DELETE, MERGE, OPTIMIZE, the operation parameters like which columns were filtered, the user who performed the operation if running in a managed environment, and operational metrics like the number of rows affected and the number of files added or removed.
# In PySpark history = delta_table.history() history.select( "version", "timestamp", "operation", "operationParameters", "operationMetrics", "userMetadata" ).show(truncate=False) # In SQL spark.sql("DESCRIBE HISTORY delta.`/output/delta_demo`").show() # Practical uses: # 1. Audit who deleted records and when history.filter(F.col("operation") == "DELETE").show() # 2. Find when a specific version was written history.filter(F.col("version") == 3).select("timestamp").show() # 3. Check how many rows were affected by each operation history.select( "version", "operation", F.col("operationMetrics.numOutputRows").alias("rows_written"), F.col("operationMetrics.numDeletedRows").alias("rows_deleted") ).show()
What to say: DESCRIBE HISTORY is one of the most practically useful Delta features for a data engineer because it gives you a complete audit trail of every change ever made to a table without needing any separate logging infrastructure. When a business user reports that records are missing or wrong, I go to DESCRIBE HISTORY first to find exactly when the data changed, what operation caused it, and which version I can time travel to in order to compare before and after. This audit capability would require significant additional engineering effort with plain Parquet tables.
Q3. What is the difference between mergeSchema and overwriteSchema in Delta?
mergeSchema allows adding new columns to an existing Delta table on write. If the incoming DataFrame has columns that do not exist in the current table schema, Delta adds those columns to the schema and writes NULL for them in existing rows. Existing columns that the incoming DataFrame does not have are preserved with their current values. overwriteSchema replaces the entire table schema with the schema of the incoming DataFrame, which means columns that existed in the old schema but not in the new DataFrame are permanently dropped and their data is lost.
# mergeSchema: safe, only adds new columns df_with_new_col.write \ .format("delta") \ .mode("append") \ .option("mergeSchema", "true") \ .save(delta_path) # overwriteSchema: dangerous, replaces entire schema # Only use when you intentionally want to change the schema completely df_new_schema.write \ .format("delta") \ .mode("overwrite") \ .option("overwriteSchema", "true") \ .save(delta_path)
What to say: I treat overwriteSchema as a destructive operation that requires explicit approval from the data owner before using in production, since it can permanently drop columns and their data. mergeSchema is the safe default for schema evolution since it only adds, never removes. In pipelines where the source schema might change over time, I enable mergeSchema by default and add a schema validation step that alerts when new columns appear unexpectedly, so the team can review whether the new columns should be trusted or whether they indicate a data quality issue upstream.
Q4. What is VACUUM and what is the risk of running it with a very short retention period?
VACUUM removes old Parquet files that are no longer referenced by any version in the transaction log that falls within the retention window. Delta's default retention period is 7 days (168 hours). Files older than the retention period that are no longer needed for any version within the retention window are permanently deleted from storage, which reduces storage costs.
# Safe vacuum with default 7 day retention spark.sql(f"VACUUM delta.`{delta_path}`") # Explicit retention period spark.sql(f"VACUUM delta.`{delta_path}` RETAIN 168 HOURS") # Check what would be deleted without actually deleting (dry run) spark.sql(f"VACUUM delta.`{delta_path}` RETAIN 168 HOURS DRY RUN").show() # DANGEROUS: vacuum with 0 hours deletes everything not in current version # This permanently destroys your ability to time travel # Disable the safety check that prevents this spark.conf.set("spark.databricks.delta.retentionDurationCheck.enabled", "false") spark.sql(f"VACUUM delta.`{delta_path}` RETAIN 0 HOURS") # NEVER in production
What to say: The risk of VACUUM with a very short retention period is that it permanently removes the old Parquet files that time travel depends on. Once those files are deleted, you cannot read version 0 or 1 or any other historical version that referenced them. The 7 day default gives you one week of time travel capability and is also the minimum retention period that Delta recommends for safe concurrent read operations, since a long running read query might be referencing a file version that a concurrent VACUUM would otherwise delete. I would never run VACUUM with less than 24 hours retention in production, and I always run with DRY RUN first to see exactly what would be deleted before committing.
Q5. How would you use time travel to recover from an accidental DELETE or UPDATE?
This is one of the most practically valuable Delta features and comes up frequently in real engineering work when someone accidentally runs a DELETE without a WHERE clause or an UPDATE with the wrong condition.
from delta.tables import DeltaTable delta_path = "/output/delta_demo" delta_table = DeltaTable.forPath(spark, delta_path) # Step 1: Check the history to find the version before the accident history = delta_table.history() history.select("version", "timestamp", "operation").show() # Step 2: Read the version before the accidental operation # If the accident was version 3 (DELETE), read version 2 df_before_accident = spark.read \ .format("delta") \ .option("versionAsOf", 2) \ .load(delta_path) print("Data before the accident:") df_before_accident.show() # Step 3: Restore to the previous version # Option A: Overwrite the current table with the previous version df_before_accident.write \ .format("delta") \ .mode("overwrite") \ .option("overwriteSchema", "true") \ .save(delta_path) # Option B: Use RESTORE command (Databricks Delta Lake 0.7+) spark.sql(f"RESTORE TABLE delta.`{delta_path}` TO VERSION AS OF 2") # Option C: Use RESTORE to a timestamp spark.sql(f""" RESTORE TABLE delta.`{delta_path}` TO TIMESTAMP AS OF '2024-01-15 12:01:00' """) print("Table restored to version before accident") # Step 4: Verify the restoration delta_table.toDF().show()
What to say: The RESTORE command is the cleanest recovery mechanism since it creates a new version in the transaction log that points back to the historical state, rather than physically overwriting anything. This means even the recovery itself is auditable through DESCRIBE HISTORY. In a production incident I would always check DESCRIBE HISTORY first to understand exactly what happened and when, then use RESTORE to the last known good version, and finally investigate the root cause to prevent the same accident from happening again. This workflow, history to diagnose, restore to recover, audit to prevent, is the standard Delta incident response pattern.
Comparing Two Delta Table Versions and Finding Differences
The Interviewer Says
We have a Delta table that gets updated daily. Yesterday a pipeline ran and made changes to the data. I want you to compare two specific versions of the Delta table and find exactly what changed between them, which records were added, which were deleted, and which were updated. Walk me through how you would approach this.
How to Handle This in the Interview
Before writing anything, say: This is a version comparison problem built on top of Delta's time travel capability. The approach is to read two specific versions of the same table as two separate DataFrames, then use set operations and joins to find the three categories of difference, new rows, deleted rows, and changed rows.
Step 1: Read two versions using time travel
- Use versionAsOf to read the older version and the current or newer version as two separate DataFrames. These are the before and after snapshots.
Step 2: Use EXCEPT to find added and deleted rows
- Rows in the new version but not in the old version are additions. Rows in the old version but not in the new version are deletions. EXCEPT compares full rows, so a row that changed in any column will appear in both sets.
Step 3: Use a join to find updated rows
- Updated rows exist in both versions but have different values in at least one non-key column. Join both versions on the primary key and compare column values to identify what changed.
Step 4: Combine everything into one reconciliation result
- Label each difference as ADDED, DELETED, or UPDATED and union into a single output DataFrame for easy reporting.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from delta.tables import DeltaTable spark = SparkSession.builder \ .appName("DeltaVersionComparison") \ .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") \ .config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog") \ .getOrCreate() delta_path = "/output/employee_delta" # ------------------------------------------------------- # SETUP: Create and evolve a Delta table across versions # ------------------------------------------------------- # Version 0: initial data data_v0 = [ ("E01", "Karan", "Finance", 55000), ("E02", "Asha", "IT", 28000), ("E03", "Ravi", "Finance", 42000), ("E04", "Meena", "HR", 31000), ("E05", "Sunil", "IT", 62000), ] df_v0 = spark.createDataFrame( data_v0, ["emp_id", "name", "dept", "salary"] ) df_v0.write \ .format("delta") \ .mode("overwrite") \ .save(delta_path) print("Version 0 written") # Version 1: Asha gets a salary hike, Meena is deleted, Divya is added delta_table = DeltaTable.forPath(spark, delta_path) delta_table.update( condition=F.col("emp_id") == "E02", set={"salary": F.lit(35000)} ) print("Version 1: Asha salary updated") delta_table.delete(condition=F.col("emp_id") == "E04") print("Version 2: Meena deleted") new_employee = spark.createDataFrame( [("E06", "Divya", "HR", 29000)], ["emp_id", "name", "dept", "salary"] ) new_employee.write \ .format("delta") \ .mode("append") \ .save(delta_path) print("Version 3: Divya added") # ------------------------------------------------------- # STEP 1: Read two specific versions # ------------------------------------------------------- # Read the original version (before any changes) df_old = spark.read \ .format("delta") \ .option("versionAsOf", 0) \ .load(delta_path) print("Old version (v0):") df_old.show() # Read the latest version (after all changes) df_new = spark.read \ .format("delta") \ .load(delta_path) print("New version (current):") df_new.show() # ------------------------------------------------------- # STEP 2: Find ADDED rows # Rows in new version but not in old version (full row comparison) # ------------------------------------------------------- df_added = df_new.exceptAll(df_old) \ .withColumn("change_type", F.lit("ADDED")) print("Added rows:") df_added.show() # ------------------------------------------------------- # STEP 3: Find DELETED rows # Rows in old version but not in new version # ------------------------------------------------------- df_deleted = df_old.exceptAll(df_new) \ .withColumn("change_type", F.lit("DELETED")) print("Deleted rows:") df_deleted.show() # ------------------------------------------------------- # STEP 4: Find UPDATED rows # Rows that exist in both versions by key but differ in values # ------------------------------------------------------- primary_key = "emp_id" value_columns = ["name", "dept", "salary"] df_updated = df_old.alias("old").join( df_new.alias("new"), on=primary_key, how="inner" ).filter( # At least one value column is different reduce( lambda a, b: a | b, [ F.col(f"old.{c}") != F.col(f"new.{c}") for c in value_columns ] ) ).select( F.col(f"old.{primary_key}").alias("emp_id"), F.col("old.name").alias("old_name"), F.col("new.name").alias("new_name"), F.col("old.salary").alias("old_salary"), F.col("new.salary").alias("new_salary"), F.col("old.dept").alias("old_dept"), F.col("new.dept").alias("new_dept"), F.lit("UPDATED").alias("change_type") ) print("Updated rows:") df_updated.show() # ------------------------------------------------------- # STEP 5: Clean combined diff report # ------------------------------------------------------- # Union added and deleted into a single change report df_diff = df_added.select( "emp_id", "name", "dept", "salary", "change_type" ).union( df_deleted.select( "emp_id", "name", "dept", "salary", "change_type" ) ) print("Full diff report (added and deleted):") df_diff.show() # ------------------------------------------------------- # STEP 6: Using SQL for version comparison # ------------------------------------------------------- spark.sql(f""" SELECT COALESCE(new.emp_id, old.emp_id) AS emp_id, old.salary AS old_salary, new.salary AS new_salary, CASE WHEN old.emp_id IS NULL THEN 'ADDED' WHEN new.emp_id IS NULL THEN 'DELETED' WHEN old.salary != new.salary THEN 'UPDATED' ELSE 'UNCHANGED' END AS change_type FROM (SELECT * FROM delta.`{delta_path}` VERSION AS OF 0) old FULL OUTER JOIN (SELECT * FROM delta.`{delta_path}`) new ON old.emp_id = new.emp_id WHERE old.emp_id IS NULL OR new.emp_id IS NULL OR old.salary != new.salary OR old.dept != new.dept """).show() # ------------------------------------------------------- # STEP 7: Hash based comparison for wide tables # ------------------------------------------------------- # For tables with many columns, hash the entire row for efficient comparison df_old_hashed = df_old.withColumn( "row_hash", F.md5(F.concat_ws("|", F.col("emp_id"), F.col("name"), F.col("dept"), F.col("salary").cast("string") )) ) df_new_hashed = df_new.withColumn( "row_hash", F.md5(F.concat_ws("|", F.col("emp_id"), F.col("name"), F.col("dept"), F.col("salary").cast("string") )) ) hash_diff = df_old_hashed.alias("old").join( df_new_hashed.alias("new"), on="emp_id", how="full" ).filter( F.col("old.row_hash").isNull() | F.col("new.row_hash").isNull() | (F.col("old.row_hash") != F.col("new.row_hash")) ).select( F.coalesce( F.col("old.emp_id"), F.col("new.emp_id") ).alias("emp_id"), F.col("old.row_hash").alias("old_hash"), F.col("new.row_hash").alias("new_hash"), F.when(F.col("old.emp_id").isNull(), "ADDED") .when(F.col("new.emp_id").isNull(), "DELETED") .otherwise("UPDATED") .alias("change_type") ) print("Hash based diff:") hash_diff.show()
What to say: I always separate the comparison into three distinct operations because each requires a different approach. EXCEPT gives me exact full-row differences for additions and deletions cleanly. A key-based join with column comparisons gives me updates where the key exists in both versions but the values changed. For wide tables with many columns, hashing the entire row is much more efficient than comparing every column individually, and it still correctly identifies any row that changed in any field.
Expected Output
Added rows:
| Emp ID | Name | Dept | Salary | Change Type |
|---|---|---|---|---|
| E06 | Divya | HR | 29000 | ADDED |
Deleted rows:
| Emp ID | Name | Dept | Salary | Change Type |
|---|---|---|---|---|
| E04 | Meena | HR | 31000 | DELETED |
Updated rows:
| Emp ID | Old Name | New Name | Old Salary | New Salary | Old Dept | New Dept | Change Type |
|---|---|---|---|---|---|---|---|
| E02 | Asha | Asha | 28000 | 35000 | IT | IT | UPDATED |
### Follow-up Questions & Answers
**Q1. What is the difference between except and exceptAll in PySpark and which should you use for version comparison?**
except removes duplicate rows from the result, similar to EXCEPT DISTINCT in SQL. If the same row appears multiple times in the left DataFrame and once in the right DataFrame, except removes all occurrences from the result. exceptAll is the set difference that preserves duplicates, meaning if a row appears three times in the left and once in the right, exceptAll keeps two occurrences in the result. For Delta version comparison you should almost always use exceptAll rather than except, because if the same employee record genuinely appears twice in one version and once in another, that difference in count is meaningful and should be preserved in the diff result.
**Q2. How would you schedule this version comparison to run automatically after every pipeline execution?**
```python
def compare_delta_versions(delta_path, old_version, new_version=None):
df_old = spark.read.format("delta") \
.option("versionAsOf", old_version) \
.load(delta_path)
if new_version:
df_new = spark.read.format("delta") \
.option("versionAsOf", new_version) \
.load(delta_path)
else:
df_new = spark.read.format("delta").load(delta_path)
df_added = df_new.exceptAll(df_old).withColumn("change_type", F.lit("ADDED"))
df_deleted = df_old.exceptAll(df_new).withColumn("change_type", F.lit("DELETED"))
diff_count = df_added.count() + df_deleted.count()
if diff_count > 0:
diff_report = df_added.union(df_deleted)
diff_report.write \
.format("delta") \
.mode("append") \
.save("/output/delta_diff_audit")
print(f"WARNING: {diff_count} differences found and logged")
else:
print("No differences found, versions match")
return diff_count
# Get the latest two versions from history
delta_table = DeltaTable.forPath(spark, delta_path)
latest_version = delta_table.history(1).select("version").first()["version"]
compare_delta_versions(delta_path, latest_version - 1, latest_version)
What to say: Wrapping this into a reusable function that automatically discovers the latest version from the history makes it easy to call after any pipeline run without hardcoding version numbers. Writing the diff report to its own Delta audit table creates a historical record of every change detected across all pipeline runs, which is invaluable for debugging data quality issues that emerge gradually over time rather than all at once.
Q3. What happens if you try to compare a version that has already been vacuumed?
If VACUUM has been run with a retention period that excludes the version you are trying to read, the underlying Parquet files for that version have been permanently deleted. Attempting to read that version with versionAsOf will throw an AnalysisException saying the version no longer exists.
try: df_old = spark.read \ .format("delta") \ .option("versionAsOf", 0) \ .load(delta_path) except Exception as e: print(f"Cannot read version 0: {e}") # Error: The provided timestamp X is before the earliest version available # Always check available history before time traveling history = delta_table.history() earliest_available = history.agg(F.min("version")).collect()[0][0] print(f"Earliest available version: {earliest_available}")
What to say: This is why the VACUUM retention period is such an important operational decision. A seven day retention gives you seven days of version comparison and recovery capability. If your compliance or audit requirements demand being able to compare versions from thirty days ago, you need to set retention to thirty days and accept the higher storage cost. In practice I recommend keeping retention at the maximum the business can justify rather than the minimum, since the cost of being unable to investigate an historical data issue usually far exceeds the storage savings from aggressive vacuuming.
Q4. How would you handle NULL values when comparing columns across versions?
Normal equality comparisons involving NULL return UNKNOWN in Spark, meaning a column that was NULL in version 0 and is still NULL in version 1 would incorrectly be flagged as changed.
# NULL safe column comparison using eqNullSafe or IS NOT DISTINCT FROM df_updated_null_safe = df_old.alias("old").join( df_new.alias("new"), on="emp_id", how="inner" ).filter( ~F.col("old.salary").eqNullSafe(F.col("new.salary")) | ~F.col("old.dept").eqNullSafe(F.col("new.dept")) | ~F.col("old.name").eqNullSafe(F.col("new.name")) ) # Or using IS DISTINCT FROM in SQL style spark.sql(f""" SELECT old.emp_id, old.salary AS old_salary, new.salary AS new_salary FROM old_version old JOIN new_version new ON old.emp_id = new.emp_id WHERE old.salary IS DISTINCT FROM new.salary """)
What to say: eqNullSafe is the PySpark equivalent of IS NOT DISTINCT FROM in SQL. It returns true when both sides are NULL, treating two NULLs as equal rather than returning UNKNOWN. Without null safe comparison, a column that changes from a real value to NULL or from NULL to a real value would also be missed since the NULL comparison evaluates to UNKNOWN rather than true. I always use eqNullSafe for column comparisons in reconciliation queries on real world data where NULL values are expected.
Q5. How would you compare two Delta tables that live in completely different storage locations rather than two versions of the same table?
Sometimes you need to compare a source table against a target table to verify a pipeline loaded correctly, rather than comparing two temporal versions of the same table.
# Read source and target as separate DataFrames df_source = spark.read.format("delta").load("/source/employees") df_target = spark.read.format("delta").load("/target/employees") # Same diff logic applies df_in_source_not_target = df_source.exceptAll(df_target) df_in_target_not_source = df_target.exceptAll(df_source) print(f"Rows in source but not target: {df_in_source_not_target.count()}") print(f"Rows in target but not source: {df_in_target_not_source.count()}") # Hash comparison for wide tables across locations def hash_df(df, key_col, value_cols): return df.withColumn( "row_hash", F.md5(F.concat_ws("|", *[F.col(c).cast("string") for c in value_cols])) ).select(key_col, "row_hash") source_hashed = hash_df(df_source, "emp_id", ["name", "dept", "salary"]) target_hashed = hash_df(df_target, "emp_id", ["name", "dept", "salary"]) cross_table_diff = source_hashed.alias("s").join( target_hashed.alias("t"), on="emp_id", how="full" ).filter( F.col("s.row_hash").isNull() | F.col("t.row_hash").isNull() | (F.col("s.row_hash") != F.col("t.row_hash")) ).select( F.coalesce(F.col("s.emp_id"), F.col("t.emp_id")).alias("emp_id"), F.when(F.col("s.row_hash").isNull(), "Missing in source") .when(F.col("t.row_hash").isNull(), "Missing in target") .otherwise("Hash mismatch") .alias("diff_status") ) print("Cross table diff:") cross_table_diff.show()
What to say: Cross table comparison is structurally identical to cross version comparison, the only difference is the source of the two DataFrames. This pattern is the PySpark equivalent of the SQL reconciliation question covered earlier in the SQL section. In a real pipeline I would run this comparison as the final validation step after every load, write the results to an audit table, and fail the pipeline with an alert if any differences exceed a defined threshold. This catches both missing records and corrupted transformations before they reach end users.
MERGE Operation in PySpark for Delta Tables
The Interviewer Says
MERGE is one of the most important operations in any data engineering pipeline. Walk me through how MERGE works in Delta Lake using PySpark, write the code for a full upsert, then extend it to handle SCD Type 1 and SCD Type 2 patterns. I want to see both the PySpark API and the SQL syntax.
How to Handle This in the Interview
Before writing anything, say: MERGE in Delta Lake is the atomic operation that combines INSERT, UPDATE, and DELETE in a single transaction. It compares a source DataFrame against the target Delta table on a matching condition and applies different actions depending on whether a match is found. This is the foundation of every incremental load pattern I use in production.
Step 1: Explain what MERGE does conceptually
- For each row in the source, Spark checks whether a matching row exists in the target based on the join condition. If a match exists you can UPDATE or DELETE the target row. If no match exists you can INSERT the source row. You can also handle the case where a target row has no match in the source, which is useful for soft deletes.
Step 2: Know the three clauses
- whenMatchedUpdate or whenMatchedDelete handles rows that match. whenNotMatchedInsert handles rows in source with no match in target. whenNotMatchedBySourceDelete handles rows in target with no match in source, added in Delta 2.0.
Step 3: Explain watermark columns
- In production MERGE operations you often only want to update a target row if the incoming source row is newer than the existing one. A watermark column like updated_at or effective_date is used in the match condition to prevent older source records from overwriting newer target records.
Step 4: Distinguish SCD Type 1 from SCD Type 2
- SCD Type 1 simply overwrites the existing row with the new values, losing history. SCD Type 2 closes the existing row by setting an end date and inserts a new row for the new version, preserving history.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from delta.tables import DeltaTable spark = SparkSession.builder \ .appName("MergeDemo") \ .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") \ .config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog") \ .getOrCreate() delta_path = "/output/merge_demo" # ------------------------------------------------------- # SETUP: Create target Delta table # ------------------------------------------------------- target_data = [ ("E01", "Karan", "Finance", 55000, "2024-01-01", True), ("E02", "Asha", "IT", 28000, "2024-01-01", True), ("E03", "Ravi", "Finance", 42000, "2024-01-01", True), ("E04", "Meena", "HR", 31000, "2024-01-01", True), ] df_target = spark.createDataFrame( target_data, ["emp_id", "name", "dept", "salary", "updated_at", "is_active"] ) df_target.write \ .format("delta") \ .mode("overwrite") \ .save(delta_path) print("Target table created:") df_target.show() # ------------------------------------------------------- # PART 1: Basic MERGE (Upsert) # Update matching rows, insert new rows # ------------------------------------------------------- source_data = [ ("E02", "Asha", "IT", 35000, "2024-02-01", True), # update salary ("E03", "Ravi", "Finance", 42000, "2024-01-01", True), # no change ("E05", "Sunil", "IT", 62000, "2024-02-01", True), # new employee ("E06", "Divya", "HR", 29000, "2024-02-01", True), # new employee ] df_source = spark.createDataFrame( source_data, ["emp_id", "name", "dept", "salary", "updated_at", "is_active"] ) print("Source DataFrame:") df_source.show() delta_table = DeltaTable.forPath(spark, delta_path) # Basic MERGE: update if match exists, insert if no match delta_table.alias("target").merge( df_source.alias("source"), "target.emp_id = source.emp_id" ).whenMatchedUpdateAll() \ .whenNotMatchedInsertAll() \ .execute() print("After basic MERGE (upsert):") delta_table.toDF().show() # ------------------------------------------------------- # PART 2: MERGE with Watermark Column # Only update if source record is NEWER than target # ------------------------------------------------------- delta_table.alias("target").merge( df_source.alias("source"), condition=""" target.emp_id = source.emp_id AND source.updated_at > target.updated_at """ ).whenMatchedUpdate(set={ "name": "source.name", "dept": "source.dept", "salary": "source.salary", "updated_at": "source.updated_at", "is_active": "source.is_active" }).whenNotMatchedInsert(values={ "emp_id": "source.emp_id", "name": "source.name", "dept": "source.dept", "salary": "source.salary", "updated_at": "source.updated_at", "is_active": "source.is_active" }).execute() print("After MERGE with watermark:") delta_table.toDF().show() # ------------------------------------------------------- # PART 3: MERGE with Soft Delete # Mark records as inactive instead of physically deleting # ------------------------------------------------------- deleted_source = spark.createDataFrame( [("E04", False)], ["emp_id", "is_active"] ) delta_table.alias("target").merge( deleted_source.alias("source"), "target.emp_id = source.emp_id" ).whenMatchedUpdate(set={ "is_active": "source.is_active", "updated_at": F.lit("2024-02-15") }).execute() print("After soft delete MERGE:") delta_table.toDF().show() # ------------------------------------------------------- # PART 4: SCD TYPE 1 using MERGE # Overwrite existing record, no history kept # ------------------------------------------------------- scd1_path = "/output/scd1_demo" scd1_target = [ ("C01", "Karan", "Mumbai", "Finance"), ("C02", "Asha", "Delhi", "IT"), ("C03", "Ravi", "Pune", "Finance"), ] df_scd1_target = spark.createDataFrame( scd1_target, ["customer_id", "name", "city", "dept"] ) df_scd1_target.write \ .format("delta") \ .mode("overwrite") \ .save(scd1_path) scd1_source = [ ("C02", "Asha", "Bangalore", "IT"), # city changed ("C03", "Ravi", "Pune", "HR"), # dept changed ("C04", "Meena", "Chennai", "HR"), # new customer ] df_scd1_source = spark.createDataFrame( scd1_source, ["customer_id", "name", "city", "dept"] ) scd1_table = DeltaTable.forPath(spark, scd1_path) # SCD Type 1: simply overwrite with latest values scd1_table.alias("target").merge( df_scd1_source.alias("source"), "target.customer_id = source.customer_id" ).whenMatchedUpdateAll() \ .whenNotMatchedInsertAll() \ .execute() print("SCD Type 1 result (history overwritten):") scd1_table.toDF().show() # ------------------------------------------------------- # PART 5: SCD TYPE 2 using MERGE # Close existing record, insert new record with history # ------------------------------------------------------- scd2_path = "/output/scd2_demo" scd2_target = [ ("C01", "Karan", "Mumbai", "Finance", "2023-01-01", "9999-12-31", True), ("C02", "Asha", "Delhi", "IT", "2023-01-01", "9999-12-31", True), ("C03", "Ravi", "Pune", "Finance", "2023-01-01", "9999-12-31", True), ] df_scd2_target = spark.createDataFrame( scd2_target, ["customer_id", "name", "city", "dept", "eff_start_date", "eff_end_date", "is_current"] ) df_scd2_target.write \ .format("delta") \ .mode("overwrite") \ .save(scd2_path) scd2_source = [ ("C02", "Asha", "Bangalore", "IT"), # city changed ("C04", "Meena", "Chennai", "HR"), # new customer ] df_scd2_source = spark.createDataFrame( scd2_source, ["customer_id", "name", "city", "dept"] ) scd2_table = DeltaTable.forPath(spark, scd2_path) today = "2024-02-01" yesterday = "2024-01-31" # SCD Type 2 Step 1: Close existing records that have changed # Find changed records by joining source to target on key # where values differ changed_records = df_scd2_source.alias("src").join( scd2_table.toDF().filter(F.col("is_current") == True).alias("tgt"), on="customer_id", how="inner" ).filter( (F.col("src.city") != F.col("tgt.city")) | (F.col("src.dept") != F.col("tgt.dept")) ).select("src.customer_id") print("Changed records to close:") changed_records.show() # Step 1: MERGE to close existing active records for changed customers scd2_table.alias("target").merge( changed_records.alias("source"), """ target.customer_id = source.customer_id AND target.is_current = true """ ).whenMatchedUpdate(set={ "eff_end_date": F.lit(yesterday), "is_current": F.lit(False) }).execute() print("After closing changed records:") scd2_table.toDF().orderBy("customer_id").show() # Step 2: Prepare new versions of changed records plus genuinely new records new_records = df_scd2_source.withColumn( "eff_start_date", F.lit(today) ).withColumn( "eff_end_date", F.lit("9999-12-31") ).withColumn( "is_current", F.lit(True) ) # Step 3: MERGE to insert new versions and new customers scd2_table.alias("target").merge( new_records.alias("source"), """ target.customer_id = source.customer_id AND target.eff_start_date = source.eff_start_date AND target.is_current = true """ ).whenNotMatchedInsertAll() \ .execute() print("Final SCD Type 2 table (full history):") scd2_table.toDF().orderBy("customer_id", "eff_start_date").show() # ------------------------------------------------------- # PART 6: MERGE using SQL syntax # ------------------------------------------------------- df_scd2_target.createOrReplaceTempView("target_table") df_scd2_source.createOrReplaceTempView("source_table") spark.sql(f""" MERGE INTO delta.`{scd2_path}` AS target USING source_table AS source ON target.customer_id = source.customer_id AND target.is_current = true WHEN MATCHED AND ( target.city != source.city OR target.dept != source.dept ) THEN UPDATE SET target.eff_end_date = '{yesterday}', target.is_current = false WHEN NOT MATCHED THEN INSERT ( customer_id, name, city, dept, eff_start_date, eff_end_date, is_current ) VALUES ( source.customer_id, source.name, source.city, source.dept, '{today}', '9999-12-31', true ) """) print("After SQL MERGE:") spark.read.format("delta").load(scd2_path).show()
What to say: SCD Type 2 in MERGE requires two separate operations rather than one, because a single MERGE cannot both close an existing row and insert a new row for the same key in the same operation. The first MERGE closes changed records by setting is_current to false and updating the end date. The second MERGE inserts the new versions. This two-step pattern is the standard approach for SCD Type 2 in Delta Lake and I would always wrap both steps in a try-except with a rollback strategy so that a failure between steps does not leave the table in an inconsistent state with some records closed but no new versions inserted.
Expected Output: SCD Type 2 Final Table
| Customer ID | Name | City | Dept | Eff Start Date | Eff End Date | Is Current |
|---|---|---|---|---|---|---|
| C01 | Karan | Mumbai | Finance | 2023-01-01 | 9999-12-31 | true |
| C02 | Asha | Delhi | IT | 2023-01-01 | 2024-01-31 | false |
| C02 | Asha | Bangalore | IT | 2024-02-01 | 9999-12-31 | true |
| C03 | Ravi | Pune | Finance | 2023-01-01 | 9999-12-31 | true |
| C04 | Meena | Chennai | HR | 2024-02-01 | 9999-12-31 | true |
Follow-up Questions & Answers
Q1. What is the difference between whenMatchedUpdateAll and whenMatchedUpdate with explicit column mappings?
whenMatchedUpdateAll updates every column in the target row with the corresponding column from the source row, exactly like UPDATE SET star in SQL. It assumes the source and target have the same column names. whenMatchedUpdate with an explicit set dictionary gives you precise control over which columns get updated and what values they receive, including using expressions, literals, or combining source and target columns to compute the new value.
# whenMatchedUpdateAll: updates all columns from source delta_table.alias("t").merge( source.alias("s"), "t.emp_id = s.emp_id" ).whenMatchedUpdateAll().execute() # whenMatchedUpdate: explicit control over each column delta_table.alias("t").merge( source.alias("s"), "t.emp_id = s.emp_id" ).whenMatchedUpdate(set={ "salary": "s.salary", "updated_at": F.lit("2024-02-01"), # literal value "dept": "t.dept" # keep target value unchanged }).execute()
What to say: I prefer explicit whenMatchedUpdate with a set dictionary in production because it makes the intent completely clear, every column that changes is listed explicitly. UpdateAll is convenient but if the source accidentally gains a new column that you do not want to propagate to the target, UpdateAll would silently overwrite or fail depending on the schema. Explicit mapping prevents any unintended column updates.
Q2. What is a watermark column and why is it important in MERGE operations?
A watermark column is a column that indicates the recency of a record, typically an updated_at timestamp or a sequence number. In incremental pipelines, the source system may send older versions of records alongside newer ones, for example due to CDC replay, late arriving events, or pipeline retries. Without a watermark condition in the MERGE, an older version of a record could overwrite a newer one that was already loaded into the target.
# Without watermark: old source records can overwrite newer target records delta_table.alias("t").merge( source.alias("s"), "t.emp_id = s.emp_id" ).whenMatchedUpdateAll().execute() # DANGEROUS: if source has old Asha with salary 28000 and # target already has updated Asha with salary 35000, # this would revert Asha's salary back to 28000 # With watermark: only update if source is newer delta_table.alias("t").merge( source.alias("s"), "t.emp_id = s.emp_id AND s.updated_at > t.updated_at" ).whenMatchedUpdateAll().execute() # SAFE: old source records are simply ignored
What to say: The watermark condition is not just a performance optimisation, it is a correctness requirement in any pipeline where the source can send out-of-order records. I treat it as mandatory in every production MERGE I write. Common watermark columns are updated_at as a timestamp, version as an integer that increments on each change, or effective_date as a date for SCD patterns. I always confirm with the source system team whether the watermark column is guaranteed monotonically increasing or whether it can have ties, since ties require additional handling.
Q3. How would you handle a MERGE where the source DataFrame itself contains duplicates on the join key?
Delta MERGE throws an error if the source DataFrame has multiple rows matching the same target row, because it cannot determine which source row should win. You must deduplicate the source before the MERGE.
from pyspark.sql.window import Window # Deduplicate source: keep the latest record per emp_id w = Window.partitionBy("emp_id").orderBy(F.col("updated_at").desc()) df_source_deduped = df_source \ .withColumn("rn", F.row_number().over(w)) \ .filter(F.col("rn") == 1) \ .drop("rn") # Now safe to MERGE with deduped source delta_table.alias("target").merge( df_source_deduped.alias("source"), "target.emp_id = source.emp_id" ).whenMatchedUpdateAll() \ .whenNotMatchedInsertAll() \ .execute()
What to say: Deduplicating the source before MERGE is a non-negotiable step in any production pipeline that processes CDC events or Kafka streams, since both sources commonly produce multiple events for the same key within a single micro-batch. The ROW_NUMBER with a watermark column in the ORDER BY guarantees that the most recent event per key wins, which is exactly the behaviour a MERGE pipeline needs. I always add this deduplication step as a standard part of the pipeline template rather than an afterthought.
Q4. How does MERGE handle schema evolution in Delta Lake?
By default, MERGE enforces the target table schema strictly. If the source has columns that the target does not, MERGE throws an AnalysisException. To allow the target schema to evolve when new columns appear in the source, enable automatic schema evolution.
# Enable schema evolution for MERGE spark.conf.set("spark.databricks.delta.schema.autoMerge.enabled", "true") # Or per operation delta_table.alias("target").merge( df_source_with_new_col.alias("source"), "target.emp_id = source.emp_id" ).whenMatchedUpdateAll() \ .whenNotMatchedInsertAll() \ .execute()
What to say: I treat autoMerge schema evolution as something to enable deliberately after reviewing what the new column is, rather than enabling it globally and accepting any schema change automatically. A new column in the source might represent a meaningful new data point, or it might indicate a bug in the upstream system that generated an unexpected field. Enabling it globally means you accept all schema changes without any review, which can silently corrupt the table schema. I prefer to detect new columns using a schema comparison step and alert before enabling schema evolution for a specific batch.
Q5. What is the performance impact of MERGE on a large Delta table and how do you optimise it?
MERGE on a large Delta table is expensive because it needs to identify which target files contain rows that match the source, read those files, apply the changes, and write new files. Without optimisation this requires scanning the entire target table.
# OPTIMISATION 1: Partition the target table on a column that narrows the merge scan delta_table_partitioned = df_target.write \ .format("delta") \ .partitionBy("dept") \ .mode("overwrite") \ .save("/output/employee_partitioned") # OPTIMISATION 2: Add a partition filter to the merge condition # to enable partition pruning during the merge scan delta_table.alias("t").merge( df_source.alias("s"), """ t.emp_id = s.emp_id AND t.dept = s.dept AND t.dept IN ('Finance', 'IT') """ ).whenMatchedUpdateAll() \ .whenNotMatchedInsertAll() \ .execute() # OPTIMISATION 3: OPTIMIZE the target table before a large MERGE spark.sql(f"OPTIMIZE delta.`{delta_path}` ZORDER BY (emp_id)") # OPTIMISATION 4: Use low shuffle merge for simple upserts # that do not need to check all files in the target spark.conf.set("spark.databricks.delta.merge.enableLowShuffle", "true") # OPTIMISATION 5: Limit source size to only truly changed records # by pre-filtering source to records newer than the last watermark last_run_watermark = spark.sql( f"SELECT MAX(updated_at) FROM delta.`{delta_path}`" ).collect()[0][0] df_source_incremental = df_source.filter( F.col("updated_at") > last_run_watermark )
What to say: The most impactful optimisation for MERGE is ensuring the target table is partitioned on a column that appears in the merge condition, since this allows Delta to skip entire partition directories that cannot contain matching rows. ZORDER on the join key within each partition further reduces the number of files that need to be read by co-locating records with similar key values in the same files. For very frequent merges on large tables, the low shuffle merge option in Databricks Delta reduces the amount of data shuffled during the merge by being smarter about which target partitions need to participate.
Incremental Load Patterns in PySpark
The Interviewer Says
Full table reloads are not practical at scale. Walk me through how you would set up an incremental load pipeline in PySpark. I want to understand how you identify new or changed records, how you track what has already been processed, how you handle late arriving data, and how you write the results without duplicating what is already in the target. This is a very practical question about how real pipelines work.
How to Handle This in the Interview
Before writing anything, say: Incremental load is about processing only the data that changed since the last run rather than reprocessing everything every time. There are three things I need to design carefully for any incremental pipeline. How to identify new or changed records in the source. How to track what was already processed so I know where to start on the next run. And how to write results to the target without creating duplicates.
Step 1: Understand the two main incremental patterns
- Timestamp based incremental reads records where a timestamp column is greater than the last processed timestamp. Change Data Capture reads from a CDC source like Kafka or a SQL Server change table that explicitly tracks inserts, updates, and deletes.
Step 2: Explain watermark tracking
- The watermark is the high-water mark of the last processed record, typically the maximum timestamp seen in the previous run. It is saved to a metadata table or a file after each successful run and read at the start of the next run.
Step 3: Explain how to write without duplicates
- The target write strategy depends on the type of change. For pure inserts use append with a partition on date. For upserts use Delta MERGE. For full overwrites of a time window use replaceWhere.
Step 4: Mention idempotency
- Say: The pipeline must be idempotent, meaning re-running the same pipeline for the same time window should produce the same result as running it once. This protects against failures and reruns without introducing duplicates.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from delta.tables import DeltaTable from datetime import datetime, timedelta spark = SparkSession.builder \ .appName("IncrementalLoadDemo") \ .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") \ .config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog") \ .getOrCreate() # ------------------------------------------------------- # SETUP: Paths and table references # ------------------------------------------------------- source_path = "/source/transactions" target_path = "/output/transactions_target" watermark_path = "/metadata/watermark" # ------------------------------------------------------- # PART 1: Watermark Tracking # ------------------------------------------------------- def get_last_watermark(watermark_path, default="1900-01-01 00:00:00"): try: watermark_df = spark.read.format("delta").load(watermark_path) last_wm = watermark_df \ .agg(F.max("last_processed_time").alias("wm")) \ .collect()[0]["wm"] return str(last_wm) if last_wm else default except Exception: print("No watermark found, using default") return default def save_watermark(new_watermark, watermark_path, pipeline_name): watermark_data = [(pipeline_name, new_watermark, datetime.now().isoformat())] watermark_df = spark.createDataFrame( watermark_data, ["pipeline_name", "last_processed_time", "run_timestamp"] ) watermark_df.write \ .format("delta") \ .mode("append") \ .save(watermark_path) print(f"Watermark saved: {new_watermark}") # ------------------------------------------------------- # PART 2: Pattern 1 - Timestamp Based Incremental Load # ------------------------------------------------------- def run_timestamp_incremental(): pipeline_name = "transactions_incremental" # Step 1: Get last watermark last_watermark = get_last_watermark(watermark_path) print(f"Starting from watermark: {last_watermark}") # Step 2: Read only new/changed records from source source_df = spark.read \ .format("delta") \ .load(source_path) incremental_df = source_df.filter( F.col("updated_at") > last_watermark ) record_count = incremental_df.count() print(f"New records to process: {record_count}") if record_count == 0: print("No new records, pipeline complete") return # Step 3: Apply transformations transformed_df = incremental_df \ .withColumn("load_date", F.current_date()) \ .withColumn("load_timestamp", F.current_timestamp()) # Step 4: Get the new max watermark from this batch new_watermark = transformed_df \ .agg(F.max("updated_at").alias("max_ts")) \ .collect()[0]["max_ts"] # Step 5: Write to target using MERGE for upsert if DeltaTable.isDeltaTable(spark, target_path): target_table = DeltaTable.forPath(spark, target_path) target_table.alias("target").merge( transformed_df.alias("source"), "target.txn_id = source.txn_id" ).whenMatchedUpdateAll() \ .whenNotMatchedInsertAll() \ .execute() else: transformed_df.write \ .format("delta") \ .mode("overwrite") \ .save(target_path) # Step 6: Save new watermark only after successful write save_watermark(str(new_watermark), watermark_path, pipeline_name) print(f"Pipeline complete, new watermark: {new_watermark}") # ------------------------------------------------------- # PART 3: Pattern 2 - Date Partition Based Incremental Load # ------------------------------------------------------- def run_partition_incremental(load_date=None): if not load_date: load_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") print(f"Loading partition for date: {load_date}") source_df = spark.read \ .format("parquet") \ .load(f"{source_path}/date={load_date}") # Write to target partitioned by date # replaceWhere makes this idempotent: re-running for same date # replaces only that date's partition without affecting others source_df.write \ .format("delta") \ .mode("overwrite") \ .option("replaceWhere", f"load_date = '{load_date}'") \ .partitionBy("load_date") \ .save(target_path) print(f"Partition {load_date} loaded successfully") # ------------------------------------------------------- # PART 4: Pattern 3 - CDC Based Incremental Load # ------------------------------------------------------- def process_cdc_batch(cdc_df): # CDC source has an operation column: INSERT, UPDATE, DELETE inserts = cdc_df.filter(F.col("operation") == "INSERT") \ .drop("operation") updates = cdc_df.filter(F.col("operation") == "UPDATE") \ .drop("operation") deletes = cdc_df.filter(F.col("operation") == "DELETE") \ .select("txn_id") target_table = DeltaTable.forPath(spark, target_path) # Process deletes first if deletes.count() > 0: target_table.alias("t").merge( deletes.alias("s"), "t.txn_id = s.txn_id" ).whenMatchedDelete().execute() print(f"Deleted {deletes.count()} records") # Process inserts and updates together as upserts upserts = inserts.union(updates) if upserts.count() > 0: target_table.alias("t").merge( upserts.alias("s"), "t.txn_id = s.txn_id" ).whenMatchedUpdateAll() \ .whenNotMatchedInsertAll() \ .execute() print(f"Upserted {upserts.count()} records") # ------------------------------------------------------- # PART 5: Making the Pipeline Idempotent # ------------------------------------------------------- def idempotent_incremental_load(batch_date): target_table = DeltaTable.forPath(spark, target_path) # Check if this batch was already successfully processed already_processed = target_table.toDF() \ .filter(F.col("load_date") == batch_date) \ .count() if already_processed > 0: print(f"Batch {batch_date} already processed with {already_processed} rows") print("Re-running: replacing existing batch data") source_df = spark.read \ .format("delta") \ .load(source_path) \ .filter(F.col("txn_date") == batch_date) \ .withColumn("load_date", F.lit(batch_date)) # replaceWhere ensures idempotency for partition-based loads source_df.write \ .format("delta") \ .mode("overwrite") \ .option("replaceWhere", f"load_date = '{batch_date}'") \ .save(target_path) print(f"Batch {batch_date} loaded: {source_df.count()} rows") # ------------------------------------------------------- # PART 6: Late Arriving Data Handling # ------------------------------------------------------- def handle_late_arriving_data(lookback_days=3): today = datetime.now().strftime("%Y-%m-%d") lookback_start = (datetime.now() - timedelta(days=lookback_days)) \ .strftime("%Y-%m-%d") print(f"Processing late arriving data from {lookback_start} to {today}") source_df = spark.read \ .format("delta") \ .load(source_path) \ .filter( (F.col("txn_date") >= lookback_start) & (F.col("txn_date") <= today) ) target_table = DeltaTable.forPath(spark, target_path) target_table.alias("t").merge( source_df.alias("s"), "t.txn_id = s.txn_id" ).whenMatchedUpdate( condition="s.updated_at > t.updated_at", set={"salary": "s.salary", "updated_at": "s.updated_at"} ).whenNotMatchedInsertAll() \ .execute() print("Late arriving data processed") # Run the pipeline run_timestamp_incremental()
What to say: The watermark is the most critical piece of an incremental load pipeline. It is the checkpoint that tells the next run where to start. I always save the watermark after a successful write, never before, because if the write fails and I have already saved the new watermark, the next run would skip the failed batch entirely. The save watermark step is the final step after everything else succeeds. I also make sure the pipeline is idempotent using replaceWhere for partition-based loads or MERGE for upsert-based loads, so that re-running a failed pipeline for the same batch produces the same result without duplicating data.
Follow-up Questions & Answers
Q1. What is the difference between a full load and an incremental load, and when would you choose each?
A full load reads the entire source dataset every time and replaces or recreates the entire target table. An incremental load reads only the records that changed since the last run and applies those changes to the target. Full loads are simpler to implement since there is no state to track but they become impractical as data grows because processing a billion row table every hour to get the thousand rows that changed is wasteful. Incremental loads are more complex because they require watermark tracking, idempotency handling, and late data logic, but they scale indefinitely since the work per run is proportional to the volume of changes rather than the total table size. I choose full load for small reference tables where simplicity matters and the data volume is always manageable, and incremental load for any fact table, event log, or large dimension table where data volume grows continuously.
Q2. How would you handle a situation where the source system does not have an updated_at column?
Without an updated_at column you cannot do timestamp-based incremental loading directly. There are three alternative approaches.
# Approach 1: Use a hash of all columns to detect changes source_df = spark.read.format("parquet").load(source_path) source_hashed = source_df.withColumn( "row_hash", F.md5(F.concat_ws("|", *[F.col(c).cast("string") for c in source_df.columns])) ) target_hashed = spark.read.format("delta").load(target_path) \ .withColumn("row_hash", F.md5(F.concat_ws("|", *[...]))) # Find rows where hash differs changed = source_hashed.join(target_hashed, on="primary_key", how="left") \ .filter( target_hashed["row_hash"].isNull() | (source_hashed["row_hash"] != target_hashed["row_hash"]) ) # Approach 2: CDC from the database transaction log # SQL Server: use Change Data Capture or Change Tracking # Oracle: use LogMiner # PostgreSQL: use Debezium # Approach 3: Full comparison with EXCEPT df_new_or_changed = source_df.exceptAll( spark.read.format("delta").load(target_path) )
What to say: The hash-based approach is my go-to when the source has no change tracking column, since it detects any change to any column regardless of what the source schema looks like. The downside is that it requires reading the full source on every run to compute hashes, which means it does not fully avoid the full scan cost. True incremental loading is only possible when the source system supports change tracking in some form, either through timestamps, sequence numbers, or CDC.
Q3. What is replaceWhere in Delta and why is it better than mode overwrite for partition-based incremental loads?
mode overwrite with no options replaces the entire Delta table including all partitions and all historical data. replaceWhere overwrites only the rows that match a specific condition, leaving all other rows untouched.
# WRONG for incremental: overwrites the entire table df.write.format("delta").mode("overwrite").save(target_path) # Destroys data from all other partitions # RIGHT for incremental: only overwrites the specific date partition df.write \ .format("delta") \ .mode("overwrite") \ .option("replaceWhere", "load_date = '2024-02-01'") \ .save(target_path) # Only touches rows where load_date = 2024-02-01 # All other partitions are completely unchanged # Also works for a date range df.write \ .format("delta") \ .mode("overwrite") \ .option("replaceWhere", "load_date BETWEEN '2024-02-01' AND '2024-02-07'") \ .save(target_path)
What to say: replaceWhere is what makes partition-based incremental loads truly idempotent. Re-running the pipeline for February 1st simply replaces the February 1st data with itself, leaving January 31st and February 2nd completely untouched. Without replaceWhere, a re-run using plain overwrite would delete the entire historical table and load only the current day, which is catastrophic for a partitioned fact table. I treat replaceWhere as the mandatory write mode for any partition-based incremental pipeline.
Q4. How would you monitor an incremental load pipeline to detect when it is falling behind or missing batches?
def pipeline_health_check(target_path, expected_daily_records, alert_threshold=0.8): target_df = spark.read.format("delta").load(target_path) # Check 1: Most recent load date should be yesterday or today latest_date = target_df \ .agg(F.max("load_date").alias("latest")) \ .collect()[0]["latest"] today = datetime.now().date() yesterday = today - timedelta(days=1) if latest_date < str(yesterday): print(f"ALERT: Latest data is from {latest_date}, expected {yesterday}") # Check 2: Record count for latest date should be within expected range latest_count = target_df \ .filter(F.col("load_date") == str(latest_date)) \ .count() if latest_count < expected_daily_records * alert_threshold: print(f"ALERT: Only {latest_count} records for {latest_date}, " f"expected at least {int(expected_daily_records * alert_threshold)}") # Check 3: Check for gaps in load dates date_coverage = target_df \ .select("load_date") \ .distinct() \ .orderBy("load_date") dates = [row["load_date"] for row in date_coverage.collect()] for i in range(1, len(dates)): gap = (datetime.strptime(dates[i], "%Y-%m-%d") - datetime.strptime(dates[i-1], "%Y-%m-%d")).days if gap > 1: print(f"ALERT: Gap detected between {dates[i-1]} and {dates[i]}") print("Health check complete") pipeline_health_check(target_path, expected_daily_records=10000)
What to say: Monitoring an incremental pipeline is just as important as building it. The three checks I always implement are freshness, meaning the latest data is not too old, volume, meaning the record count for each batch is within expected bounds, and completeness, meaning there are no gaps in the date sequence. These checks run after every pipeline execution and write results to a monitoring table that feeds a dashboard. Any alert triggers a Slack or email notification so the team can investigate before downstream consumers notice missing data.
Q5. How would you design an incremental load that handles schema changes in the source gracefully?
Schema changes in the source, like new columns being added or existing columns being renamed, are one of the most common causes of incremental pipeline failures.
def schema_aware_incremental_load(source_path, target_path): source_df = spark.read.format("delta").load(source_path) # Check if target exists if not DeltaTable.isDeltaTable(spark, target_path): source_df.write.format("delta").mode("overwrite").save(target_path) print("Initial load complete") return target_df = spark.read.format("delta").load(target_path) # Detect schema differences source_cols = set(source_df.columns) target_cols = set(target_df.columns) new_cols = source_cols - target_cols removed_cols = target_cols - source_cols if new_cols: print(f"New columns detected: {new_cols}") # Use mergeSchema to allow adding new columns source_df.write \ .format("delta") \ .mode("append") \ .option("mergeSchema", "true") \ .save(target_path) print("Schema evolved to include new columns") elif removed_cols: print(f"WARNING: Source is missing columns: {removed_cols}") # Add missing columns as NULL to keep schema consistent for col_name in removed_cols: source_df = source_df.withColumn(col_name, F.lit(None)) # Proceed with normal MERGE after handling schema target_table = DeltaTable.forPath(spark, target_path) target_table.alias("t").merge( source_df.alias("s"), "t.primary_key = s.primary_key" ).whenMatchedUpdateAll() \ .whenNotMatchedInsertAll() \ .execute() schema_aware_incremental_load(source_path, target_path)
What to say: Schema change handling is the difference between a fragile pipeline that fails on the first schema evolution and a robust one that adapts gracefully. New columns are handled with mergeSchema, which adds them to the target schema and backfills NULL for existing rows. Removed columns are handled by adding them back as NULL to the source DataFrame so the schema stays consistent and existing data is not corrupted. Renamed columns are the hardest case since there is no way to automatically detect a rename versus a delete plus an add, so I always add a schema validation alert that notifies the team when columns disappear, allowing a human to decide whether it is a rename or a genuine removal before the pipeline proceeds.
Medallion Architecture
The Interviewer Says
You mentioned medallion architecture on your resume. Explain what it is, what each layer contains and why, and then walk me through a concrete example of data flowing through all three layers. I also want to understand what transformations happen at each layer and how Delta Lake fits into this architecture.
How to Handle This in the Interview
Before answering, say: Medallion architecture is a data lake design pattern that organises data into three progressively refined layers called Bronze, Silver, and Gold. Each layer has a specific contract about data quality, structure, and purpose. The core idea is to separate raw ingestion from curation from business consumption, so that each concern can be managed independently without one layer affecting another.
Step 1: Define each layer clearly and concisely
- Bronze is raw, Silver is clean and conformed, Gold is business ready and aggregated. These three words summarise the pattern well enough to anchor the rest of the explanation.
Step 2: Explain why three layers instead of one
- Say: The reason you do not transform raw data directly into business-ready gold is because you lose the ability to reprocess if your transformation logic had a bug. Bronze is your safety net. If a transformation in Silver was wrong, you can fix the logic and rerun from Bronze without going back to the source system.
Step 3: Walk through a concrete end-to-end example
- Use a simple e-commerce transaction pipeline where raw Kafka events flow from Bronze through Silver to Gold as an example. Concrete examples always land better than abstract descriptions.
Step 4: Explain how Delta Lake enables this architecture
- Delta's ACID transactions, schema enforcement, and time travel make it the ideal storage format for each layer, since it gives you reliability and recoverability at every stage of the pipeline.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import ( StructType, StructField, StringType, IntegerType, TimestampType, DoubleType ) from delta.tables import DeltaTable spark = SparkSession.builder \ .appName("MedallionArchitecture") \ .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") \ .config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog") \ .getOrCreate() # ------------------------------------------------------- # PATHS for each layer # ------------------------------------------------------- bronze_path = "/datalake/bronze/transactions" silver_path = "/datalake/silver/transactions" gold_path = "/datalake/gold/customer_spend_summary" # ------------------------------------------------------- # BRONZE LAYER: Raw ingestion, no transformation # ------------------------------------------------------- # Purpose : Land raw data exactly as received from source # What goes : Raw events, no parsing, no cleaning, no schema enforcement # Format : Delta (for reliability and time travel) # Schema : Minimal or inferred, preserve the source as-is # SLA : Fast ingestion, low latency # Who reads : Silver pipeline only print("=" * 50) print("BRONZE LAYER: Raw ingestion") print("=" * 50) # Simulating raw Kafka JSON messages arriving from source raw_events = [ ('{"txn_id":"T01","customer_id":"C01","product":"Laptop","amount":"75000","txn_time":"2024-01-05 09:00:00","region":"North"}',), ('{"txn_id":"T02","customer_id":"C02","product":"Phone","amount":"25000","txn_time":"2024-01-06 10:00:00","region":"South"}',), ('{"txn_id":"T03","customer_id":"C01","product":"Headphones","amount":"invalid_amount","txn_time":"2024-01-07 11:00:00","region":"North"}',), ('{"txn_id":"T04","customer_id":"C03","product":"Monitor","amount":"15000","txn_time":"2024-01-08","region":null}',), ('{"txn_id":"T05","customer_id":"C04","product":"Keyboard","amount":"5000","txn_time":"2024-01-09 09:00:00","region":"South"}',), ('corrupted_json_record',), ] # Bronze: store raw payload with minimal metadata df_bronze = spark.createDataFrame(raw_events, ["raw_payload"]) \ .withColumn("ingestion_timestamp", F.current_timestamp()) \ .withColumn("source_system", F.lit("kafka_transactions")) \ .withColumn("load_date", F.current_date()) df_bronze.write \ .format("delta") \ .mode("append") \ .partitionBy("load_date") \ .save(bronze_path) print("Bronze layer contents (raw, unprocessed):") df_bronze.show(truncate=False) print(f"Bronze row count: {df_bronze.count()}") # ------------------------------------------------------- # SILVER LAYER: Cleaned, parsed, validated # ------------------------------------------------------- # Purpose : Parse, validate, deduplicate, conform data # What goes : Cleaned rows with proper types, nulls handled, # duplicates removed, schema enforced # Format : Delta with schema enforcement # Schema : Strict, all columns typed correctly # SLA : Within minutes to hours of Bronze # Who reads : Gold pipeline, data scientists, analysts needing raw events print("=" * 50) print("SILVER LAYER: Cleaned and validated") print("=" * 50) # Define target schema for Silver transaction_schema = StructType([ StructField("txn_id", StringType(), True), StructField("customer_id", StringType(), True), StructField("product", StringType(), True), StructField("amount", DoubleType(), True), StructField("txn_time", TimestampType(), True), StructField("region", StringType(), True), ]) # Read from Bronze df_bronze_read = spark.read \ .format("delta") \ .load(bronze_path) # Step 1: Parse JSON payload df_parsed = df_bronze_read.withColumn( "parsed", F.from_json(F.col("raw_payload"), transaction_schema) ) # Step 2: Extract fields from parsed struct df_extracted = df_parsed.select( F.col("parsed.txn_id").alias("txn_id"), F.col("parsed.customer_id").alias("customer_id"), F.col("parsed.product").alias("product"), F.col("parsed.amount").alias("amount_raw"), F.col("parsed.txn_time").alias("txn_time_raw"), F.col("parsed.region").alias("region"), F.col("ingestion_timestamp"), F.col("load_date") ) # Step 3: Separate good records from bad records df_valid = df_extracted.filter( F.col("txn_id").isNotNull() & F.col("customer_id").isNotNull() & F.col("amount_raw").isNotNull() ) df_quarantine = df_extracted.filter( F.col("txn_id").isNull() | F.col("customer_id").isNull() | F.col("amount_raw").isNull() ).withColumn("rejection_reason", F.when(F.col("txn_id").isNull(), "Missing txn_id") .when(F.col("customer_id").isNull(), "Missing customer_id") .otherwise("Missing amount") ) print(f"Valid records: {df_valid.count()}") print(f"Quarantined records: {df_quarantine.count()}") # Write quarantined records to a separate location for investigation df_quarantine.write \ .format("delta") \ .mode("append") \ .save("/datalake/quarantine/transactions") # Step 4: Apply transformations on valid records df_silver = df_valid \ .withColumn( "amount", F.col("amount_raw").cast(DoubleType()) ) \ .withColumn( "txn_time", F.coalesce( F.to_timestamp(F.col("txn_time_raw"), "yyyy-MM-dd HH:mm:ss"), F.to_timestamp(F.col("txn_time_raw"), "yyyy-MM-dd") ) ) \ .withColumn( "region", F.coalesce(F.col("region"), F.lit("Unknown")) ) \ .withColumn( "amount_category", F.when(F.col("amount") >= 50000, "High") .when(F.col("amount") >= 10000, "Medium") .otherwise("Low") ) \ .withColumn("silver_load_timestamp", F.current_timestamp()) \ .select( "txn_id", "customer_id", "product", "amount", "txn_time", "region", "amount_category", "load_date", "silver_load_timestamp" ) # Step 5: Deduplicate on txn_id from pyspark.sql.window import Window w_dedup = Window.partitionBy("txn_id").orderBy(F.col("txn_time").desc()) df_silver_deduped = df_silver \ .withColumn("rn", F.row_number().over(w_dedup)) \ .filter(F.col("rn") == 1) \ .drop("rn") # Step 6: MERGE into Silver Delta table if DeltaTable.isDeltaTable(spark, silver_path): silver_table = DeltaTable.forPath(spark, silver_path) silver_table.alias("t").merge( df_silver_deduped.alias("s"), "t.txn_id = s.txn_id" ).whenMatchedUpdateAll() \ .whenNotMatchedInsertAll() \ .execute() else: df_silver_deduped.write \ .format("delta") \ .mode("overwrite") \ .partitionBy("load_date") \ .save(silver_path) print("Silver layer contents (cleaned, validated):") spark.read.format("delta").load(silver_path).show(truncate=False) # ------------------------------------------------------- # GOLD LAYER: Business-ready aggregations # ------------------------------------------------------- # Purpose : Aggregate, join, and shape data for specific business needs # What goes : Pre-aggregated summary tables, dimensional models, # feature tables for ML, reports # Format : Delta # Schema : Business friendly names, pre-joined, pre-aggregated # SLA : Within hours of Silver # Who reads : BI tools, dashboards, data analysts, business users print("=" * 50) print("GOLD LAYER: Business-ready aggregations") print("=" * 50) df_silver_read = spark.read.format("delta").load(silver_path) # Gold Table 1: Customer spend summary df_gold_customer = df_silver_read \ .groupBy("customer_id") \ .agg( F.sum("amount").alias("total_spend"), F.count("*").alias("total_transactions"), F.avg("amount").alias("avg_transaction_value"), F.max("txn_time").alias("last_transaction_date"), F.min("txn_time").alias("first_transaction_date"), F.countDistinct("product").alias("unique_products_bought") ) \ .withColumn( "customer_segment", F.when(F.col("total_spend") >= 100000, "Premium") .when(F.col("total_spend") >= 50000, "Standard") .otherwise("Basic") ) \ .withColumn("gold_load_timestamp", F.current_timestamp()) df_gold_customer.write \ .format("delta") \ .mode("overwrite") \ .save(gold_path + "/customer_summary") print("Gold: Customer spend summary:") df_gold_customer.show() # Gold Table 2: Daily regional sales report df_gold_regional = df_silver_read \ .withColumn("txn_date", F.to_date(F.col("txn_time"))) \ .groupBy("txn_date", "region") \ .agg( F.sum("amount").alias("daily_revenue"), F.count("*").alias("transaction_count"), F.countDistinct("customer_id").alias("unique_customers"), F.avg("amount").alias("avg_order_value") ) \ .orderBy("txn_date", "region") df_gold_regional.write \ .format("delta") \ .mode("overwrite") \ .save(gold_path + "/regional_daily_summary") print("Gold: Regional daily summary:") df_gold_regional.show() # Gold Table 3: Product performance report df_gold_product = df_silver_read \ .groupBy("product", "amount_category") \ .agg( F.count("*").alias("units_sold"), F.sum("amount").alias("total_revenue"), F.countDistinct("customer_id").alias("unique_buyers") ) \ .orderBy(F.col("total_revenue").desc()) df_gold_product.write \ .format("delta") \ .mode("overwrite") \ .save(gold_path + "/product_performance") print("Gold: Product performance:") df_gold_product.show()
What to say: The most important principle of medallion architecture is that data only flows forward from Bronze to Silver to Gold, never backward. Gold never reads directly from Bronze. Silver never writes to Bronze. This strict unidirectional flow means any bug in a transformation only needs to be fixed in one layer and re-run forward. If Silver had a bug in how it parsed timestamps, I fix the Silver transformation logic and rerun Silver from Bronze, then rerun Gold from Silver. The Bronze data is untouched and serves as the permanent recovery point. This separation of concerns is what makes large-scale data lake pipelines maintainable over years rather than months.
Follow-up Questions & Answers
Q1. What is the contract each layer must uphold and what happens when data is rejected?
Bronze has a minimal contract: accept everything from the source exactly as it arrives. It never rejects data, never transforms it, and never applies schema enforcement. Its only job is to land data reliably with a timestamp and source identifier. Silver has a stricter contract: every row must pass schema validation, have no duplicates on the natural key, and have no critical null values in mandatory fields. Rows that fail Silver validation go to a quarantine table rather than being dropped silently. Gold has the strictest business contract: every table must match the agreed schema for the business intelligence or reporting tool that consumes it.
# Quarantine table structure quarantine_schema = StructType([ StructField("raw_payload", StringType(), True), StructField("rejection_reason", StringType(), True), StructField("rejected_at", TimestampType(), True), StructField("source_layer", StringType(), True), StructField("pipeline_run_id", StringType(), True), ]) # Write rejected records to quarantine for investigation df_quarantine.withColumn("rejected_at", F.current_timestamp()) \ .withColumn("source_layer", F.lit("Bronze to Silver")) \ .write \ .format("delta") \ .mode("append") \ .save("/datalake/quarantine/transactions")
What to say: The quarantine table is as important as the main pipeline tables. In production I monitor the quarantine table's row count as a data quality metric, alerted on via the same monitoring system as other pipeline health checks. A sudden spike in quarantine records almost always indicates something changed upstream, either in the source system's schema, the data format, or the business rules, and it is far better to catch this in the quarantine than to silently drop records from the pipeline.
Q2. How would you handle a situation where the Silver transformation logic had a bug and needs to be reprocessed from Bronze?
# Step 1: Identify the affected date range from DESCRIBE HISTORY silver_table = DeltaTable.forPath(spark, silver_path) silver_table.history().filter( F.col("timestamp") >= "2024-01-01" ).show() # Step 2: Delete the affected Silver data for the date range silver_table.delete( condition="load_date BETWEEN '2024-01-01' AND '2024-01-07'" ) # Step 3: Re-read from Bronze for the same date range df_bronze_reprocess = spark.read \ .format("delta") \ .load(bronze_path) \ .filter( F.col("load_date").between("2024-01-01", "2024-01-07") ) # Step 4: Apply corrected Silver transformation df_silver_corrected = apply_silver_transformation(df_bronze_reprocess) # Step 5: Write corrected data to Silver df_silver_corrected.write \ .format("delta") \ .mode("append") \ .save(silver_path) # Step 6: Rerun Gold from the corrected Silver rerun_gold_pipeline("2024-01-01", "2024-01-07")
What to say: This reprocessing workflow is exactly why Bronze exists as a permanent raw layer. Without Bronze, a bug in the transformation logic would require going back to the source system and re-extracting the historical data, which may not be possible if the source does not retain historical data. With Bronze, reprocessing is always possible by simply re-running the Silver and Gold pipelines from the already-stored Bronze data. This is the core value proposition of the medallion architecture over a traditional ETL approach that transforms data before storing it.
Q3. How does the medallion architecture differ from a traditional data warehouse star schema?
A traditional data warehouse stores data in a tightly defined star schema with fact and dimension tables, where data is transformed and modelled before loading. The structure is optimised for query performance and business reporting but is rigid. Adding a new attribute to a dimension requires schema changes and often ETL changes. A medallion architecture is more flexible because Bronze accepts any structure, and the schema evolution happens progressively through Silver toward Gold. The Gold layer of a medallion architecture can implement a star schema for BI consumption, making the two approaches complementary rather than competing. In practice many organisations use a medallion architecture in their data lake and then load the Gold layer into a traditional Redshift or SQL Server data warehouse for legacy BI tools that expect a star schema.
Q4. What is the difference between a hot path and a cold path in the context of medallion architecture?
A hot path processes data with low latency, typically in seconds to minutes, and is used for real-time dashboards, operational alerts, and time-sensitive decisions. A cold path processes data in batches, typically in hours, and is used for historical analysis and complex aggregations that do not need to be current to the second. In a medallion architecture, Bronze can serve both paths. The hot path reads from Bronze using Structured Streaming and writes to a real-time Silver and Gold table. The cold path reads from Bronze as a batch job, applies more expensive transformations, and writes to a historical Silver and Gold table. Delta Lake supports both patterns natively since the same Delta table can be read by both streaming and batch consumers simultaneously.
# HOT PATH: streaming from Bronze to Silver streaming_query = spark.readStream \ .format("delta") \ .load(bronze_path) \ .writeStream \ .format("delta") \ .option("checkpointLocation", "/checkpoints/hot_silver") \ .outputMode("append") \ .trigger(processingTime="1 minute") \ .start(silver_path + "_realtime") # COLD PATH: batch from Bronze to Silver spark.read.format("delta").load(bronze_path) \ .filter(F.col("load_date") == yesterday) \ .write.format("delta").mode("append").save(silver_path + "_historical")
Q5. How would you implement data lineage tracking across all three medallion layers?
Data lineage tracks where each record came from, what transformations were applied, and when it moved between layers. This is critical for debugging data quality issues, regulatory compliance, and impact analysis when upstream schemas change.
def add_lineage_columns(df, layer_name, source_path, pipeline_run_id): return df \ .withColumn("_layer", F.lit(layer_name)) \ .withColumn("_source_path", F.lit(source_path)) \ .withColumn("_pipeline_run_id", F.lit(pipeline_run_id)) \ .withColumn("_load_timestamp", F.current_timestamp()) \ .withColumn("_load_date", F.current_date()) from uuid import uuid4 run_id = str(uuid4()) df_bronze_with_lineage = add_lineage_columns( df_bronze, "bronze", "kafka://transactions", run_id ) df_silver_with_lineage = add_lineage_columns( df_silver, "silver", bronze_path, run_id ) df_gold_with_lineage = add_lineage_columns( df_gold_customer, "gold", silver_path, run_id )
What to say: The pipeline_run_id that I generate at the start of each run and propagate through all three layers is the most valuable lineage column. If a Gold table shows wrong numbers, I can filter by run_id to find all Bronze, Silver, and Gold rows that were processed in that specific pipeline run, immediately seeing which input records contributed to the wrong output. Without a consistent run_id threading through all layers, debugging a cross-layer data quality issue requires correlating records by timestamp, which is much harder and error-prone on a busy pipeline that runs every hour.
File System Operations and ADLS Management in PySpark
The Interviewer Says
In a real data engineering role you often need to manage files and folders in cloud storage as part of your pipelines. Walk me through how you would list, copy, move, and delete files and folders in Azure Data Lake Storage using PySpark and Databricks utilities. Also tell me how you would handle situations like deleting old partitions, checking if a path exists before writing, and cleaning up temporary files after a pipeline run.
How to Handle This in the Interview
Before writing anything, say: File system operations in Databricks and Microsoft Fabric are primarily done through dbutils.fs which provides a Pythonic API for ADLS, S3, and GCS operations without needing to manage credentials directly. For non-Databricks environments like AWS EMR or standalone Spark, the equivalent is the Hadoop FileSystem API accessed through the Spark context or the subprocess module for cloud CLI commands.
Step 1: Know the dbutils.fs methods
- ls for listing, cp for copying, mv for moving, rm for removing, mkdirs for creating directories, put for writing small files, head for reading the first N bytes of a file.
Step 2: Know how to check if a path exists
- dbutils.fs has no direct exists method. The pattern is to wrap a dbutils.fs.ls call in a try-except block and catch the exception that occurs when the path does not exist.
Step 3: Know when to use dbutils vs Hadoop FileSystem API
- dbutils is simpler and works in Databricks and Fabric notebooks. The Hadoop FileSystem API is portable across any Spark environment and is better for production jobs that run outside notebooks.
Step 4: Mention the security context
- In Databricks, dbutils operations use the same service principal or managed identity configured for the cluster, so no additional credential management is needed. In raw PySpark on EMR or HDInsight you need to configure storage credentials through Spark configuration.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F import subprocess from datetime import datetime, timedelta spark = SparkSession.builder \ .appName("FileSystemOperations") \ .getOrCreate() # ------------------------------------------------------- # PART 1: dbutils.fs operations (Databricks / Fabric) # ------------------------------------------------------- # NOTE: dbutils is available automatically in Databricks/Fabric notebooks # For standalone scripts, access it via: # from pyspark.dbutils import DBUtils # dbutils = DBUtils(spark) adls_path = "abfss://container@storageaccount.dfs.core.windows.net/data" # List contents of a directory print("Listing files:") files = dbutils.fs.ls(adls_path) for f in files: print(f" {f.name:<40} {f.size:>15} bytes {f.path}") # List recursively (get all files including subdirectories) def list_all_files(path): all_files = [] items = dbutils.fs.ls(path) for item in items: if item.name.endswith("/"): all_files.extend(list_all_files(item.path)) else: all_files.append(item) return all_files all_files = list_all_files(adls_path) print(f"Total files: {len(all_files)}") # Check if a path exists (no direct exists() method in dbutils) def path_exists(path): try: dbutils.fs.ls(path) return True except Exception as e: if "java.io.FileNotFoundException" in str(e) or \ "The specified path does not exist" in str(e): return False raise # re-raise unexpected exceptions if path_exists(f"{adls_path}/transactions"): print("Path exists") else: print("Path does not exist") # Create a directory dbutils.fs.mkdirs(f"{adls_path}/new_folder") print("Directory created") # Copy a file or directory dbutils.fs.cp( f"{adls_path}/source_file.parquet", f"{adls_path}/backup/source_file.parquet" ) print("File copied") # Copy a directory recursively dbutils.fs.cp( f"{adls_path}/transactions/", f"{adls_path}/transactions_backup/", recurse=True ) print("Directory copied recursively") # Move a file (rename) dbutils.fs.mv( f"{adls_path}/temp_file.parquet", f"{adls_path}/final_file.parquet" ) print("File moved") # Delete a single file dbutils.fs.rm(f"{adls_path}/temp_file.parquet") print("File deleted") # Delete a directory and all its contents (recurse=True is mandatory for directories) dbutils.fs.rm(f"{adls_path}/temp_folder/", recurse=True) print("Directory deleted") # Write a small text file directly dbutils.fs.put( f"{adls_path}/metadata/watermark.txt", "2024-01-15 12:00:00", overwrite=True ) print("Watermark file written") # Read content of a small file content = dbutils.fs.head(f"{adls_path}/metadata/watermark.txt", 100) print(f"Watermark content: {content}") # ------------------------------------------------------- # PART 2: Hadoop FileSystem API (works in any Spark environment) # ------------------------------------------------------- # Access Hadoop FileSystem through SparkContext sc = spark.sparkContext hadoop_conf = sc._jvm.org.apache.hadoop.conf.Configuration() fs = sc._jvm.org.apache.hadoop.fs.FileSystem.get(hadoop_conf) Path = sc._jvm.org.apache.hadoop.fs.Path # Check if path exists using Hadoop API def hadoop_path_exists(path_str): return fs.exists(Path(path_str)) print(f"Path exists: {hadoop_path_exists('/output/transactions')}") # Delete using Hadoop API (recursive=True for directories) def delete_path(path_str, recursive=True): if hadoop_path_exists(path_str): fs.delete(Path(path_str), recursive) print(f"Deleted: {path_str}") else: print(f"Path not found, skipping: {path_str}") delete_path("/output/temp_folder", recursive=True) # List files using Hadoop API def list_files_hadoop(path_str): status = fs.listStatus(Path(path_str)) return [(str(s.getPath()), s.getLen()) for s in status] files = list_files_hadoop("/output/transactions") for path, size in files: print(f" {path}: {size} bytes") # ------------------------------------------------------- # PART 3: Production use cases # ------------------------------------------------------- # USE CASE 1: Check and create output path before writing def safe_write_delta(df, output_path, mode="overwrite"): if not path_exists(output_path): dbutils.fs.mkdirs(output_path) print(f"Created directory: {output_path}") df.write \ .format("delta") \ .mode(mode) \ .save(output_path) print(f"Data written to: {output_path}") safe_write_delta(df_silver, f"{adls_path}/silver/transactions") # USE CASE 2: Delete old partitions to manage storage costs def delete_old_partitions(base_path, partition_col, retention_days=90): cutoff_date = (datetime.now() - timedelta(days=retention_days)) \ .strftime("%Y-%m-%d") print(f"Deleting partitions older than {cutoff_date}") try: partitions = dbutils.fs.ls(base_path) deleted_count = 0 for partition in partitions: partition_name = partition.name.rstrip("/") if "=" in partition_name: partition_value = partition_name.split("=")[1] if partition_value < cutoff_date: dbutils.fs.rm(partition.path, recurse=True) print(f" Deleted partition: {partition_name}") deleted_count += 1 print(f"Total partitions deleted: {deleted_count}") except Exception as e: print(f"Error during partition cleanup: {e}") delete_old_partitions( f"{adls_path}/transactions", partition_col="load_date", retention_days=90 ) # USE CASE 3: Cleanup temp files after pipeline run def cleanup_temp_files(temp_paths): for path in temp_paths: if path_exists(path): dbutils.fs.rm(path, recurse=True) print(f"Cleaned up: {path}") else: print(f"Already clean: {path}") temp_paths = [ f"{adls_path}/_temp/", f"{adls_path}/_staging/", f"{adls_path}/_checkpoint_old/", ] cleanup_temp_files(temp_paths) # USE CASE 4: Get total size of a directory def get_directory_size(path): try: files = list_all_files(path) total_bytes = sum(f.size for f in files) total_gb = total_bytes / (1024 ** 3) return total_bytes, total_gb except Exception: return 0, 0.0 total_bytes, total_gb = get_directory_size(f"{adls_path}/transactions") print(f"Directory size: {total_bytes:,} bytes ({total_gb:.2f} GB)") # USE CASE 5: Archive processed files by moving to archive folder def archive_processed_files(source_path, archive_path, date_str): archive_dest = f"{archive_path}/{date_str}/" dbutils.fs.mkdirs(archive_dest) dbutils.fs.cp(source_path, archive_dest, recurse=True) dbutils.fs.rm(source_path, recurse=True) print(f"Archived {source_path} to {archive_dest}") archive_processed_files( source_path=f"{adls_path}/landing/2024-01-15/", archive_path=f"{adls_path}/archive/", date_str="2024-01-15" ) # USE CASE 6: Read all Parquet files from a directory into one DataFrame def read_all_parquet(path): if not path_exists(path): print(f"Path not found: {path}") return None files = [f.path for f in dbutils.fs.ls(path) if f.name.endswith(".parquet")] if not files: print(f"No parquet files found in: {path}") return None df = spark.read.parquet(*files) print(f"Read {len(files)} parquet files, {df.count()} rows") return df df_loaded = read_all_parquet(f"{adls_path}/transactions/")
What to say: In a production Databricks or Fabric pipeline I use dbutils.fs for all file operations because it handles authentication through the cluster's managed identity automatically, I do not need to manage SAS tokens or service principal secrets in the code itself. The most important habit is always checking if a path exists before attempting to delete or read from it, since a missing path throws an exception that would fail the pipeline. I wrap all file system operations in helper functions with proper error handling so the pipeline can log and continue rather than crashing on a missing temp folder.
Follow-up Questions & Answers
Q1. What is the difference between dbutils.fs.rm and dbutils.fs.mv, and when would you use each?
dbutils.fs.rm permanently deletes a file or directory. Once deleted it cannot be recovered unless Delta time travel is available for Delta tables, or unless the underlying cloud storage has soft delete enabled. dbutils.fs.mv moves or renames a file or directory, which is equivalent to a copy followed by a delete but implemented more efficiently when source and destination are in the same storage account since it becomes a metadata operation rather than a data copy. Use rm for cleanup of genuinely unneeded files like temp directories and staging outputs. Use mv for renaming, reorganising, or archiving files where you want to change their location but not permanently delete them.
Q2. How would you handle ADLS file operations when not using Databricks, for example on AWS EMR with S3?
# AWS S3 using boto3 (Python AWS SDK) import boto3 s3 = boto3.client("s3") bucket = "my-data-lake" # List objects response = s3.list_objects_v2(Bucket=bucket, Prefix="data/transactions/") for obj in response.get("Contents", []): print(obj["Key"], obj["Size"]) # Delete object s3.delete_object(Bucket=bucket, Key="data/temp/file.parquet") # Delete a prefix (folder equivalent) objects = s3.list_objects_v2(Bucket=bucket, Prefix="data/temp/") for obj in objects.get("Contents", []): s3.delete_object(Bucket=bucket, Key=obj["Key"]) # Using Spark Hadoop API (works on any cloud) sc = spark.sparkContext hadoop_fs = sc._jvm.org.apache.hadoop.fs.FileSystem \ .get(sc._jsc.hadoopConfiguration()) Path = sc._jvm.org.apache.hadoop.fs.Path hadoop_fs.delete(Path("s3://bucket/data/temp/"), True)
What to say: The Hadoop FileSystem API is the most portable approach since it works with any storage backend that has a Hadoop compatible connector, including ADLS, S3, GCS, and HDFS, with no code changes required beyond the path scheme prefix. The trade-off is that the Java interop syntax is verbose and less intuitive than boto3 or dbutils. In AWS-based pipelines I prefer boto3 for file management operations since it gives more control and better error messages than the Hadoop FileSystem API.
Q3. How would you safely delete a Delta table partition without breaking Delta metadata?
Never use dbutils.fs.rm to delete individual Parquet files from a Delta table, since this breaks the transaction log which still references those deleted files and causes AnalysisException when reading.
# WRONG: directly deleting Delta files breaks the transaction log dbutils.fs.rm("/output/transactions/load_date=2024-01-01", recurse=True) # This causes errors when reading the table later # CORRECT: use Delta's built-in partition management from delta.tables import DeltaTable delta_table = DeltaTable.forPath(spark, "/output/transactions") # Delete specific partition using Delta's delete operation delta_table.delete(condition="load_date = '2024-01-01'") print("Partition deleted through Delta, transaction log updated correctly") # For very old partitions, use VACUUM to physically remove the files # after the logical delete spark.sql("VACUUM delta.`/output/transactions` RETAIN 0 HOURS DRY RUN").show() spark.sql("VACUUM delta.`/output/transactions` RETAIN 168 HOURS")
What to say: This is one of the most common mistakes I see with Delta tables. Using dbutils.fs.rm on a Delta table directory removes the Parquet files but leaves the transaction log intact, which still references those files. The next read operation then fails because Delta tries to open files that no longer exist. The correct approach is always to use Delta's own delete operation which updates the transaction log atomically, marking the files as removed in the log before VACUUM eventually cleans them up physically.
Q4. How would you implement a file-based pipeline trigger, meaning start processing when a new file arrives in a specific folder?
# Pattern 1: Structured Streaming with file source # Automatically detects new files added to a directory streaming_df = spark.readStream \ .format("parquet") \ .schema(schema) \ .option("maxFilesPerTrigger", 10) \ .load(f"{adls_path}/landing/") # Process each new file as it arrives streaming_df.writeStream \ .format("delta") \ .option("checkpointLocation", "/checkpoints/file_trigger") \ .outputMode("append") \ .start(silver_path) # Pattern 2: Polling approach for batch pipelines def wait_for_file(expected_path, timeout_minutes=60, poll_interval_seconds=30): import time deadline = datetime.now() + timedelta(minutes=timeout_minutes) while datetime.now() < deadline: if path_exists(expected_path): print(f"File detected: {expected_path}") return True print(f"Waiting for {expected_path}... polling again in {poll_interval_seconds}s") time.sleep(poll_interval_seconds) print(f"Timeout: file not found after {timeout_minutes} minutes") return False if wait_for_file(f"{adls_path}/landing/2024-01-15/_SUCCESS"): print("Processing today's landing files") run_timestamp_incremental()
What to say: Structured Streaming with the file source is the cleanest approach for file-triggered pipelines since it natively detects new files using the checkpoint mechanism without any manual polling. The maxFilesPerTrigger option prevents one very large drop of files from overwhelming a single micro-batch. The polling approach is a simpler alternative for batch pipelines where you know a file should arrive by a certain time and just need to wait for it before triggering a batch job, which is common when the source is a daily export from a legacy system with unpredictable delivery times.
Q5. How would you audit all file operations in a production pipeline to maintain a record of what was created, moved, or deleted?
from pyspark.sql.types import StructType, StructField, StringType, TimestampType, LongType audit_schema = StructType([ StructField("operation", StringType(), True), StructField("source_path", StringType(), True), StructField("target_path", StringType(), True), StructField("file_size", LongType(), True), StructField("status", StringType(), True), StructField("error_message", StringType(), True), StructField("operated_at", TimestampType(), True), StructField("pipeline_name", StringType(), True), ]) def audited_rm(path, pipeline_name, recurse=True): operated_at = datetime.now() status = "SUCCESS" error_msg = None file_size = 0 try: if path_exists(path): files = dbutils.fs.ls(path) file_size = sum(f.size for f in files) dbutils.fs.rm(path, recurse=recurse) except Exception as e: status = "FAILED" error_msg = str(e) raise finally: audit_record = [( "DELETE", path, None, file_size, status, error_msg, operated_at, pipeline_name )] audit_df = spark.createDataFrame(audit_record, audit_schema) audit_df.write \ .format("delta") \ .mode("append") \ .save("/audit/file_operations") audited_rm( path=f"{adls_path}/temp/", pipeline_name="transactions_incremental", recurse=True )
What to say: The audit log for file operations is essential in regulated industries where you need to demonstrate to auditors exactly what data was deleted, when, and by which pipeline. It also makes debugging much easier when something goes missing from the data lake, since the audit log shows every delete operation with the file size and timestamp rather than requiring you to rely on cloud storage access logs which can be harder to query and correlate. I always write the audit record in a finally block so it captures both successful and failed operations including the error message for failures.
Identifying and Optimising Slow Spark Notebooks
The Interviewer Says
You are working in Microsoft Fabric or Databricks and a notebook that used to run in 20 minutes is now taking 2 hours. Walk me through exactly how you would identify where the problem is and what steps you would take to fix it. I want to see a systematic debugging approach, not just a list of random tips.
How to Handle This in the Interview
Before answering, say: Performance debugging in Spark is a diagnostic process, not a guessing game. I always follow a structured approach: first understand what the job is doing through the Spark UI, then identify the specific stage or operation causing the slowdown, then apply the fix that addresses the root cause. Jumping straight to configuration changes without understanding the problem first almost always wastes time.
Step 1: Start with the Spark UI, not the code
- Say: The first thing I do is open the Spark UI and look at the Jobs, Stages, and SQL tabs. I am looking for three things: which stage is taking the most time, how much data is being shuffled, and whether any tasks within a stage are taking much longer than others.
Step 2: Know the four most common causes
- Data skew causing straggler tasks. Missing broadcast join triggering an unnecessary shuffle. Too many or too few shuffle partitions. Recomputing the same DataFrame multiple times without caching.
Step 3: Connect the Spark UI observation to the fix
- Each symptom in the Spark UI points to a specific fix. Knowing the mapping between what you see and what you do is what separates a strong candidate from a weak one.
Step 4: Mention AQE as an automatic safety net
- Adaptive Query Execution in Spark 3 handles some of these automatically but not all. Knowing what AQE does and does not fix shows depth.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.window import Window spark = SparkSession.builder \ .appName("PerformanceDebugging") \ .config("spark.sql.adaptive.enabled", "true") \ .config("spark.sql.adaptive.coalescePartitions.enabled", "true") \ .config("spark.sql.adaptive.skewJoin.enabled", "true") \ .getOrCreate() # ------------------------------------------------------- # STEP 1: Diagnose using explain() before running # ------------------------------------------------------- df_transactions = spark.read.format("delta").load("/data/transactions") df_customers = spark.read.format("delta").load("/data/customers") slow_query = df_transactions \ .join(df_customers, on="customer_id") \ .filter(F.col("amount") > 1000) \ .groupBy("region") \ .agg(F.sum("amount").alias("total")) # Always check the execution plan before running an expensive query slow_query.explain(mode="formatted") # In the output look for: # SortMergeJoin = expensive shuffle join (should be BroadcastHashJoin if customer is small) # Exchange = shuffle operation (each Exchange is a stage boundary) # Sort = sorting operation before a merge join # PartitionFilters = good (partition pruning is happening) # PushedFilters = good (predicate pushdown is working) # ------------------------------------------------------- # STEP 2: Check partition distribution for skew # ------------------------------------------------------- def check_partition_skew(df, label="DataFrame"): partition_counts = df.groupBy(F.spark_partition_id()) \ .count() \ .orderBy(F.col("count").desc()) stats = partition_counts.agg( F.max("count").alias("max_rows"), F.min("count").alias("min_rows"), F.avg("count").alias("avg_rows"), F.count("*").alias("num_partitions") ).collect()[0] print(f"\n{label} partition stats:") print(f" Partitions: {stats['num_partitions']}") print(f" Max rows: {stats['max_rows']:,}") print(f" Min rows: {stats['min_rows']:,}") print(f" Avg rows: {stats['avg_rows']:,.0f}") print(f" Skew ratio: {stats['max_rows'] / stats['avg_rows']:.1f}x") if stats["max_rows"] > stats["avg_rows"] * 5: print(" WARNING: Significant skew detected (max > 5x average)") return partition_counts check_partition_skew(df_transactions, "Transactions") # ------------------------------------------------------- # STEP 3: Fix 1 - Missing broadcast join # ------------------------------------------------------- # Check size of the smaller DataFrame customer_count = df_customers.count() print(f"Customer count: {customer_count:,}") # If customers is small (< few million rows), force broadcast join # Current auto broadcast threshold is 10MB by default current_threshold = spark.conf.get("spark.sql.autoBroadcastJoinThreshold") print(f"Current broadcast threshold: {current_threshold}") # Option A: Force broadcast hint in code fixed_query_broadcast = df_transactions \ .join(F.broadcast(df_customers), on="customer_id") \ .filter(F.col("amount") > 1000) \ .groupBy("region") \ .agg(F.sum("amount").alias("total")) # Option B: Raise the auto broadcast threshold spark.conf.set( "spark.sql.autoBroadcastJoinThreshold", str(100 * 1024 * 1024) # 100MB ) # Verify the plan now shows BroadcastHashJoin instead of SortMergeJoin fixed_query_broadcast.explain(mode="formatted") # ------------------------------------------------------- # STEP 4: Fix 2 - Data skew with salting # ------------------------------------------------------- # Find which customer_id values have the most transactions skewed_keys = df_transactions \ .groupBy("customer_id") \ .count() \ .orderBy(F.col("count").desc()) \ .limit(10) print("Most frequent customer_ids (potential skew):") skewed_keys.show() n_salts = 20 # Add salt to the large transactions table df_transactions_salted = df_transactions.withColumn( "salted_key", F.concat( F.col("customer_id"), F.lit("_"), (F.rand() * n_salts).cast("int") ) ) # Explode the small customers table to match all salt values from pyspark.sql.functions import array, explode, lit df_customers_salted = df_customers.withColumn( "salt", explode(array([lit(i) for i in range(n_salts)])) ).withColumn( "salted_key", F.concat(F.col("customer_id"), F.lit("_"), F.col("salt")) ) # Join on salted key df_result_salted = df_transactions_salted.join( df_customers_salted, on="salted_key", how="inner" ).drop("salted_key", "salt") # ------------------------------------------------------- # STEP 5: Fix 3 - Too many small shuffle partitions # ------------------------------------------------------- # Check current setting current_shuffle_partitions = spark.conf.get("spark.sql.shuffle.partitions") print(f"Current shuffle partitions: {current_shuffle_partitions}") # Rule: target 100-200MB per partition after shuffle # If total data after shuffle is 2GB, use 10-20 partitions # If total data after shuffle is 2TB, use 10000-20000 partitions # For small datasets: reduce from default 200 to something smaller spark.conf.set("spark.sql.shuffle.partitions", "20") # For large datasets: increase spark.conf.set("spark.sql.shuffle.partitions", "2000") # AQE handles this automatically in Spark 3 by coalescing small partitions spark.conf.set("spark.sql.adaptive.enabled", "true") spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true") spark.conf.set( "spark.sql.adaptive.advisoryPartitionSizeInBytes", str(128 * 1024 * 1024) # target 128MB per partition ) # ------------------------------------------------------- # STEP 6: Fix 4 - Recomputing expensive DataFrames # ------------------------------------------------------- from pyspark import StorageLevel # PROBLEM: This expensive transformation runs TWICE expensive_df = df_transactions \ .filter(F.col("amount") > 1000) \ .join(df_customers, on="customer_id") \ .withColumn("tax", F.col("amount") * 0.18) agg1 = expensive_df.groupBy("region").agg(F.sum("amount")) # triggers full recompute agg2 = expensive_df.groupBy("product").agg(F.count("*")) # triggers full recompute again # FIX: Cache the expensive result so it is computed only once expensive_df.cache() expensive_df.count() # materialise the cache agg1 = expensive_df.groupBy("region").agg(F.sum("amount")) # reads from cache agg2 = expensive_df.groupBy("product").agg(F.count("*")) # reads from cache agg1.show() agg2.show() expensive_df.unpersist() # free memory when done # ------------------------------------------------------- # STEP 7: Fix 5 - Push filters early # ------------------------------------------------------- # SLOW: read everything then filter slow_approach = spark.read.format("delta").load("/data/transactions") \ .join(df_customers, on="customer_id") \ .filter(F.col("txn_date") >= "2024-01-01") \ .filter(F.col("region") == "North") # FAST: filter before join to reduce data volume fast_approach = spark.read.format("delta").load("/data/transactions") \ .filter(F.col("txn_date") >= "2024-01-01") \ .filter(F.col("region") == "North") \ .join(F.broadcast(df_customers), on="customer_id") # Catalyst usually pushes filters automatically but not always # across join boundaries, so explicit early filtering is safer # ------------------------------------------------------- # STEP 8: Fix 6 - Select only needed columns early # ------------------------------------------------------- # SLOW: carry all columns through the entire pipeline slow_wide = spark.read.format("delta") \ .load("/data/transactions") \ .join(df_customers, on="customer_id") \ .groupBy("region") \ .agg(F.sum("amount")) # FAST: select only needed columns before join fast_slim = spark.read.format("delta") \ .load("/data/transactions") \ .select("customer_id", "region", "amount") \ .join( df_customers.select("customer_id"), on="customer_id" ) \ .groupBy("region") \ .agg(F.sum("amount")) # ------------------------------------------------------- # STEP 9: Measure improvement # ------------------------------------------------------- import time def time_query(query_df, label): start = time.time() count = query_df.count() elapsed = time.time() - start print(f"{label}: {elapsed:.2f}s ({count:,} rows)") return elapsed slow_time = time_query(slow_query, "Original query") fast_time = time_query(fixed_query_broadcast, "Optimised query") print(f"Speedup: {slow_time / fast_time:.1f}x faster")
What to say: The systematic approach is: open Spark UI, find the slowest stage, look at the task timeline in that stage for skew, look at the shuffle bytes to see if a join is causing an unexpected full shuffle, check the SQL plan for SortMergeJoin where BroadcastHashJoin should be. Each observation maps directly to a fix. Skew means salting. SortMergeJoin on a small table means broadcast hint. Too much recomputation means cache. Large shuffle with small data means reduce shuffle partitions. I never apply these fixes blindly, I always verify using explain() that the plan actually changed the way I expected before measuring the time improvement.
Follow-up Questions & Answers
Q1. What specific things do you look at in the Spark UI when a job is slow?
The Spark UI has five tabs that I work through in order when debugging a slow job.
The Jobs tab shows all jobs triggered by actions and their total duration. I find the slowest job and click into it.
The Stages tab within that job shows all stages, their duration, and their shuffle read and write bytes. A stage with unusually high shuffle bytes is an immediate red flag since shuffle involves disk IO and network transfer.
The Tasks tab within the slowest stage shows the duration of every individual task. If most tasks finish in 5 seconds but two tasks take 10 minutes, that is data skew, specific partitions have too much data compared to others.
The SQL tab shows the full query execution plan as a DAG with timing information for each node. I look for SortMergeJoin on a table that should be broadcast, large Exchange operators showing heavy shuffle, and whether filters are being pushed down or sitting above expensive operations.
The Storage tab shows cached DataFrames and how much memory and disk they are using. If cache is spilling to disk heavily it means the executor memory is too small for the cached data.
Q2. What is speculative execution and when does it help versus when does it not?
Speculative execution is a Spark feature where if a task is running significantly slower than the median task in the same stage, Spark launches a duplicate copy of that task on a different executor and uses whichever copy finishes first. It is controlled by spark.speculation being set to true.
# Enable speculative execution spark.conf.set("spark.speculation", "true") spark.conf.set("spark.speculation.multiplier", "1.5") # task is 1.5x slower than median spark.conf.set("spark.speculation.quantile", "0.75") # 75% of tasks must finish first
Speculative execution helps when a task is slow due to a bad executor, meaning a node with a slow disk, high CPU load from other processes, or network issues. In those cases launching the same task on a healthy executor genuinely finishes faster. Speculative execution does not help for data skew because the problem is the data itself, not the executor. A skewed partition with ten times more rows than average will take ten times longer regardless of which executor runs it. Launching a duplicate of a skewed task just consumes twice the cluster resources without finishing any faster.
Q3. You see that a join between a 500GB table and a 50MB table is using SortMergeJoin instead of BroadcastHashJoin. What would you check and how would you fix it?
# Check 1: What is the current auto broadcast threshold? print(spark.conf.get("spark.sql.autoBroadcastJoinThreshold")) # Default is 10485760 (10MB), 50MB table exceeds this # Check 2: What does Spark think the table size is? # Sometimes Spark's size estimate is wrong due to stale statistics df_small.explain() # Look for "Statistics(sizeInBytes=..." in the plan # If the estimated size is larger than the actual size, statistics are stale # Fix 1: Raise the threshold to cover the 50MB table spark.conf.set( "spark.sql.autoBroadcastJoinThreshold", str(100 * 1024 * 1024) # 100MB ) # Fix 2: Force broadcast with an explicit hint regardless of threshold result = large_df.join( F.broadcast(small_df), on="key", how="inner" ) # Verify the plan now shows BroadcastHashJoin result.explain(mode="formatted") # Fix 3: Run ANALYZE TABLE to update statistics # (Delta tables have statistics maintained automatically) spark.sql("ANALYZE TABLE small_table COMPUTE STATISTICS")
What to say: The most common reason Spark uses SortMergeJoin on a table that should be broadcast is that the auto broadcast threshold is too low for the table size, or that Spark's size estimate for the table is larger than the actual size due to stale or missing table statistics. I always check both the threshold setting and the size estimate in the explain plan before adding a broadcast hint, since raising the threshold is a cleaner long-term solution than adding hints to every query.
Q4. How would you identify which cell in a notebook is causing the slowness, especially in a long notebook with many cells?
import time def timed_cell(operation_name, func): start = time.time() result = func() elapsed = time.time() - start print(f"[TIMING] {operation_name}: {elapsed:.2f}s") return result # Wrap each major operation df_raw = timed_cell( "Read source data", lambda: spark.read.format("delta").load("/data/transactions") ) df_filtered = timed_cell( "Apply filters", lambda: df_raw.filter(F.col("amount") > 1000).cache() ) df_filtered.count() # materialise to get accurate timing df_joined = timed_cell( "Join with customers", lambda: df_filtered.join(F.broadcast(df_customers), on="customer_id") ) df_aggregated = timed_cell( "GroupBy aggregation", lambda: df_joined.groupBy("region").agg(F.sum("amount")) ) result = timed_cell( "Write output", lambda: df_aggregated.write.format("delta").mode("overwrite").save("/output/result") )
What to say: The key insight is that because Spark is lazy, transformations like filter and join report near-zero time since they do not execute. Actions like count and write report the real time because they trigger execution. So I add a count after each transformation that I want to time separately, which forces that stage to execute and gives me an accurate measurement. This lets me isolate which specific operation, the join, the groupBy, or the write, is the bottleneck rather than seeing all the time reported on the final action.
Q5. A notebook ran fine yesterday but is slow today with the same code and same data. What are the first three things you would check?
First I check whether the data volume actually changed. A table that grew by ten times overnight due to a runaway pipeline or a data backfill would make everything slower even with identical code. I check row counts for all source tables.
print(spark.read.format("delta").load("/data/transactions").count()) print(spark.read.format("delta").load("/data/customers").count())
Second I check the cluster resources. In Databricks and Fabric, clusters can be autoscaled down, or another job running on the same cluster may be consuming most of the CPU and memory. I check the cluster metrics in the Spark UI executor tab to see how many executors are active and their memory and CPU utilisation.
Third I check whether any Delta tables involved in the query have grown to the point where small file problems are causing overhead. A Delta table that receives many small appends over time accumulates thousands of tiny Parquet files that Spark must open and read individually, which is much slower than reading a few large files.
# Check number of files in a Delta table files_df = spark.sql( "DESCRIBE DETAIL delta.`/data/transactions`" ).select("numFiles", "sizeInBytes") files_df.show() # If numFiles is very large (thousands), run OPTIMIZE to compact spark.sql("OPTIMIZE delta.`/data/transactions`")
What to say: These three checks cover the most common causes of an unexplained slowdown on unchanged code: more data, fewer resources, or file fragmentation. If all three check out and the cluster has the same resources and the data has the same volume, I then look at whether any upstream table statistics changed in a way that changed the query plan, for example if a table grew enough to push a join above the broadcast threshold, causing Spark to switch from BroadcastHashJoin to SortMergeJoin automatically.
Optimisation Techniques Summary
The Interviewer Says
You have been working on a large PySpark project for some time. Walk me through all the optimisation techniques you have used in your real project work. I do not want a textbook list, I want to hear specific scenarios where each technique was needed and what the actual impact was. This is a senior level question.
How to Handle This in the Interview
Before answering, say: Performance optimisation in Spark is always about finding the biggest bottleneck and eliminating it, then finding the next biggest one. The techniques I use fall into four categories: storage and data layout optimisations, join optimisations, shuffle and partition optimisations, and compute reuse. Let me walk through each with the specific scenario where I applied it.
Step 1: Frame each technique as a problem-solution pair
- Do not just list technique names. Say what the symptom was, what you diagnosed, what you changed, and what the result was.
Step 2: Cover all four categories
- Storage: file format, partitioning, compaction, Z-ordering. Join: broadcast, salting, pre-partitioning. Shuffle: shuffle partitions, AQE, repartition timing. Compute: caching, column pruning, filter pushdown.
Step 3: Mention trade-offs
- Every optimisation has a trade-off. Broadcasting is limited by memory. Caching consumes executor heap. Z-ordering helps some queries and does nothing for others. Mentioning trade-offs shows maturity.
Step 4: End with a process
- Say: I treat optimisation as a feedback loop: measure, diagnose, fix, measure again. I never apply optimisations speculatively without measuring the before and after.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.window import Window from pyspark import StorageLevel from delta.tables import DeltaTable spark = SparkSession.builder \ .appName("OptimisationTechniques") \ .config("spark.sql.adaptive.enabled", "true") \ .config("spark.sql.adaptive.coalescePartitions.enabled", "true") \ .config("spark.sql.adaptive.skewJoin.enabled", "true") \ .getOrCreate() # ------------------------------------------------------- # TECHNIQUE 1: Broadcast Join # Scenario: joining a 200GB transactions table to a 50MB product lookup # Symptom: stage taking 45 minutes due to full shuffle of both tables # Fix: broadcast the small lookup table # Result: stage time reduced from 45 minutes to 4 minutes # ------------------------------------------------------- df_transactions = spark.read.format("delta").load("/data/transactions") df_products = spark.read.format("delta").load("/data/products") result_broadcast = df_transactions.join( F.broadcast(df_products), on="product_id", how="inner" ) # Raise threshold so Spark auto-broadcasts tables up to 100MB spark.conf.set( "spark.sql.autoBroadcastJoinThreshold", str(100 * 1024 * 1024) ) # ------------------------------------------------------- # TECHNIQUE 2: Data Skew Handling with Salting # Scenario: groupBy customer_id where one customer has 60% of all transactions # Symptom: 199 tasks finishing in 30 seconds, 1 task running for 40 minutes # Fix: salt the join/groupBy key to split the hot key across multiple partitions # Result: all tasks now finish within 2 minutes of each other # ------------------------------------------------------- n_salts = 20 df_salted = df_transactions.withColumn( "salted_customer_id", F.concat( F.col("customer_id"), F.lit("_"), (F.rand() * n_salts).cast("int") ) ) df_grouped_salted = df_salted \ .groupBy("salted_customer_id") \ .agg(F.sum("amount").alias("partial_sum")) \ .withColumn( "customer_id", F.split(F.col("salted_customer_id"), "_").getItem(0) ) \ .groupBy("customer_id") \ .agg(F.sum("partial_sum").alias("total_amount")) # ------------------------------------------------------- # TECHNIQUE 3: Partition Tuning # Scenario: groupBy on 500GB of data defaulting to 200 shuffle partitions # Symptom: each partition averaging 2.5GB, causing executor OOM # Fix: increase shuffle partitions so each is ~128MB # Result: no more OOM, job completed successfully # ------------------------------------------------------- data_size_gb = 500 target_partition_gb = 0.128 # 128MB optimal_partitions = int(data_size_gb / target_partition_gb) print(f"Recommended shuffle partitions: {optimal_partitions}") spark.conf.set("spark.sql.shuffle.partitions", str(optimal_partitions)) # AQE will automatically coalesce if some partitions end up small spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true") spark.conf.set( "spark.sql.adaptive.advisoryPartitionSizeInBytes", str(128 * 1024 * 1024) ) # ------------------------------------------------------- # TECHNIQUE 4: Caching Reused DataFrames # Scenario: the same filtered join result used in 5 different aggregations # Symptom: 5x the expected runtime because the expensive join ran 5 times # Fix: cache the result after the join, reuse for all aggregations # Result: join runs once, all 5 aggregations read from memory # ------------------------------------------------------- df_base = df_transactions \ .filter(F.col("txn_date") >= "2024-01-01") \ .join(F.broadcast(df_products), on="product_id") \ .persist(StorageLevel.MEMORY_AND_DISK) df_base.count() # materialise the cache agg1 = df_base.groupBy("region").agg(F.sum("amount")) agg2 = df_base.groupBy("product").agg(F.avg("amount")) agg3 = df_base.groupBy("customer_id").agg(F.count("*")) agg4 = df_base.filter(F.col("amount") > 10000) agg5 = df_base.groupBy("region", "product").agg(F.max("amount")) agg1.show(); agg2.show(); agg3.show() df_base.unpersist() # ------------------------------------------------------- # TECHNIQUE 5: File Format and Partitioning # Scenario: ingestion pipeline reading CSV files, taking 4 hours # Symptom: reading a 2TB CSV was slow due to no columnar compression # and no partition pruning # Fix: convert to Delta partitioned by date, reads now take 8 minutes # because most queries only touch 1-2 date partitions # Result: 30x faster reads for date-filtered queries # ------------------------------------------------------- df_csv = spark.read \ .option("header", "true") \ .schema(schema) \ .csv("/raw/transactions/*.csv") df_csv.write \ .format("delta") \ .partitionBy("txn_date") \ .mode("overwrite") \ .save("/optimised/transactions") # Queries that filter by txn_date now use partition pruning spark.read.format("delta").load("/optimised/transactions") \ .filter(F.col("txn_date") == "2024-01-15") \ .count() # ------------------------------------------------------- # TECHNIQUE 6: Z-Ordering for Multi-Column Filter Performance # Scenario: queries frequently filter by customer_id and product_id # but data is not co-located by these columns within partitions # Symptom: reading 100GB partition but only 2GB matches the filter # Fix: OPTIMIZE with ZORDER co-locates related rows in same files # Result: 90% fewer files read for typical customer+product queries # ------------------------------------------------------- spark.sql(""" OPTIMIZE delta.`/optimised/transactions` ZORDER BY (customer_id, product_id) """) # ------------------------------------------------------- # TECHNIQUE 7: Column Pruning and Filter Pushdown # Scenario: pipeline reading 200-column table but only using 5 columns # Symptom: unnecessarily reading 40x more data than needed # Fix: select only needed columns early, let Catalyst push filters to reader # Result: 95% reduction in data read from storage # ------------------------------------------------------- df_pruned = spark.read.format("delta") \ .load("/data/wide_table") \ .select("customer_id", "amount", "txn_date", "region", "product_id") \ .filter(F.col("txn_date") >= "2024-01-01") # Verify column pruning and filter pushdown in the plan df_pruned.explain(mode="formatted") # Look for: Project [customer_id, amount, ...] (column pruning) # Look for: PushedFilters: [IsNotNull(txn_date), GreaterThanOrEqual(txn_date, 2024-01-01)] # ------------------------------------------------------- # TECHNIQUE 8: Repartition on Join Key Before Expensive Join # Scenario: large-large join on customer_id # neither table was co-partitioned by customer_id # Symptom: very large shuffle with both tables being fully reshuffled # Fix: pre-partition both tables by customer_id once, # write them bucketed to storage so future joins skip the shuffle # Result: join stage eliminated the full shuffle, 60% faster # ------------------------------------------------------- df_transactions_bucketed = df_transactions.repartition(200, "customer_id") df_customers_bucketed = spark.read.format("delta").load("/data/customers") \ .repartition(200, "customer_id") result = df_transactions_bucketed.join( df_customers_bucketed, on="customer_id", how="inner" ) # For permanent bucketing (skip shuffle on every future join) df_transactions.write \ .format("parquet") \ .bucketBy(64, "customer_id") \ .sortBy("customer_id") \ .saveAsTable("transactions_bucketed") # ------------------------------------------------------- # TECHNIQUE 9: Small File Compaction # Scenario: Delta table receiving hourly micro-batch appends # accumulated 50,000 tiny Parquet files over 6 months # Symptom: read performance degraded 20x due to file listing overhead # Fix: OPTIMIZE compacts small files into 1GB target files # Result: file count reduced from 50,000 to 200, reads 15x faster # ------------------------------------------------------- spark.sql("OPTIMIZE delta.`/data/transactions`") spark.sql("OPTIMIZE delta.`/data/transactions` ZORDER BY (customer_id)") # Set up automatic optimisation in Databricks spark.conf.set("spark.databricks.delta.optimizeWrite.enabled", "true") spark.conf.set("spark.databricks.delta.autoCompact.enabled", "true") # ------------------------------------------------------- # TECHNIQUE 10: Avoiding UDFs with Built-in Functions # Scenario: using Python UDF for string cleaning, taking 3 hours # Fix: replace with built-in regexp_replace and lower # Result: same transformation in 8 minutes (22x faster) # ------------------------------------------------------- # SLOW: Python UDF with row-by-row serialisation overhead from pyspark.sql.types import StringType @F.udf(StringType()) def clean_product_name_udf(name): if name is None: return None return name.strip().lower().replace(" ", " ") df_slow = df_transactions.withColumn( "clean_name", clean_product_name_udf(F.col("product")) ) # FAST: built-in functions run entirely in JVM, no Python overhead df_fast = df_transactions.withColumn( "clean_name", F.trim(F.lower(F.regexp_replace(F.col("product"), "\\s+", " "))) )
What to say: The most impactful optimisation I have applied in real projects has consistently been converting CSV to Delta with partitioning, which gave the biggest improvement by far because it changed both the storage format and enabled partition pruning. The second most impactful was adding broadcast hints to small table joins, which eliminated full shuffles that were the dominant bottleneck in many analytical pipelines. Caching comes third when the same computation feeds multiple downstream aggregations. I treat each of these as a progressive refinement where I measure the impact of each change rather than applying all of them at once, so I know exactly which fix solved the problem.
Follow-up Questions & Answers
Q1. What is the difference between repartition and bucketing and when would you choose each?
repartition redistributes data into N partitions based on a hash of the specified column at runtime. It fixes the partition layout for a single job but the result is not persisted. The next job reading the same data starts from scratch with the original unpartitioned layout. Bucketing is a persistent physical layout written to storage where data is pre-sorted and distributed into a fixed number of bucket files based on the hash of the bucket column. When two tables are bucketed on the same column with the same number of buckets, Spark can join them without any shuffle since matching keys are guaranteed to be in the same bucket files.
# repartition: runtime only, must be redone each job df.repartition(200, "customer_id").join(other_df, on="customer_id") # bucketing: permanent, join skips shuffle every time df.write \ .format("parquet") \ .bucketBy(64, "customer_id") \ .sortBy("customer_id") \ .saveAsTable("transactions_bucketed") other_df.write \ .format("parquet") \ .bucketBy(64, "customer_id") \ .sortBy("customer_id") \ .saveAsTable("customers_bucketed") # This join now has no shuffle since both tables are co-bucketed spark.sql(""" SELECT t.*, c.name FROM transactions_bucketed t JOIN customers_bucketed c ON t.customer_id = c.customer_id """)
What to say: I use repartition when I need to fix the partition layout for a single complex query that runs infrequently. I invest in bucketing when the same large-large join runs repeatedly, like a daily aggregation pipeline, because the upfront cost of writing the bucketed tables is recovered many times over by eliminating the shuffle on every subsequent run.
Q2. What is Z-ordering in Delta Lake and what types of queries does it help and not help?
Z-ordering is a multi-dimensional clustering technique that co-locates related data in the same Parquet files within a partition. When you Z-order by customer_id and product_id, Delta rearranges the rows within each partition so that rows with similar customer_id and product_id values are stored in the same files. When a query filters by customer_id equals C01, Delta can use the file-level min-max statistics in the transaction log to skip files that cannot contain C01 records rather than reading every file.
# Z-order helps: queries filtering on the Z-order columns spark.sql("OPTIMIZE delta.`/data` ZORDER BY (customer_id, product_id)") # Queries that benefit df.filter(F.col("customer_id") == "C01") # good df.filter((F.col("customer_id") == "C01") & (F.col("product_id") == "P01")) # good # Queries that do NOT benefit from Z-ordering df.filter(F.col("amount") > 1000) # no Z-order on amount df.groupBy("region").agg(F.count("*")) # full scan needed
What to say: Z-ordering helps queries that filter on the clustered columns because it enables data skipping at the file level. It does not help full table scans or filters on non-Z-ordered columns. The maintenance cost is also real, OPTIMIZE with ZORDER rewrites all the files in the partition which is expensive. I only apply Z-ordering to columns that appear frequently in WHERE clauses of time-sensitive queries, not to every column in the table.
Q3. How does Adaptive Query Execution change your optimisation approach in Spark 3?
AQE automatically handles three specific problems at runtime that previously required manual intervention. It coalesces small shuffle partitions, meaning if you set shuffle partitions to 2000 but most partitions end up tiny after the shuffle, AQE merges them into larger ones without any code change. It switches join strategies at runtime, meaning if Spark planned a SortMergeJoin but the actual data is much smaller than estimated, AQE switches to BroadcastHashJoin automatically. It also handles skewed join partitions by splitting them into smaller pieces.
# Enable all AQE features spark.conf.set("spark.sql.adaptive.enabled", "true") spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true") spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true") # With AQE enabled these manual tunings become less critical # but still useful for extreme cases spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "134217728") # 128MB spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "5") spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "268435456")
What to say: AQE has made Spark significantly more self-tuning in Spark 3, which means I spend less time manually tuning shuffle partitions and more time on the higher-level optimisations that AQE cannot handle automatically, like choosing the right storage format, partitioning strategy, and which joins need broadcast hints. I always enable all three AQE features as the baseline configuration for any production cluster and then apply manual tuning on top for the specific bottlenecks that remain.
Q4. You have a pipeline that processes 1TB of data daily in 4 hours. The business needs it to run in 30 minutes. How would you approach this?
What to say: A 8x improvement target is achievable through a combination of architectural and code-level changes. I would attack them in order of expected impact.
# STEP 1: Profile first to understand where time actually goes # Target: understand the slowest 20% of operations that cause 80% of the time # STEP 2: Storage format (potential 3-5x improvement) # Convert CSV/JSON input to Delta with date partitioning # This alone often gives the biggest single improvement # STEP 3: Eliminate unnecessary shuffles (potential 2-3x improvement) # Identify all SortMergeJoins on small tables and add broadcast hints # Check explain plans for unexpected Exchange operators # STEP 4: Increase cluster resources # Profile CPU and memory utilisation during the job # If executors are CPU-bound, add more cores # If shuffle is the bottleneck, adding cores does not help num_executors = 20 executor_cores = 4 executor_memory = "16g" # STEP 5: Parallelize independent stages # If Pipeline has A -> B, A -> C where B and C are independent # run B and C as concurrent threads import threading def run_b(): df_b.write.format("delta").save("/output/b") def run_c(): df_c.write.format("delta").save("/output/c") thread_b = threading.Thread(target=run_b) thread_c = threading.Thread(target=run_c) thread_b.start(); thread_c.start() thread_b.join(); thread_c.join() # STEP 6: Measure after each change # Document the before and after time for each optimisation # Stop when you hit the 30-minute target or run out of optimisations
What to say: The most important thing to say here is that I would not guess which optimisation to apply first. I would profile the job thoroughly, understand the bottleneck, apply the change most likely to address that specific bottleneck, measure the result, and repeat. In my experience the biggest wins almost always come from storage format conversion and broadcast join fixes, not from configuration tuning, which is why I address those first.
Q5. What monitoring would you set up to ensure the optimised pipeline stays fast over time?
Performance optimisations have a shelf life. A job optimised for 1TB of daily data may slow down again when that data grows to 10TB without any code changes.
def monitor_pipeline_performance(pipeline_name, target_runtime_minutes): import time start_time = time.time() # Run the pipeline run_pipeline() elapsed_minutes = (time.time() - start_time) / 60 performance_record = [( pipeline_name, elapsed_minutes, target_runtime_minutes, "PASS" if elapsed_minutes <= target_runtime_minutes else "FAIL", datetime.now().isoformat() )] perf_df = spark.createDataFrame( performance_record, ["pipeline_name", "runtime_minutes", "target_minutes", "status", "run_timestamp"] ) perf_df.write \ .format("delta") \ .mode("append") \ .save("/monitoring/pipeline_performance") if elapsed_minutes > target_runtime_minutes: print(f"ALERT: {pipeline_name} took {elapsed_minutes:.1f} min, " f"target was {target_runtime_minutes} min") # Also track input data volume source_count = spark.read.format("delta").load("/data/transactions").count() spark.createDataFrame( [(pipeline_name, source_count, datetime.now().isoformat())], ["pipeline", "source_rows", "measured_at"] ).write.format("delta").mode("append").save("/monitoring/data_volume")
What to say: I track both runtime and input data volume together because that correlation tells you whether a slowdown is caused by the data growing or by something else changing in the environment. If runtime increases proportionally with data volume that is expected behaviour and might just need more cluster resources. If runtime increases faster than data volume that signals a specific performance regression, like an index becoming ineffective, a new code change introducing an unoptimised operation, or a data skew developing as the distribution of key values shifts over time.
SCD Type 1 and SCD Type 2 with MERGE — Deep Dive
The Interviewer Says
You mentioned SCD in your resume. Explain to me the difference between SCD Type 1 and SCD Type 2 conceptually, and then write the actual PySpark MERGE code for both. I also want you to tell me what watermark columns you used, how you handle late arriving records, and what happens if the MERGE fails halfway through the SCD Type 2 two-step process.
How to Handle This in the Interview
Before writing anything, say: SCD stands for Slowly Changing Dimension. It describes how to handle changes to dimension data over time in a data warehouse. The core question is whether you care about history. SCD Type 1 says no, just overwrite. SCD Type 2 says yes, keep the old version and add a new one. The choice depends entirely on whether the business needs to answer historical questions like what was this customer's city when they placed that order two years ago.
Step 1: Explain Type 1 vs Type 2 in one sentence each
- Type 1: when something changes, update the existing row and lose the old value forever. Type 2: when something changes, close the existing row with an end date and insert a new row with the new values and a new start date.
Step 2: Explain the watermark column
- A watermark column like updated_at or effective_date prevents older source records from overwriting newer target records. Without it, a CDC replay or late arriving event could revert a correct current value back to an older one.
Step 3: Explain why SCD Type 2 needs two MERGE operations
- A single MERGE cannot both update an existing row and insert a new row for the same key simultaneously. The first MERGE closes the old row. The second MERGE inserts the new version.
Step 4: Address the failure scenario
- If the job fails after the first MERGE but before the second, the old record is closed with no active replacement. This inconsistency must be detected and handled on retry.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from delta.tables import DeltaTable from datetime import datetime spark = SparkSession.builder \ .appName("SCDDeepDive") \ .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") \ .config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog") \ .getOrCreate() # ------------------------------------------------------- # PART 1: SCD TYPE 1 - Overwrite, no history # ------------------------------------------------------- scd1_path = "/output/scd1_customers" # Initial target table scd1_target_data = [ ("C01", "Karan", "Mumbai", "Finance", "2024-01-01 00:00:00"), ("C02", "Asha", "Delhi", "IT", "2024-01-01 00:00:00"), ("C03", "Ravi", "Pune", "Finance", "2024-01-01 00:00:00"), ] df_scd1_target = spark.createDataFrame( scd1_target_data, ["customer_id", "name", "city", "dept", "updated_at"] ) df_scd1_target.write \ .format("delta") \ .mode("overwrite") \ .save(scd1_path) print("SCD1 Initial state:") spark.read.format("delta").load(scd1_path).show() # Source with changes scd1_source_data = [ ("C02", "Asha", "Bangalore", "IT", "2024-02-01 10:00:00"), # city changed ("C03", "Ravi", "Pune", "Finance", "2023-12-01 10:00:00"), # OLDER than target ("C04", "Meena", "Chennai", "HR", "2024-02-01 10:00:00"), # new customer ] df_scd1_source = spark.createDataFrame( scd1_source_data, ["customer_id", "name", "city", "dept", "updated_at"] ) scd1_table = DeltaTable.forPath(spark, scd1_path) # SCD Type 1 MERGE with watermark # Only update if source record is newer than target scd1_table.alias("target").merge( df_scd1_source.alias("source"), "target.customer_id = source.customer_id" ).whenMatchedUpdate( condition="source.updated_at > target.updated_at", set={ "name": "source.name", "city": "source.city", "dept": "source.dept", "updated_at": "source.updated_at" } ).whenNotMatchedInsertAll() \ .execute() print("SCD1 After MERGE:") spark.read.format("delta").load(scd1_path).show() print("Note: C02 city updated to Bangalore, C03 unchanged (source was OLDER), C04 inserted") # ------------------------------------------------------- # PART 2: SCD TYPE 2 - Full history preservation # ------------------------------------------------------- scd2_path = "/output/scd2_customers" # Initial target with SCD2 columns scd2_target_data = [ ("C01", "Karan", "Mumbai", "Finance", "2023-01-01", "9999-12-31", True, "2024-01-01 00:00:00"), ("C02", "Asha", "Delhi", "IT", "2023-01-01", "9999-12-31", True, "2024-01-01 00:00:00"), ("C03", "Ravi", "Pune", "Finance", "2023-01-01", "9999-12-31", True, "2024-01-01 00:00:00"), ] df_scd2_target = spark.createDataFrame( scd2_target_data, ["customer_id", "name", "city", "dept", "eff_start_date", "eff_end_date", "is_current", "updated_at"] ) df_scd2_target.write \ .format("delta") \ .mode("overwrite") \ .save(scd2_path) print("SCD2 Initial state:") spark.read.format("delta").load(scd2_path).show() # Source with changes scd2_source_data = [ ("C02", "Asha", "Bangalore", "IT", "2024-02-01 10:00:00"), # city changed ("C03", "Ravi", "Pune", "HR", "2024-02-01 10:00:00"), # dept changed ("C04", "Meena", "Chennai", "HR", "2024-02-01 10:00:00"), # new customer ] df_scd2_source = spark.createDataFrame( scd2_source_data, ["customer_id", "name", "city", "dept", "updated_at"] ) today = "2024-02-01" yesterday = "2024-01-31" scd2_table = DeltaTable.forPath(spark, scd2_path) # ------------------------------------------------------- # SCD TYPE 2 STEP 1: # Find which existing records actually changed # and close them by setting is_current = False # ------------------------------------------------------- # Join source to current target to identify genuinely changed records changed_keys = df_scd2_source.alias("src").join( scd2_table.toDF().filter(F.col("is_current") == True).alias("tgt"), on="customer_id", how="inner" ).filter( (F.col("src.city") != F.col("tgt.city")) | (F.col("src.dept") != F.col("tgt.dept")) | (F.col("src.name") != F.col("tgt.name")) ).filter( # Only process if source record is newer than target (watermark) F.col("src.updated_at") > F.col("tgt.updated_at") ).select( F.col("src.customer_id").alias("customer_id") ) print("Records to close (changed and source is newer):") changed_keys.show() # MERGE 1: Close existing active records for changed customers scd2_table.alias("target").merge( changed_keys.alias("source"), """ target.customer_id = source.customer_id AND target.is_current = true """ ).whenMatchedUpdate(set={ "eff_end_date": F.lit(yesterday), "is_current": F.lit(False) }).execute() print("After MERGE 1 (closing changed records):") scd2_table.toDF().orderBy("customer_id", "eff_start_date").show() # ------------------------------------------------------- # SCD TYPE 2 STEP 2: # Insert new versions of changed records # and insert brand new records # ------------------------------------------------------- # Prepare new records to insert # This includes both new versions of changed records # and genuinely new customers from source new_records = df_scd2_source.withColumn( "eff_start_date", F.lit(today) ).withColumn( "eff_end_date", F.lit("9999-12-31") ).withColumn( "is_current", F.lit(True) ) # Filter to only records that are either: # 1. Changed records (their old version was just closed) # 2. New records (not in target at all) records_to_insert = new_records.alias("src").join( scd2_table.toDF().filter(F.col("is_current") == True).alias("tgt"), on="customer_id", how="left_anti" ) print("Records to insert (new versions + new customers):") records_to_insert.show() # MERGE 2: Insert new versions and new customers scd2_table.alias("target").merge( records_to_insert.alias("source"), """ target.customer_id = source.customer_id AND target.is_current = true """ ).whenNotMatchedInsertAll() \ .execute() print("Final SCD2 state (full history preserved):") scd2_table.toDF().orderBy("customer_id", "eff_start_date").show() # ------------------------------------------------------- # PART 3: Handling partial failure in SCD Type 2 # ------------------------------------------------------- def run_scd2_safe(scd2_table, df_source, today, yesterday): """ Idempotent SCD Type 2 implementation with failure recovery. Can be safely re-run if it fails between Step 1 and Step 2. """ # Detect if Step 1 ran but Step 2 did not # Symptom: records with eff_end_date = yesterday and is_current = False # but no corresponding is_current = True record for same customer recently_closed = scd2_table.toDF() \ .filter( (F.col("eff_end_date") == yesterday) & (F.col("is_current") == False) ).select("customer_id") active_records = scd2_table.toDF() \ .filter(F.col("is_current") == True) \ .select("customer_id") orphaned_closures = recently_closed.join( active_records, on="customer_id", how="left_anti" ) orphan_count = orphaned_closures.count() if orphan_count > 0: print(f"WARNING: Found {orphan_count} orphaned closures from previous failed run") print("Re-running Step 2 to complete the SCD2 operation...") # Step 1: Close changed records (idempotent, safe to re-run) changed_keys = df_source.alias("src").join( scd2_table.toDF().filter(F.col("is_current") == True).alias("tgt"), on="customer_id", how="inner" ).filter( (F.col("src.city") != F.col("tgt.city")) | (F.col("src.dept") != F.col("tgt.dept")) ).select("src.customer_id") scd2_table.alias("t").merge( changed_keys.alias("s"), "t.customer_id = s.customer_id AND t.is_current = true" ).whenMatchedUpdate(set={ "eff_end_date": F.lit(yesterday), "is_current": F.lit(False) }).execute() # Step 2: Insert new versions (idempotent via left_anti join check) new_records = df_source.withColumn("eff_start_date", F.lit(today)) \ .withColumn("eff_end_date", F.lit("9999-12-31")) \ .withColumn("is_current", F.lit(True)) records_to_insert = new_records.alias("src").join( scd2_table.toDF().filter(F.col("is_current") == True).alias("tgt"), on="customer_id", how="left_anti" ) scd2_table.alias("t").merge( records_to_insert.alias("s"), "t.customer_id = s.customer_id AND t.is_current = true" ).whenNotMatchedInsertAll().execute() print("SCD2 completed successfully") run_scd2_safe(scd2_table, df_scd2_source, today, yesterday)
What to say: The watermark column is the most important part of both SCD Type 1 and Type 2 MERGE implementations. Without it, a late arriving or replayed CDC event with an older timestamp could overwrite a more recently updated target record, reverting the data to an older state. The condition source.updated_at greater than target.updated_at in the MERGE ensures that only genuinely newer source records trigger an update. For SCD Type 2 the idempotency check for orphaned closures is critical for production reliability, because the two-step nature of the operation means a failure between steps leaves the table in an inconsistent state that must be detected and corrected on the next run.
Expected Output — SCD Type 2 Final State
| Customer ID | Name | City | Dept | Eff Start Date | Eff End Date | Is Current |
|---|---|---|---|---|---|---|
| C01 | Karan | Mumbai | Finance | 2023-01-01 | 9999-12-31 | true |
| C02 | Asha | Delhi | IT | 2023-01-01 | 2024-01-31 | false |
| C02 | Asha | Bangalore | IT | 2024-02-01 | 9999-12-31 | true |
| C03 | Ravi | Pune | Finance | 2023-01-01 | 2024-01-31 | false |
| C03 | Ravi | Pune | HR | 2024-02-01 | 9999-12-31 | true |
| C04 | Meena | Chennai | HR | 2024-02-01 | 9999-12-31 | true |
Follow-up Questions & Answers
Q1. A manager asks why you need SCD Type 2 — why not just keep the latest value?
SCD Type 1 is simpler and uses less storage, which is why managers sometimes prefer it. But it loses the ability to answer historical questions correctly. Consider an order placed by a customer when they lived in Mumbai. Six months later the customer moves to Bangalore and the city is updated with Type 1. Now if you query the order history joined to the customer dimension, every historical order shows Bangalore as the city even though the customer was in Mumbai when those orders were placed. This is called a slowly changing dimension problem and it produces analytically incorrect results for any time-sensitive question. SCD Type 2 preserves the Mumbai record with a closed end date, so a point-in-time query for the order date shows the correct city at that time.
Q2. What is the difference between SCD Type 2 and SCD Type 3?
SCD Type 2 keeps unlimited history by adding new rows. SCD Type 3 keeps limited history by adding new columns. In Type 3, instead of inserting a new row when a customer moves from Mumbai to Bangalore, you add a previous_city column to the existing row and update current_city. Type 3 lets you easily see the current and immediately previous value but loses anything before that. It is simpler to query since each customer always has exactly one row, but it cannot answer questions about states more than one change ago. Type 3 is rarely used in practice because the fixed two-column approach does not scale to more than one historical snapshot and requires schema changes to add more history.
Q3. How would you query SCD Type 2 data to see what a customer record looked like on a specific historical date?
# Point in time query: what did customer C02 look like on 2023-06-15? target_date = "2023-06-15" historical_view = scd2_table.toDF().filter( (F.col("eff_start_date") <= target_date) & (F.col("eff_end_date") > target_date) ) print(f"Customer records active on {target_date}:") historical_view.show() # In SQL spark.sql(f""" SELECT * FROM delta.`{scd2_path}` WHERE eff_start_date <= '{target_date}' AND eff_end_date > '{target_date}' """).show() # Getting the current active records current_view = scd2_table.toDF().filter(F.col("is_current") == True) print("Current active records:") current_view.show()
What to say: The between style filter on eff_start_date and eff_end_date is the fundamental query pattern for SCD Type 2. I use strictly greater than for eff_end_date rather than greater than or equal to, because the end date of the closed record and the start date of the new record are typically consecutive days with no overlap, and I want exactly one row per customer for any given date. The is_current column is a convenience flag that avoids this date arithmetic for the common case of just wanting current records.
Q4. Your SCD Type 2 pipeline runs every day. On day 100 the same customer changes their city twice in one day. How do you handle two changes for the same key in one batch?
When a single source batch contains two changes for the same customer_id, a naive MERGE would fail or produce incorrect results since it cannot handle two source rows matching one target row. The fix is to process within-batch changes sequentially by ordering them and applying them one at a time, or to collapse within-batch changes to only the latest state.
from pyspark.sql.window import Window # If we only care about the final state at end of day # collapse to last change per customer in today's batch w_latest = Window.partitionBy("customer_id").orderBy(F.col("updated_at").desc()) df_source_deduplicated = df_scd2_source \ .withColumn("rn", F.row_number().over(w_latest)) \ .filter(F.col("rn") == 1) \ .drop("rn") # If we care about every intermediate state within the day # process changes in chronological order within the batch changes_ordered = df_scd2_source \ .orderBy("customer_id", "updated_at") \ .collect() for row in changes_ordered: single_row_df = spark.createDataFrame([row], df_scd2_source.schema) run_scd2_safe(scd2_table, single_row_df, today, yesterday)
What to say: For most business use cases, collapsing to the latest state within a batch is the correct choice since a daily pipeline snapshot does not need to capture intermediate intraday states. If the business requirement is to capture every single change including multiple changes within a day, I would need to process the batch in chronological order row by row, which is slower but preserves the full change history. I always clarify with the business analyst whether intraday history is required before deciding which approach to implement, since it has a significant impact on both pipeline complexity and storage consumption.
Q5. What is the storage cost of SCD Type 2 over time and how would you manage it?
SCD Type 2 grows unboundedly since every change adds a new row and the old rows are never deleted. A customer dimension that starts with one million customers and has an average of five changes per customer per year will have six million rows after one year, eleven million after two years, and so on. Over ten years a moderate-sized dimension can become extremely large.
# Monitor SCD2 table growth over time def scd2_health_check(scd2_path): df = spark.read.format("delta").load(scd2_path) total_rows = df.count() active_rows = df.filter(F.col("is_current") == True).count() history_rows = total_rows - active_rows print(f"Total rows: {total_rows:,}") print(f"Active records: {active_rows:,}") print(f"Historical records: {history_rows:,}") print(f"History ratio: {history_rows / total_rows * 100:.1f}%") avg_versions = df.groupBy("customer_id").count() \ .agg(F.avg("count")).collect()[0][0] print(f"Avg versions/customer: {avg_versions:.1f}") scd2_health_check(scd2_path) # Archive very old history to cold storage to reduce hot table size cutoff_date = "2020-01-01" old_history = scd2_table.toDF().filter( (F.col("is_current") == False) & (F.col("eff_end_date") < cutoff_date) ) old_history.write \ .format("delta") \ .mode("append") \ .save("/archive/scd2_history_cold") # Delete archived rows from hot table scd2_table.delete( condition=f"is_current = false AND eff_end_date < '{cutoff_date}'" ) print(f"Archived history older than {cutoff_date} to cold storage")
What to say: Managing SCD Type 2 storage is an operational concern that comes up after the pipeline has been running for a few years. My approach is to archive historical records beyond a retention horizon to a lower-cost cold storage tier while keeping recent history in the hot Delta table. The retention horizon depends on business requirements, regulatory requirements like GDPR or financial audit rules, and storage budget. I would never delete SCD Type 2 history without explicit sign-off from both the business owner and the compliance team, since that history may be required for legal, regulatory, or audit purposes that the data team might not be fully aware of.
Reading Nested JSON and Extracting Fields
The Interviewer Says
I am going to give you a JSON file with nested fields. I want you to read it into a PySpark DataFrame and then extract the nested city and state fields from an address object into their own flat columns. Walk me through how you would approach this, including how you define the schema for the nested structure.
Input JSON Structure
{ "id": 123, "name": "John", "address": { "city": "New York", "state": "NY" } }
How to Handle This in the Interview
Before writing anything, say: JSON with nested objects is one of the most common data formats in API and Kafka based pipelines. PySpark handles this natively through StructType for nested objects and ArrayType for arrays. The key is to define the schema explicitly before reading rather than relying on inferSchema, then use dot notation to access nested fields.
Step 1: Define the schema including the nested struct
- The nested address object is a StructType inside the main StructType. Defining it explicitly avoids the double scan cost of inferSchema and ensures the types are exactly what you expect.
Step 2: Read the JSON with the explicit schema
- Use spark.read.schema(schema).json(path) for files or from_json for JSON strings embedded in a column.
Step 3: Access nested fields with dot notation
- After reading, nested fields are accessed using col("address.city") in PySpark, which is the same dot notation used in SQL.
Step 4: Flatten into a clean single-level DataFrame
- Use select with aliases to produce a clean flat DataFrame with no nested columns, which is easier for downstream processing and writing to relational targets.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import ( StructType, StructField, StringType, IntegerType, ArrayType, DoubleType, BooleanType ) spark = SparkSession.builder \ .appName("NestedJSONDemo") \ .getOrCreate() # ------------------------------------------------------- # PART 1: Read JSON file with nested struct # ------------------------------------------------------- # Define schema including nested address struct schema = StructType([ StructField("id", IntegerType(), True), StructField("name", StringType(), True), StructField("address", StructType([ StructField("city", StringType(), True), StructField("state", StringType(), True), ]), True) ]) # Read from JSON file df = spark.read \ .schema(schema) \ .json("/data/users.json") print("Raw DataFrame with nested address struct:") df.show(truncate=False) df.printSchema() # Access nested fields using dot notation df_flat = df.select( F.col("id"), F.col("name"), F.col("address.city").alias("city"), F.col("address.state").alias("state") ) print("Flattened DataFrame:") df_flat.show() # ------------------------------------------------------- # PART 2: From JSON string column (Kafka / API scenario) # ------------------------------------------------------- # When JSON arrives as a string in a column (very common in Kafka pipelines) json_data = [ ('{"id": 123, "name": "John", "address": {"city": "New York", "state": "NY"}}',), ('{"id": 124, "name": "Jane", "address": {"city": "Los Angeles", "state": "CA"}}',), ('{"id": 125, "name": "Bob", "address": {"city": "Chicago", "state": "IL"}}',), ('{"id": 126, "name": "Alice","address": null}',), # null address ('corrupted json',), # bad record ] df_raw = spark.createDataFrame(json_data, ["payload"]) # Parse JSON string into struct using from_json df_parsed = df_raw.withColumn( "data", F.from_json(F.col("payload"), schema) ) print("After from_json (struct column created):") df_parsed.show(truncate=False) df_parsed.printSchema() # Extract fields from the parsed struct df_extracted = df_parsed.select( F.col("data.id").alias("id"), F.col("data.name").alias("name"), F.col("data.address.city").alias("city"), F.col("data.address.state").alias("state") ) print("Extracted fields:") df_extracted.show() # ------------------------------------------------------- # PART 3: More complex nested JSON with arrays # ------------------------------------------------------- complex_schema = StructType([ StructField("id", IntegerType(), True), StructField("name", StringType(), True), StructField("address", StructType([ StructField("city", StringType(), True), StructField("state", StringType(), True), StructField("zipcode", StringType(), True), ]), True), StructField("orders", ArrayType(StructType([ StructField("order_id", StringType(), True), StructField("amount", DoubleType(), True), StructField("status", StringType(), True), ])), True) ]) complex_data = [ ('{"id":1,"name":"John","address":{"city":"New York","state":"NY","zipcode":"10001"},' '"orders":[{"order_id":"O1","amount":100.0,"status":"delivered"},' '{"order_id":"O2","amount":200.0,"status":"pending"}]}',), ('{"id":2,"name":"Jane","address":{"city":"LA","state":"CA","zipcode":"90001"},' '"orders":[{"order_id":"O3","amount":150.0,"status":"shipped"}]}',), ] df_complex = spark.createDataFrame(complex_data, ["payload"]) df_complex_parsed = df_complex.withColumn( "data", F.from_json(F.col("payload"), complex_schema) ).select( F.col("data.id").alias("id"), F.col("data.name").alias("name"), F.col("data.address.city").alias("city"), F.col("data.address.state").alias("state"), F.col("data.address.zipcode").alias("zipcode"), F.col("data.orders").alias("orders") ) print("Complex nested JSON parsed:") df_complex_parsed.show(truncate=False) # Explode orders array to get one row per order df_orders = df_complex_parsed.select( "id", "name", "city", F.explode("orders").alias("order") ).select( "id", "name", "city", F.col("order.order_id").alias("order_id"), F.col("order.amount").alias("amount"), F.col("order.status").alias("status") ) print("One row per order:") df_orders.show() # ------------------------------------------------------- # PART 4: Handling bad JSON records # ------------------------------------------------------- # from_json returns NULL for any row where parsing fails # The corrupted row will have NULL in all extracted fields df_with_bad = df_raw.withColumn( "data", F.from_json(F.col("payload"), schema) ) bad_records = df_with_bad.filter(F.col("data").isNull()) good_records = df_with_bad.filter(F.col("data").isNotNull()) print(f"Good records: {good_records.count()}") print(f"Bad records: {bad_records.count()}") bad_records.select("payload").show(truncate=False) # ------------------------------------------------------- # PART 5: Writing flat result # ------------------------------------------------------- df_extracted.write \ .format("delta") \ .mode("overwrite") \ .save("/output/users_flat") print("Flat data written to Delta")
What to say: The most common mistake when reading nested JSON is using inferSchema, which works but scans the entire file twice and can infer wrong types, especially for fields that happen to look like integers in the sample but are actually strings in the full dataset. Defining the schema explicitly with StructType and StructField gives you exact control over every field type including the nested ones, and Spark only reads the file once. The dot notation for accessing nested fields like address.city works both in the DataFrame API as F.col with a dot notation string and in Spark SQL as a standard dotted column reference.
Follow-up Questions & Answers
Q1. What is the difference between StructType, ArrayType, and MapType for representing nested JSON structures?
StructType represents a JSON object with named fields, like the address object with city and state. Each field has a fixed name and type. ArrayType represents a JSON array, like a list of orders where each element has the same structure. MapType represents a JSON object where the keys are dynamic and not known in advance, like a dictionary where the key names vary per record. StructType is by far the most common since most JSON objects from APIs have well-defined field names. ArrayType is used for lists of items. MapType is used when the JSON keys themselves are data values rather than fixed field names.
# StructType: fixed named fields (address object) StructType([ StructField("city", StringType(), True), StructField("state", StringType(), True) ]) # ArrayType: ordered list of items (orders list) ArrayType(StructType([ StructField("order_id", StringType(), True), StructField("amount", DoubleType(), True) ])) # MapType: dynamic key-value pairs (tags, metadata) # {"tag1": "value1", "tag2": "value2"} where keys are not fixed MapType(StringType(), StringType())
Q2. How would you infer the schema of a JSON file automatically if you do not know the structure in advance?
# inferSchema reads the file twice but discovers the schema automatically df_inferred = spark.read \ .option("inferSchema", "true") \ .option("multiLine", "true") \ .json("/data/unknown_structure.json") df_inferred.printSchema() # Capture the inferred schema for reuse without re-inferring inferred_schema = df_inferred.schema print(inferred_schema.json()) # prints schema as JSON string # Use the captured schema for future reads df_reread = spark.read \ .schema(inferred_schema) \ .json("/data/unknown_structure.json") # Use schema_of_json to get schema from a sample JSON string sample = '{"id": 123, "name": "John", "address": {"city": "NY", "state": "NY"}}' schema_str = spark.range(1).select(F.schema_of_json(F.lit(sample))).collect()[0][0] print(f"Inferred schema string: {schema_str}")
What to say: I use inferSchema only during exploration and development to understand a new JSON structure I have never seen before. Once I understand the schema I always explicitly define it for production pipelines. The two-scan cost of inferSchema is acceptable in a notebook where I am working interactively, but unacceptable in a scheduled pipeline that runs hundreds of times. I also capture the inferred schema and save it as a JSON string so I can paste it into the explicit schema definition rather than writing it from scratch.
Q3. What happens when a field is present in some JSON records but missing in others?
If a field exists in the schema definition but is missing from some JSON records, from_json returns NULL for that field in those records. If a field exists in some records but is not in the schema definition at all, it is silently ignored. This is permissive parsing which is the default and desirable behaviour for most real pipelines.
# Some records missing the state field mixed_json = [ ('{"id":1,"name":"John","address":{"city":"NY","state":"NY"}}',), ('{"id":2,"name":"Jane","address":{"city":"LA"}}',), # missing state ('{"id":3,"name":"Bob"}',), # missing entire address ] df_mixed = spark.createDataFrame(mixed_json, ["payload"]) df_parsed_mixed = df_mixed.withColumn( "data", F.from_json(F.col("payload"), schema) ).select( F.col("data.id"), F.col("data.name"), F.col("data.address.city").alias("city"), F.col("data.address.state").alias("state") # NULL for record 2 ) df_parsed_mixed.show() # Record 2 shows NULL for state # Record 3 shows NULL for both city and state
What to say: This permissive NULL behaviour for missing fields is exactly why defining the schema explicitly is important. If the schema defines state as StringType and a record has no state field, the record is still successfully parsed with state as NULL. If I had used inferSchema and the sample records used for inference all had state, Spark might infer state as a required non-null field and then fail when it encounters a record without it. Explicit schemas with nullable=True for all fields gives maximum robustness for real-world JSON data.
Q4. How would you convert a flat DataFrame back into a nested JSON string for writing to Kafka or an API?
# Reconstruct the nested address struct from flat columns df_flat_to_nested = df_extracted.select( F.col("id"), F.col("name"), F.struct( F.col("city").alias("city"), F.col("state").alias("state") ).alias("address") ) print("Reconstructed nested struct:") df_flat_to_nested.show(truncate=False) df_flat_to_nested.printSchema() # Convert entire row to a JSON string for Kafka df_as_json = df_flat_to_nested.withColumn( "payload", F.to_json(F.struct( F.col("id"), F.col("name"), F.col("address") )) ) print("As JSON string for Kafka:") df_as_json.select("payload").show(truncate=False) # Write to Kafka (the value column must be a string or binary) df_as_json.select( F.col("id").cast("string").alias("key"), F.col("payload").alias("value") ).write \ .format("kafka") \ .option("kafka.bootstrap.servers", "broker:9092") \ .option("topic", "processed_users") \ .save()
What to say: The struct function recreates a nested struct column from flat columns, and to_json converts any column expression including struct and array columns into a JSON string. This round trip from nested JSON to flat DataFrame and back to JSON string is the standard pattern for Kafka-to-Kafka transformation pipelines where the input and output are both JSON streams but the business logic operates on flat columns in between.
Q5. How would you handle a JSON file where each line is a separate JSON object, commonly called JSONL or newline-delimited JSON?
# Standard JSON file: one JSON document (could span multiple lines) df_standard = spark.read \ .option("multiLine", "true") \ .json("/data/single_document.json") # JSONL file: one JSON object per line (most common for large datasets) # This is the DEFAULT for spark.read.json df_jsonl = spark.read \ .schema(schema) \ .json("/data/records.jsonl") # Each line is parsed as a separate record automatically # Mixed: some records span multiple lines df_multi = spark.read \ .option("multiLine", "true") \ .option("mode", "PERMISSIVE") \ .json("/data/mixed.json") # Writing JSONL (one JSON per line, efficient for large datasets) df_flat.write \ .mode("overwrite") \ .json("/output/users_jsonl") # By default Spark writes one JSON object per line # Write as pretty-printed multi-line JSON (not recommended for large files) # PySpark does not natively support this, use coalesce + UDF if needed
What to say: JSONL is actually Spark's default JSON format when both reading and writing, where each line is an independent JSON record. This is the most efficient format for large datasets because Spark can split the file into multiple partitions and read each partition in parallel without needing to parse the entire document first. The multiLine option is needed only for the rare case where a single JSON document spans multiple lines, which prevents parallel reading since Spark cannot know where one record ends and another begins without parsing the entire file first. I always prefer JSONL over multiLine JSON for any dataset larger than a few megabytes.
Optimisation Techniques and Production Best Practices — Final Round
The Interviewer Says
This is the last question and it is a broad one. You are joining our data engineering team next week. Walk me through what you would do in your first hour, first day, and first week. Then tell me what optimisation techniques you have applied in past projects with real impact numbers. I am looking for engineering maturity and production mindset here.
How to Handle This in the Interview
Before answering, say: This question is really asking about how I think about production systems, not just how I write code. Let me answer the first hour, day, week part genuinely, and then walk through the optimisations with the context of the actual problems they solved.
Step 1: First hour, day, week shows your professional maturity
- First hour: understand the environment, do not touch anything. First day: understand the data, the pipelines, and the team's pain points. First week: make one small measurable improvement.
Step 2: Frame optimisations as problem-solution-impact
- Every optimisation should have a before state, the diagnosis, the fix, and the measured improvement. Numbers matter.
Step 3: Mention the things you would never do
- Never change production configuration without understanding the impact. Never tune shuffle partitions before fixing join strategies. Never cache without unpersisting.
Step 4: Show that you know what you do not know
- Saying I would spend the first day understanding the existing architecture before suggesting any changes shows more maturity than immediately proposing solutions.
Solution
# ------------------------------------------------------- # FIRST HOUR: Understand the environment # ------------------------------------------------------- # What I would do, not code: # # 1. Open the Spark UI for the last three pipeline runs # and scan for any stages with high shuffle bytes or skewed tasks # This gives an instant picture of where bottlenecks currently live # # 2. Check the Delta table DESCRIBE HISTORY for the main tables # to understand how frequently they are written and what operations run spark.sql("DESCRIBE HISTORY delta.`/data/main_transactions`").show(20) # 3. Check table sizes and file counts spark.sql("DESCRIBE DETAIL delta.`/data/main_transactions`").show() # 4. Check the current Spark configuration in use for key in [ "spark.sql.shuffle.partitions", "spark.sql.adaptive.enabled", "spark.sql.autoBroadcastJoinThreshold", "spark.executor.memory", "spark.executor.cores", ]: try: value = spark.conf.get(key) print(f"{key}: {value}") except Exception: print(f"{key}: not set (using default)") # ------------------------------------------------------- # FIRST DAY: Understand the data and pipelines # ------------------------------------------------------- # 1. Profile the main tables for null rates and value distributions def quick_profile(df, sample_fraction=0.01): sampled = df.sample(fraction=sample_fraction, seed=42) total = sampled.count() print(f"Profiling {total:,} sampled rows ({sample_fraction*100:.0f}% sample)") print(f"\nNull rates:") null_pct = sampled.select([ F.round( F.count(F.when(F.col(c).isNull(), c)) * 100.0 / total, 2 ).alias(c + "_null_pct") for c in sampled.columns ]) null_pct.show() print(f"\nBasic stats for numeric columns:") numeric_cols = [c for c, t in sampled.dtypes if t in ("int", "bigint", "double", "float")] if numeric_cols: sampled.select(numeric_cols).describe().show() df_main = spark.read.format("delta").load("/data/main_transactions") quick_profile(df_main) # 2. Understand the join keys and their cardinality def check_join_key_cardinality(df, key_col): total = df.count() distinct_keys = df.select(key_col).distinct().count() max_freq = df.groupBy(key_col).count() \ .agg(F.max("count")).collect()[0][0] print(f"\nJoin key analysis for: {key_col}") print(f" Total rows: {total:,}") print(f" Distinct values: {distinct_keys:,}") print(f" Max frequency: {max_freq:,}") print(f" Avg frequency: {total/distinct_keys:.1f}") print(f" Skew indicator: {max_freq / (total/distinct_keys):.1f}x") if max_freq > (total / distinct_keys) * 10: print(" WARNING: Significant skew detected on this key") check_join_key_cardinality(df_main, "customer_id") # ------------------------------------------------------- # FIRST WEEK: Make one measurable improvement # ------------------------------------------------------- # Example: Discovered the main customer join was using SortMergeJoin # because customers table (50MB) exceeded the 10MB broadcast threshold # Fix: raise threshold to 100MB and add explicit broadcast hint # BEFORE (measure the slow version) import time df_customers = spark.read.format("delta").load("/data/customers") start = time.time() df_main.join(df_customers, on="customer_id") \ .groupBy("region") \ .agg(F.sum("amount")) \ .count() before_time = time.time() - start print(f"Before optimisation: {before_time:.2f}s") # AFTER (apply fix) spark.conf.set("spark.sql.autoBroadcastJoinThreshold", str(100 * 1024 * 1024)) start = time.time() df_main.join(F.broadcast(df_customers), on="customer_id") \ .groupBy("region") \ .agg(F.sum("amount")) \ .count() after_time = time.time() - start print(f"After optimisation: {after_time:.2f}s") print(f"Improvement: {before_time / after_time:.1f}x faster") # Document the finding and fix clearly optimisation_log = { "date": "2024-02-01", "pipeline": "daily_regional_summary", "problem": "SortMergeJoin on 50MB customers table (threshold was 10MB)", "symptom": "Stage 2 taking 45 minutes due to full shuffle of both tables", "diagnosis": "explain() showed SortMergeJoin, threshold was too low", "fix": "Raised autoBroadcastJoinThreshold to 100MB + explicit broadcast hint", "before": f"{before_time:.1f}s", "after": f"{after_time:.1f}s", "improvement": f"{before_time / after_time:.1f}x faster", } print("\nOptimisation log:") for k, v in optimisation_log.items(): print(f" {k:<15}: {v}")
What to say: In the first hour I only observe. I look at Spark UI history, table details, and configuration without changing anything. In the first day I understand the data through profiling and understand which pipelines are causing pain for the team. By the end of the first week I want to have made one specific measurable improvement with documented before and after numbers. One concrete improvement with real numbers does more to establish credibility with a new team than a long list of theoretical suggestions. The discipline of measuring before and after every change is how I make sure I am actually solving the right problem rather than the problem I assumed existed.
Follow-up Questions & Answers
Q1. What are the top three mistakes you see junior data engineers make with PySpark in production?
The first mistake is calling collect on large DataFrames. Junior engineers often come from Pandas where iterating over rows is normal, and they bring that habit to PySpark without realising collect transfers all data to the Driver. I have seen this crash production clusters with out of memory errors on what appeared to be a small finalisation step.
The second mistake is not handling NULL values explicitly. A cast that fails silently returns NULL, an equality comparison with NULL returns false, and a filter that should exclude a record sometimes passes it through because of NULL semantics. Production data always has NULLs and they behave differently from empty strings and zeros in ways that are not obvious.
The third mistake is writing transformation logic inside Python UDFs when equivalent built-in functions exist. I have seen pipelines take four hours because a simple string cleaning operation was written as a Python UDF row by row instead of using the built-in regexp_replace and lower functions that run in the JVM.
Q2. How do you handle a situation where a pipeline that worked in development fails in production with different data?
# Production data is almost always messier than development data # Build defensive data validation into every pipeline def validate_before_processing(df, pipeline_name): issues = [] # Check 1: Not empty row_count = df.count() if row_count == 0: issues.append("DataFrame is empty") # Check 2: Critical columns have acceptable null rates critical_cols = ["customer_id", "amount", "txn_date"] for col_name in critical_cols: null_count = df.filter(F.col(col_name).isNull()).count() null_pct = null_count / row_count * 100 if row_count > 0 else 0 if null_pct > 5: issues.append(f"{col_name} has {null_pct:.1f}% nulls (threshold: 5%)") # Check 3: Volume is within expected range (no sudden 10x spikes or drops) expected_min = 1000 expected_max = 10000000 if row_count < expected_min: issues.append(f"Row count {row_count} below minimum {expected_min}") if row_count > expected_max: issues.append(f"Row count {row_count} above maximum {expected_max}") if issues: for issue in issues: print(f"VALIDATION FAILED: {issue}") raise ValueError(f"{pipeline_name} failed validation: {issues}") print(f"Validation passed: {row_count:,} rows, all checks passed") return df df_validated = validate_before_processing(df_main, "daily_transactions")
What to say: The defensive validation pattern is something I add to every production pipeline because production data is never as clean as development data. The three most common failure modes I have seen are empty DataFrames from upstream pipeline failures, unexpectedly high null rates from schema changes in the source system, and extreme row count spikes from a data backfill that was not communicated to the downstream team. Each of these is easy to catch with a pre-processing validation check and extremely painful to debug after the fact when they have already corrupted a downstream table.
Q3. How do you manage Spark configuration across different environments, development, staging, and production?
def get_spark_config(environment="dev"): configs = { "dev": { "spark.sql.shuffle.partitions": "10", "spark.sql.adaptive.enabled": "true", "spark.executor.memory": "2g", "spark.executor.cores": "2", "spark.sql.autoBroadcastJoinThreshold": str(10 * 1024 * 1024), }, "staging": { "spark.sql.shuffle.partitions": "100", "spark.sql.adaptive.enabled": "true", "spark.executor.memory": "8g", "spark.executor.cores": "4", "spark.sql.autoBroadcastJoinThreshold": str(50 * 1024 * 1024), }, "prod": { "spark.sql.shuffle.partitions": "500", "spark.sql.adaptive.enabled": "true", "spark.executor.memory": "16g", "spark.executor.cores": "8", "spark.sql.autoBroadcastJoinThreshold": str(100 * 1024 * 1024), } } return configs.get(environment, configs["dev"]) import os env = os.getenv("ENVIRONMENT", "dev") config = get_spark_config(env) builder = SparkSession.builder.appName("MyPipeline") for key, value in config.items(): builder = builder.config(key, value) spark = builder.getOrCreate() print(f"Running in {env} environment with {len(config)} config overrides")
What to say: Environment-specific configuration is essential because the optimal settings for a development laptop with 16GB RAM are completely wrong for a production cluster with 500GB of distributed memory. I store configuration as code in a dictionary keyed by environment name, read the environment from an environment variable set by the deployment system, and apply it at SparkSession creation. This means the pipeline code itself never changes between environments, only the configuration values, which is the correct separation of concerns.
Q4. How do you ensure a pipeline is production-ready before deploying it?
Production readiness has five dimensions that I check before every deployment.
Correctness: the output has been validated against a known-good reference dataset or against manual calculations for a sample of records.
Robustness: the pipeline handles empty input, high null rates, schema variations, and large unexpected data volumes without crashing.
Performance: the pipeline has been profiled on production-scale data, any stages with high shuffle bytes or skewed tasks have been addressed, and the runtime is within the SLA.
Observability: the pipeline logs its start time, end time, input row count, output row count, and any data quality metrics to a monitoring table that feeds an alert system.
Recoverability: the pipeline is idempotent, meaning re-running it for the same time window produces the same result without duplicates, so a failed run can be safely retried without manual cleanup.
Q5. What is the most challenging data engineering problem you have solved and how did you approach it?
What to say: Adapt this answer to your own real experience. Here is a template structure that works well.
The most challenging problem I solved was [describe the business context and why it mattered]. The symptoms were [what you observed, ideally with numbers]. The diagnosis took [how long] because [what made it non-obvious]. The root cause turned out to be [the actual problem]. I fixed it by [what you changed]. The result was [measured improvement]. What I learned was [the generalised lesson that applies beyond this specific case].
# Example structure for your answer: # # "We had a daily pipeline processing 500GB of transaction data # that was taking 6 hours and sometimes failing with OOM. # The symptoms were one executor task taking 40 minutes # while all others finished in 30 seconds. # The diagnosis took two days because the skew only appeared # in the production dataset, not in our test data. # The root cause was that one customer_id accounted for # 12% of all transactions, creating a massive skewed partition. # I fixed it by salting the join key with 20 random buckets. # The result was the job went from 6 hours to 35 minutes. # What I learned was to always check key distribution # before designing a join, not after." print("Structure your answer with: context, symptoms, diagnosis, root cause, fix, impact, lesson")
What to say: The key to answering this question well is specificity. Vague answers like I optimised a slow query sound unconvincing. Specific answers with numbers like I reduced a 6-hour job to 35 minutes by identifying and fixing data skew on one join key demonstrate real engineering experience. Every data engineer has at least one story like this. Think of yours before the interview and be ready to tell it clearly with the five elements: what was slow, why was it slow, what did you change, what was the result, and what did you learn.
Data Masking and PII Obfuscation
The Interviewer Says
We handle customer data that contains personally identifiable information like email addresses and mobile numbers. Before this data is shared with analysts or written to a reporting layer, we need to mask the sensitive parts. Write PySpark code to mask the email so only the first character and the domain are visible, and mask the mobile number so only the last three digits are visible. Walk me through your approach.
Input Data
| customer_id | name | mobile | |
|---|---|---|---|
| C01 | Karan | karan.sharma@gmail.com | 9876543210 |
| C02 | Asha | asha.verma@yahoo.com | 8765432109 |
| C03 | Ravi | ravi123@company.co.in | 7654321098 |
| C04 | Meena | meena@outlook.com | 9988776655 |
| C05 | Sunil | NULL | NULL |
How to Handle This in the Interview
Before writing anything, say: Data masking is a critical security requirement in any pipeline that handles PII. The approach depends on how much of the original value needs to remain visible for identification purposes versus how much needs to be hidden. I will use a combination of substring extraction, regexp_replace, and string concatenation to produce the masked values.
Step 1: Break down the email masking logic
- The visible parts are the first character of the username and the full domain including the at symbol. Everything between the first character and the at symbol is replaced with asterisks. The number of asterisks can either match the hidden character count exactly for consistency or use a fixed count to avoid revealing the username length.
Step 2: Break down the mobile masking logic
- Only the last three digits are visible. Everything before the last three digits is replaced with asterisks. Use RIGHT to get the last three digits and REPEAT or rpad logic to prepend the asterisks.
Step 3: Handle NULL values explicitly
- If email or mobile is NULL, the masking functions should return NULL rather than failing. PySpark built-in functions return NULL when the input is NULL so this is handled automatically, but it is worth mentioning.
Step 4: Mention the difference between masking and encryption
- Say: Masking replaces part of the value with a placeholder and is irreversible. Encryption transforms the entire value and is reversible with the right key. For display purposes masking is appropriate. For cases where the original value needs to be recovered, encryption is needed.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import StringType spark = SparkSession.builder \ .appName("DataMaskingDemo") \ .getOrCreate() data = [ ("C01", "Karan", "karan.sharma@gmail.com", "9876543210"), ("C02", "Asha", "asha.verma@yahoo.com", "8765432109"), ("C03", "Ravi", "ravi123@company.co.in", "7654321098"), ("C04", "Meena", "meena@outlook.com", "9988776655"), ("C05", "Sunil", None, None), ] df = spark.createDataFrame( data, ["customer_id", "name", "email", "mobile"] ) print("Raw DataFrame:") df.show(truncate=False) # ------------------------------------------------------- # PART 1: Email Masking # ------------------------------------------------------- # Strategy: keep first char + mask middle + keep @domain # karan.sharma@gmail.com -> k***********@gmail.com df_masked_email = df.withColumn( "masked_email", F.when( F.col("email").isNull(), F.lit(None) ).otherwise( F.concat( # First character of email (before @) F.substring(F.col("email"), 1, 1), # Fixed asterisks to hide middle part F.lit("***********"), # Everything from @ onwards F.substring( F.col("email"), F.instr(F.col("email"), "@"), F.length(F.col("email")) ) ) ) ) print("After email masking (fixed asterisks):") df_masked_email.select("customer_id", "email", "masked_email").show(truncate=False) # ------------------------------------------------------- # PART 2: Dynamic email masking (asterisk count matches hidden chars) # ------------------------------------------------------- df_masked_email_dynamic = df.withColumn( "at_position", F.instr(F.col("email"), "@") ).withColumn( "masked_email_dynamic", F.when( F.col("email").isNull(), F.lit(None) ).otherwise( F.concat( # First character F.substring(F.col("email"), 1, 1), # Asterisks equal to hidden character count (from position 2 to @-1) F.repeat( F.lit("*"), F.col("at_position") - 2 ), # @ and domain F.substring( F.col("email"), F.col("at_position"), F.length(F.col("email")) ) ) ) ).drop("at_position") print("After dynamic email masking:") df_masked_email_dynamic.select( "customer_id", "email", "masked_email_dynamic" ).show(truncate=False) # ------------------------------------------------------- # PART 3: Mobile Number Masking # ------------------------------------------------------- # Strategy: show only last 3 digits, replace rest with * # 9876543210 -> *******210 df_masked_mobile = df.withColumn( "masked_mobile", F.when( F.col("mobile").isNull(), F.lit(None) ).otherwise( F.concat( # Asterisks for all digits except last 3 F.repeat( F.lit("*"), F.length(F.col("mobile")) - 3 ), # Last 3 digits F.substring( F.col("mobile"), F.length(F.col("mobile")) - 2, 3 ) ) ) ) print("After mobile masking:") df_masked_mobile.select("customer_id", "mobile", "masked_mobile").show(truncate=False) # ------------------------------------------------------- # PART 4: Combined masking in one step # ------------------------------------------------------- df_fully_masked = df \ .withColumn( "masked_email", F.when(F.col("email").isNull(), F.lit(None)) .otherwise( F.concat( F.substring(F.col("email"), 1, 1), F.repeat(F.lit("*"), F.instr(F.col("email"), "@") - 2), F.substring( F.col("email"), F.instr(F.col("email"), "@"), F.length(F.col("email")) ) ) ) ) \ .withColumn( "masked_mobile", F.when(F.col("mobile").isNull(), F.lit(None)) .otherwise( F.concat( F.repeat(F.lit("*"), F.length(F.col("mobile")) - 3), F.substring( F.col("mobile"), F.length(F.col("mobile")) - 2, 3 ) ) ) ) \ .drop("email", "mobile") print("Fully masked DataFrame:") df_fully_masked.show(truncate=False) # ------------------------------------------------------- # PART 5: Using regexp_replace for pattern-based masking # ------------------------------------------------------- # Mask all digits in the mobile except last 3 using regex df_regex_masked = df.withColumn( "masked_mobile_regex", F.when( F.col("mobile").isNull(), F.lit(None) ).otherwise( F.regexp_replace( F.col("mobile"), r"^\d+(?=\d{3}$)", # match all digits that are followed by exactly 3 digits at end F.repeat(F.lit("*"), F.length(F.col("mobile")) - 3) ) ) ) print("After regex mobile masking:") df_regex_masked.select("customer_id", "mobile", "masked_mobile_regex").show(truncate=False) # ------------------------------------------------------- # PART 6: Name masking (show only initials) # ------------------------------------------------------- # Karan -> K***** df_masked_name = df.withColumn( "masked_name", F.when( F.col("name").isNull(), F.lit(None) ).otherwise( F.concat( F.substring(F.col("name"), 1, 1), F.repeat(F.lit("*"), F.length(F.col("name")) - 1) ) ) ) print("After name masking:") df_masked_name.select("customer_id", "name", "masked_name").show(truncate=False) # ------------------------------------------------------- # PART 7: Reusable masking UDF # ------------------------------------------------------- from pyspark.sql.functions import pandas_udf import pandas as pd import re @pandas_udf(StringType()) def mask_email_udf(email_series: pd.Series) -> pd.Series: def mask_single(email): if pd.isna(email) or "@" not in email: return email at_pos = email.index("@") username = email[:at_pos] domain = email[at_pos:] masked = username[0] + "*" * (len(username) - 1) return masked + domain return email_series.apply(mask_single) df_udf_masked = df.withColumn( "masked_email_udf", mask_email_udf(F.col("email")) ) print("After Pandas UDF email masking:") df_udf_masked.select("customer_id", "email", "masked_email_udf").show(truncate=False)
What to say: I prefer built-in PySpark functions over UDFs for masking because they run in the JVM without Python serialisation overhead and scale better on large datasets. The key operations are instr to find the position of the at symbol in the email, substring to extract specific portions of the string, repeat to generate the asterisk placeholder, and concat to assemble the masked result. For complex masking patterns that cannot be expressed with built-in functions, a Pandas UDF is the next best option since it processes data in batches rather than row by row like a regular Python UDF.
Expected Output
| Customer ID | Name | Masked Email | Masked Mobile |
|---|---|---|---|
| C01 | Karan | k***********@gmail.com | *******210 |
| C02 | Asha | a***********@yahoo.com | *******109 |
| C03 | Ravi | r***********@company.co.in | *****098 |
| C04 | Meena | m***********@outlook.com | *******655 |
| C05 | Sunil | NULL | NULL |
Follow-up Questions & Answers
Q1. What is the difference between data masking, data anonymisation, and data encryption, and when would you use each?
Data masking replaces part of a value with a placeholder like asterisks. The result is still recognisable in structure, like an email that still looks like an email, but the sensitive portion is hidden. Masking is irreversible and is used for display purposes or when sharing data with analysts who need to see the data shape without seeing the actual values.
Data anonymisation removes or transforms data so that an individual cannot be identified even in combination with other data. It is stronger than masking and is used when data is shared externally or used for research. True anonymisation is difficult to achieve since indirect identifiers like age, postcode, and job title can often be combined to re-identify individuals.
Data encryption transforms the entire value into an unreadable format using an algorithm and key. The original value can be recovered with the correct key. Encryption is used when the original value needs to be stored securely but retrieved later, for example storing payment card numbers for recurring billing.
# Encryption example using a hash (one-way, for tokenisation) df.withColumn( "email_token", F.sha2(F.col("email"), 256) # SHA-256 hash ) # This creates a consistent token for the same email # useful for joining datasets without revealing the actual email
Q2. How would you mask a credit card number to show only the last four digits in the format ****-****-****-1234?
data_cc = [ ("C01", "4532015112830366"), ("C02", "5425233430109903"), ("C03", "6011111111111117"), ("C04", None), ] df_cc = spark.createDataFrame(data_cc, ["customer_id", "card_number"]) df_masked_cc = df_cc.withColumn( "masked_card", F.when( F.col("card_number").isNull(), F.lit(None) ).otherwise( F.concat( F.lit("****-****-****-"), F.substring(F.col("card_number"), -4, 4) ) ) ) df_masked_cc.show(truncate=False)
What to say: Negative indexing in PySpark substring means counting from the end of the string, so substring with -4 and length 4 extracts the last four characters regardless of the total card number length. I always add the formatted dashes as literals in the concat to make the masked output look like a properly formatted card number rather than just four trailing digits, which makes it more immediately recognisable as a card number placeholder in reports.
Q3. How would you implement consistent tokenisation so the same email always maps to the same token across different pipeline runs?
# Consistent tokenisation using SHA-256 hash # The same input always produces the same hash df_tokenised = df.withColumn( "email_token", F.sha2(F.lower(F.trim(F.col("email"))), 256) ).withColumn( "mobile_token", F.sha2(F.col("mobile"), 256) ) print("Tokenised DataFrame:") df_tokenised.select( "customer_id", "email_token", "mobile_token" ).show(truncate=False) # HMAC-based tokenisation with a secret key (more secure) # Requires a UDF since PySpark does not have native HMAC support import hmac import hashlib secret_key = b"my_secret_key_2025" @F.udf(StringType()) def hmac_token(value): if value is None: return None return hmac.new( secret_key, value.encode(), hashlib.sha256 ).hexdigest() df_hmac = df.withColumn( "email_hmac_token", hmac_token(F.col("email")) ) df_hmac.select("customer_id", "email", "email_hmac_token").show(truncate=False)
What to say: Consistent tokenisation with SHA-256 means the same email always produces the same token hash, which allows joining tokenised datasets from different sources without ever exposing the actual email value. The limitation of plain SHA-256 is that it is deterministic and publicly known, so if an attacker knows a small set of possible emails they can hash each one and compare to the token. HMAC with a secret key prevents this since the token depends on both the value and the secret, which the attacker does not have.
Q4. How would you apply different masking rules to different columns dynamically based on a configuration table?
# Configuration: which columns need masking and what type masking_config = { "email": "email_mask", "mobile": "mobile_mask", "name": "name_initial" } def apply_masking(df, config): for col_name, mask_type in config.items(): if col_name not in df.columns: continue if mask_type == "email_mask": df = df.withColumn( col_name, F.when(F.col(col_name).isNull(), F.lit(None)) .otherwise( F.concat( F.substring(F.col(col_name), 1, 1), F.repeat(F.lit("*"), F.instr(F.col(col_name), "@") - 2), F.substring( F.col(col_name), F.instr(F.col(col_name), "@"), F.length(F.col(col_name)) ) ) ) ) elif mask_type == "mobile_mask": df = df.withColumn( col_name, F.when(F.col(col_name).isNull(), F.lit(None)) .otherwise( F.concat( F.repeat(F.lit("*"), F.length(F.col(col_name)) - 3), F.substring( F.col(col_name), F.length(F.col(col_name)) - 2, 3 ) ) ) ) elif mask_type == "name_initial": df = df.withColumn( col_name, F.when(F.col(col_name).isNull(), F.lit(None)) .otherwise( F.concat( F.substring(F.col(col_name), 1, 1), F.repeat(F.lit("*"), F.length(F.col(col_name)) - 1) ) ) ) return df df_dynamically_masked = apply_masking(df, masking_config) df_dynamically_masked.show(truncate=False)
What to say: The configuration-driven approach separates the masking rules from the masking logic. When a new column needs masking or an existing column needs a different masking style, you update the configuration dictionary rather than modifying pipeline code. In a production pipeline this configuration would typically live in a Delta table or a JSON config file rather than a hardcoded dictionary, making it manageable by a data governance team without needing to touch the pipeline code.
Q5. How would you audit which users accessed the unmasked PII data in a Databricks environment?
# In Databricks Unity Catalog, column-level access control # prevents unauthorised users from seeing PII columns at all # This is enforced at the catalog level without any pipeline code # For audit logging in PySpark, write access events to an audit table from datetime import datetime def log_pii_access(spark, user_id, table_name, columns_accessed, row_count): audit_data = [( user_id, table_name, ",".join(columns_accessed), row_count, datetime.now().isoformat(), spark.sparkContext.applicationId )] audit_df = spark.createDataFrame( audit_data, ["user_id", "table_name", "columns_accessed", "rows_accessed", "access_timestamp", "spark_app_id"] ) audit_df.write \ .format("delta") \ .mode("append") \ .save("/audit/pii_access_log") print(f"Access logged for user {user_id} on {table_name}") # Log access when reading PII columns log_pii_access( spark, user_id="analyst_123", table_name="customers", columns_accessed=["email", "mobile"], row_count=df.count() )
What to say: In Databricks Unity Catalog, column-level security and row-level security policies enforce PII protection at the catalog level, meaning users without permission simply cannot query the column regardless of how the pipeline is written. For environments without Unity Catalog, the audit log pattern at least provides visibility into who accessed what data and when. I always recommend column masking at the view or policy level rather than at the pipeline level because pipeline-level masking can be bypassed by anyone with direct table access, whereas catalog-level policies cannot.
Generating All Combination Pairs Using Cross Join
The Interviewer Says
We have a cricket teams table with team names. Generate all possible match fixtures, meaning every unique pair of teams that should play against each other, without repeating the same pair in both directions. Also show the total number of matches in the fixture list. Walk me through your thinking.
Input Data
| team_id | team_name |
|---|---|
| T01 | India |
| T02 | Australia |
| T03 | England |
| T04 | Pakistan |
| T05 | New Zealand |
How to Handle This in the Interview
Before writing anything, say: This is a combinatorics problem. I need every unique unordered pair from a set of five teams. The mathematical formula is n choose 2 which gives n times n minus 1 divided by 2. For five teams that is 10 matches. In SQL this was solved with a self join. In PySpark the approach is a cross join of the DataFrame with itself followed by a filter to avoid duplicates and self-matches.
Step 1: Cross join the DataFrame with itself
- A cross join produces every possible combination of rows from the left and right sides. With five teams this produces 25 rows including a team versus itself and both orderings of every pair.
Step 2: Filter out self-matches
- Remove rows where team_id_1 equals team_id_2 since a team cannot play against itself.
Step 3: Remove duplicate pairs
- India vs Australia and Australia vs India are the same fixture. Filter where team_id_1 is less than team_id_2 to keep only one direction per pair.
Step 4: Confirm the count
- Five teams choose two equals ten matches. Verify the output has exactly ten rows.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = SparkSession.builder \ .appName("MatchFixturesDemo") \ .getOrCreate() data = [ ("T01", "India"), ("T02", "Australia"), ("T03", "England"), ("T04", "Pakistan"), ("T05", "New Zealand"), ] df_teams = spark.createDataFrame(data, ["team_id", "team_name"]) print("Teams:") df_teams.show() # ------------------------------------------------------- # APPROACH 1: Cross join with filter # ------------------------------------------------------- # Alias both sides to avoid column name conflicts df_team_a = df_teams.alias("team_a") df_team_b = df_teams.alias("team_b") # Cross join: every team against every other team df_all_combinations = df_team_a.crossJoin(df_team_b) print(f"All combinations (including self-matches and duplicates): {df_all_combinations.count()}") # Filter: remove self-matches and keep only one direction per pair df_fixtures = df_all_combinations.filter( F.col("team_a.team_id") < F.col("team_b.team_id") ).select( F.col("team_a.team_id").alias("team_1_id"), F.col("team_a.team_name").alias("team_1"), F.col("team_b.team_id").alias("team_2_id"), F.col("team_b.team_name").alias("team_2"), F.concat( F.col("team_a.team_name"), F.lit(" vs "), F.col("team_b.team_name") ).alias("fixture") ) print("All unique fixtures:") df_fixtures.show(truncate=False) print(f"Total fixtures: {df_fixtures.count()}") # ------------------------------------------------------- # APPROACH 2: Self join (equivalent, explicit join syntax) # ------------------------------------------------------- df_fixtures_join = df_teams.alias("a").join( df_teams.alias("b"), F.col("a.team_id") < F.col("b.team_id"), how="inner" ).select( F.col("a.team_name").alias("team_1"), F.col("b.team_name").alias("team_2"), F.concat( F.col("a.team_name"), F.lit(" vs "), F.col("b.team_name") ).alias("fixture") ).orderBy("team_1", "team_2") print("Fixtures using self join:") df_fixtures_join.show(truncate=False) # ------------------------------------------------------- # PART 2: Round robin schedule with match number # ------------------------------------------------------- from pyspark.sql.window import Window df_scheduled = df_fixtures.withColumn( "match_number", F.row_number().over( Window.orderBy("team_1_id", "team_2_id") ) ).withColumn( "match_date", F.date_add( F.lit("2025-06-01"), (F.col("match_number") - 1) * 3 # one match every 3 days ) ) print("Scheduled fixtures:") df_scheduled.select( "match_number", "fixture", "match_date" ).show(truncate=False) # ------------------------------------------------------- # PART 3: Count total matches given n teams # ------------------------------------------------------- n_teams = df_teams.count() total_matches = n_teams * (n_teams - 1) // 2 print(f"Number of teams: {n_teams}") print(f"Total matches (n choose 2 = {n_teams} * {n_teams-1} / 2): {total_matches}") # ------------------------------------------------------- # PART 4: Group stage format - each team plays every other team twice # ------------------------------------------------------- df_home_away = df_teams.alias("home").join( df_teams.alias("away"), F.col("home.team_id") != F.col("away.team_id"), how="inner" ).select( F.col("home.team_name").alias("home_team"), F.col("away.team_name").alias("away_team"), F.concat( F.col("home.team_name"), F.lit(" (H) vs "), F.col("away.team_name"), F.lit(" (A)") ).alias("fixture") ).orderBy("home_team", "away_team") print("Home and away fixtures (each pair twice):") df_home_away.show(truncate=False) print(f"Total home-away fixtures: {df_home_away.count()}")
What to say: The cross join of five teams produces 25 rows, including a team against itself five times and each pair twice in both directions. The filter team_a.team_id less than team_b.team_id eliminates both self-matches and duplicates in one condition, giving exactly 10 unique fixtures which matches the formula n choose 2. I use team_id comparison rather than team_name comparison to avoid any edge case where two teams might have similar names and the alphabetic comparison could be ambiguous.
Expected Output
| Team 1 | Team 2 | Fixture |
|---|---|---|
| India | Australia | India vs Australia |
| India | England | India vs England |
| India | Pakistan | India vs Pakistan |
| India | New Zealand | India vs New Zealand |
| Australia | England | Australia vs England |
| Australia | Pakistan | Australia vs Pakistan |
| Australia | New Zealand | Australia vs New Zealand |
| England | Pakistan | England vs Pakistan |
| England | New Zealand | England vs New Zealand |
| Pakistan | New Zealand | Pakistan vs New Zealand |
Total fixtures: 10
Follow-up Questions & Answers
Q1. What is a cross join and when is it appropriate to use one in production?
A cross join produces the cartesian product of two DataFrames, meaning every row from the left side is paired with every row from the right side. If the left has M rows and the right has N rows, the result has M times N rows. Cross joins are appropriate when you genuinely need all combinations, like generating fixtures, creating all date-product combinations for a reporting grid, or generating test data. They are dangerous on large DataFrames because the output size grows quadratically. A cross join of two DataFrames each with one million rows produces one trillion rows which would almost certainly crash any cluster. I always add a comment in code that uses crossJoin explaining why it is intentional and what the expected output size is.
Q2. How would you detect an accidental cross join in a Spark job?
# Check the query plan for CartesianProduct nodes df_result.explain(mode="formatted") # Look for: CartesianProduct in the plan # This indicates a cross join, intentional or accidental # Spark configuration to raise an error for accidental cross joins spark.conf.set("spark.sql.crossJoin.enabled", "false") # With this setting, any cross join raises AnalysisException # unless explicitly written as df.crossJoin() # To detect large output unexpectedly df_result.count() # If the count is much larger than either input, # a cross join might have occurred accidentally # For example a join with a missing ON condition produces a cross join # COMMON ACCIDENTAL CROSS JOIN: missing join condition df_accidental = df_teams.join(df_teams) # no on= condition specified # This silently produces a cartesian product in some Spark versions
What to say: Accidental cross joins are one of the most common causes of Spark job failures in production. They happen when a join condition is accidentally omitted or when a join key has no matching rows and the fallback is a cartesian product. Setting spark.sql.crossJoin.enabled to false forces all cross joins to be explicit, which causes the job to fail with a clear error rather than silently producing billions of rows. I enable this setting in all production pipelines as a safety net.
Q3. How would you extend this to generate a balanced round-robin tournament schedule where every team plays on each available date?
# Generate dates for the tournament num_rounds = n_teams - 1 # for odd n, use n rounds matches_per_day = n_teams // 2 # Assign each fixture to a round where each team plays exactly once per round # This requires a proper round-robin algorithm # Simple approach for scheduling purposes: # collect fixtures and assign rounds manually using modulo fixtures_collected = df_fixtures.collect() total = len(fixtures_collected) schedule_data = [ ( idx + 1, # match number fixtures_collected[idx]["team_1"], fixtures_collected[idx]["team_2"], f"Round {(idx // (n_teams // 2)) + 1}", # round number f"Day {idx + 1}" # match day ) for idx in range(total) ] df_schedule = spark.createDataFrame( schedule_data, ["match_num", "team_1", "team_2", "round", "day"] ) df_schedule.show(truncate=False)
Q4. How would you use cross join to generate a date-product grid for a sales report?
This is a very common real-world use case where you need every date-product combination to appear in a report, even if no sales occurred on that date for that product.
# Create a date range from pyspark.sql.functions import sequence, explode, to_date df_dates = spark.range(1).select( explode( sequence( to_date(F.lit("2025-01-01")), to_date(F.lit("2025-01-31")), F.expr("interval 1 day") ) ).alias("report_date") ) df_products = spark.createDataFrame( [("P01", "Laptop"), ("P02", "Phone"), ("P03", "Tablet")], ["product_id", "product_name"] ) # Generate all date-product combinations df_grid = df_dates.crossJoin(df_products) print(f"Grid size: {df_grid.count()} rows ({df_dates.count()} dates x {df_products.count()} products)") df_grid.show(10) # Left join actual sales onto the grid to get zeros for missing combinations df_sales = spark.read.format("delta").load("/data/sales") df_report = df_grid.join( df_sales, on=["report_date", "product_id"], how="left" ).withColumn( "daily_revenue", F.coalesce(F.col("amount"), F.lit(0)) )
What to say: The date-product grid is one of the most practically important uses of cross join in data engineering. Without it, products with no sales on a given day simply do not appear in the report, making it impossible to distinguish between zero sales and missing data. The cross join generates the complete grid, and the subsequent left join attaches actual sales where they exist, using COALESCE to show zero for days with no sales. This pattern is used in almost every sales dashboard I have built.
Q5. How would you write this in Spark SQL instead of the DataFrame API?
df_teams.createOrReplaceTempView("teams") df_fixtures_sql = spark.sql(""" SELECT a.team_name AS team_1, b.team_name AS team_2, CONCAT(a.team_name, ' vs ', b.team_name) AS fixture FROM teams a CROSS JOIN teams b WHERE a.team_id < b.team_id ORDER BY a.team_id, b.team_id """) print("SQL cross join result:") df_fixtures_sql.show(truncate=False)
What to say: The Spark SQL version is more concise for this kind of combinatorial query since the CROSS JOIN keyword and WHERE clause read naturally. Both the DataFrame API and SQL produce identical execution plans since they go through the same Catalyst optimiser. I choose between them based on team preference and readability for the specific query. Cross join combinatorics queries often read more naturally in SQL since the intent is immediately clear from the keyword.
Counting Specific Values in Array Columns
The Interviewer Says
We have a competition results table where each participant has an array of their rank positions across multiple rounds. Write PySpark code to count how many times each participant achieved rank 1, and then find the participant who achieved rank 1 the most number of times. Walk me through your approach.
Input Data
| participant_id | name | ranks |
|---|---|---|
| P01 | Arjun | [1, 2, 1, 3, 1] |
| P02 | Priya | [2, 3, 1, 4, 2] |
| P03 | Rahul | [1, 1, 1, 1, 2] |
| P04 | Sneha | [3, 2, 4, 1, 3] |
| P05 | Kiran | [1, 1, 3, 2, 1] |
How to Handle This in the Interview
Before writing anything, say: There are two approaches here. The first uses the array built-in function to filter the array and count matching elements without exploding. The second uses explode to turn array elements into rows and then applies a standard groupBy count. Both are valid but the built-in approach avoids the shuffle of explode and is more efficient.
Step 1: Know the aggregate and filter array functions
- PySpark has
aggregateandfilterfunctions specifically for working with array columns without exploding them.F.filter(array_col, lambda x: condition)returns a new array with only matching elements. ApplyingF.sizeon the filtered array gives the count.
Step 2: Know the explode approach as an alternative
- Explode the ranks array into individual rows, filter for value equals 1, then groupBy participant and count. This is more familiar but involves a shuffle.
Step 3: Find the participant with the maximum count
- After computing the rank-1 count per participant, find the maximum using a window function or a subquery comparison.
Step 4: Handle ties
- If two participants both achieved rank 1 the same maximum number of times, both should appear in the output.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import ( StructType, StructField, StringType, ArrayType, IntegerType ) from pyspark.sql.window import Window spark = SparkSession.builder \ .appName("ArrayCountDemo") \ .getOrCreate() schema = StructType([ StructField("participant_id", StringType(), True), StructField("name", StringType(), True), StructField("ranks", ArrayType(IntegerType()), True), ]) data = [ ("P01", "Arjun", [1, 2, 1, 3, 1]), ("P02", "Priya", [2, 3, 1, 4, 2]), ("P03", "Rahul", [1, 1, 1, 1, 2]), ("P04", "Sneha", [3, 2, 4, 1, 3]), ("P05", "Kiran", [1, 1, 3, 2, 1]), ] df = spark.createDataFrame(data, schema) print("Raw DataFrame:") df.show(truncate=False) # ------------------------------------------------------- # APPROACH 1: Using array filter and size (no explode, no shuffle) # ------------------------------------------------------- # Filter array to keep only rank-1 elements, then measure size df_rank1_count = df.withColumn( "rank1_array", F.filter( F.col("ranks"), lambda x: x == 1 ) ).withColumn( "rank1_count", F.size(F.col("rank1_array")) ).drop("rank1_array") print("Rank-1 count per participant (array filter approach):") df_rank1_count.show(truncate=False) # ------------------------------------------------------- # APPROACH 2: Using aggregate function # ------------------------------------------------------- # aggregate(array, initial_value, merge_function) # starts with 0 and adds 1 for each element equal to 1 df_aggregate = df.withColumn( "rank1_count_agg", F.aggregate( F.col("ranks"), F.lit(0), lambda acc, x: acc + F.when(x == 1, 1).otherwise(0) ) ) print("Rank-1 count using aggregate function:") df_aggregate.select( "participant_id", "name", "ranks", "rank1_count_agg" ).show(truncate=False) # ------------------------------------------------------- # APPROACH 3: Using explode then groupBy (involves shuffle) # ------------------------------------------------------- df_exploded = df.select( "participant_id", "name", F.explode("ranks").alias("rank_value") ) df_rank1_explode = df_exploded \ .filter(F.col("rank_value") == 1) \ .groupBy("participant_id", "name") \ .count() \ .withColumnRenamed("count", "rank1_count") print("Rank-1 count using explode approach:") df_rank1_explode.orderBy("participant_id").show() # ------------------------------------------------------- # PART 2: Find participant with most rank-1 finishes # ------------------------------------------------------- # Using window function (handles ties correctly) w_rank = Window.orderBy(F.col("rank1_count").desc()) df_with_position = df_rank1_count \ .withColumn( "position", F.rank().over(w_rank) ) df_winner = df_with_position.filter(F.col("position") == 1) \ .drop("position") print("Participant(s) with most rank-1 finishes:") df_winner.show(truncate=False) # ------------------------------------------------------- # PART 3: Extended array analytics # ------------------------------------------------------- df_array_stats = df.withColumn( "rank1_count", F.size(F.filter(F.col("ranks"), lambda x: x == 1)) ).withColumn( "total_rounds", F.size(F.col("ranks")) ).withColumn( "rank1_pct", F.round( F.size(F.filter(F.col("ranks"), lambda x: x == 1)) * 100.0 / F.size(F.col("ranks")), 1 ) ).withColumn( "best_rank", F.array_min(F.col("ranks")) ).withColumn( "worst_rank", F.array_max(F.col("ranks")) ).withColumn( "is_consistent_top3", F.array_min(F.col("ranks")) <= 3 ) print("Full array analytics:") df_array_stats.select( "participant_id", "name", "ranks", "rank1_count", "rank1_pct", "best_rank", "worst_rank", "is_consistent_top3" ).show(truncate=False) # ------------------------------------------------------- # PART 4: Count any target value in an array # ------------------------------------------------------- def count_value_in_array(df, array_col, target_value, result_col_name): return df.withColumn( result_col_name, F.size( F.filter( F.col(array_col), lambda x: x == target_value ) ) ) # Count rank-2 finishes df_rank2 = count_value_in_array(df, "ranks", 2, "rank2_count") df_rank2.select("participant_id", "name", "ranks", "rank2_count").show(truncate=False) # ------------------------------------------------------- # PART 5: Check if array contains a specific value # ------------------------------------------------------- df_contains = df.withColumn( "ever_ranked_1", F.array_contains(F.col("ranks"), 1) ).withColumn( "ever_ranked_last", F.array_contains(F.col("ranks"), 4) ) print("Array contains checks:") df_contains.select( "participant_id", "name", "ranks", "ever_ranked_1", "ever_ranked_last" ).show(truncate=False)
What to say: The array filter approach without explode is my first choice because it processes the array as a unit on each executor without any shuffle. Explode followed by groupBy requires redistributing rows across the cluster which is expensive for large DataFrames. The filter and size combination is a single pass transformation that scales linearly with the number of elements in the array. I use the explode approach only when I need to apply more complex row-level transformations to each array element that cannot be expressed with the built-in array functions.
Expected Output
| Participant | Name | Ranks | Rank1 Count |
|---|---|---|---|
| P01 | Arjun | [1, 2, 1, 3, 1] | 3 |
| P02 | Priya | [2, 3, 1, 4, 2] | 1 |
| P03 | Rahul | [1, 1, 1, 1, 2] | 4 |
| P04 | Sneha | [3, 2, 4, 1, 3] | 1 |
| P05 | Kiran | [1, 1, 3, 2, 1] | 3 |
Participant with most rank-1 finishes: P03 - Rahul with 4 rank-1 finishes
Follow-up Questions & Answers
Q1. What is the difference between F.filter for arrays and DataFrame.filter in PySpark?
DataFrame.filter or DataFrame.where filters entire rows from a DataFrame based on a column condition, reducing the number of rows in the output. F.filter applied to an ArrayType column is a higher order function that filters elements within an array, reducing the elements inside the array without changing the number of rows in the DataFrame. The row stays in the result but the array column now contains only the elements that matched the condition.
# DataFrame.filter: removes entire rows df.filter(F.col("rank1_count") > 2) # only rows where count > 2 # F.filter on array: filters elements inside the array df.withColumn( "top2_ranks", F.filter(F.col("ranks"), lambda x: x <= 2) # keep only ranks 1 and 2 in array ) # All rows remain, but the ranks array now only contains elements <= 2
Q2. What other higher-order functions does PySpark support for array columns?
# transform: apply a function to each element and return new array df.withColumn( "doubled_ranks", F.transform(F.col("ranks"), lambda x: x * 2) ) # filter: keep elements matching a condition df.withColumn( "top_ranks", F.filter(F.col("ranks"), lambda x: x <= 2) ) # aggregate: reduce array to a single value with initial state df.withColumn( "sum_of_ranks", F.aggregate(F.col("ranks"), F.lit(0), lambda acc, x: acc + x) ) # exists: check if any element satisfies a condition (returns boolean) df.withColumn( "has_rank1", F.exists(F.col("ranks"), lambda x: x == 1) ) # forall: check if all elements satisfy a condition df.withColumn( "all_top3", F.forall(F.col("ranks"), lambda x: x <= 3) ) # array_sort: sort array elements df.withColumn("sorted_ranks", F.array_sort(F.col("ranks"))) # array_distinct: remove duplicates from array df.withColumn("unique_ranks", F.array_distinct(F.col("ranks"))) # array_except: elements in first array not in second df.withColumn( "non_podium_ranks", F.array_except(F.col("ranks"), F.array(F.lit(1), F.lit(2), F.lit(3))) )
What to say: PySpark's higher-order array functions, transform, filter, aggregate, exists, and forall, were introduced in Spark 2.4 and allow complex array processing without exploding and regrouping, which eliminates shuffles and dramatically improves performance on array-heavy datasets. Knowing these functions separates candidates who understand PySpark's full capability from those who only know the basic explode-group pattern.
Q3. How would you calculate the average rank across all rounds for each participant using array functions?
# Using aggregate to sum then dividing by size df_avg_rank = df.withColumn( "sum_of_ranks", F.aggregate(F.col("ranks"), F.lit(0), lambda acc, x: acc + x) ).withColumn( "total_rounds", F.size(F.col("ranks")) ).withColumn( "avg_rank", F.round( F.col("sum_of_ranks") * 1.0 / F.col("total_rounds"), 2 ) ).drop("sum_of_ranks", "total_rounds") print("Average rank per participant:") df_avg_rank.select( "participant_id", "name", "ranks", "avg_rank" ).orderBy("avg_rank").show(truncate=False)
What to say: I use aggregate to sum all elements in one pass, then divide by the array size. This avoids exploding the array and is computed entirely within each partition. An alternative is to use explode followed by groupBy with AVG, but that requires a shuffle for the groupBy. For a computation as simple as an average, the higher-order function approach is always preferable on large datasets.
Q4. How would you find participants whose ranks are strictly improving, meaning each rank in the array is less than or equal to the previous one?
# Check if array is non-increasing (improving = lower rank number = better) # Using a sequence window: element i <= element i-1 for all i df_improving = df.withColumn( "is_improving", F.forall( F.sequence(F.lit(1), F.size(F.col("ranks")) - F.lit(1)), lambda i: F.col("ranks")[i] <= F.col("ranks")[i - 1] ) ) print("Participants with improving ranks:") df_improving.filter(F.col("is_improving") == True) \ .select("participant_id", "name", "ranks") \ .show(truncate=False)
What to say: This uses forall with a sequence of indices to check each consecutive pair in the array. The sequence function generates a range of integers from 1 to the array length minus 1, and forall verifies that every index satisfies the condition that the element at that position is less than or equal to the element at the previous position. This is a more advanced use of higher-order functions that demonstrates understanding of how to work with index-based array operations in PySpark.
Q5. How would you union two array columns into a single sorted array for each participant?
data_two_arrays = [ ("P01", "Arjun", [1, 2, 1], [3, 1, 2]), ("P02", "Priya", [2, 3], [1, 4, 2]), ] df_two = spark.createDataFrame( data_two_arrays, ["participant_id", "name", "ranks_round1", "ranks_round2"] ) df_combined = df_two.withColumn( "all_ranks", F.array_sort( F.concat(F.col("ranks_round1"), F.col("ranks_round2")) ) ).withColumn( "distinct_ranks", F.array_distinct( F.array_sort( F.concat(F.col("ranks_round1"), F.col("ranks_round2")) ) ) ).withColumn( "total_rank1_combined", F.size( F.filter( F.concat(F.col("ranks_round1"), F.col("ranks_round2")), lambda x: x == 1 ) ) ) df_combined.show(truncate=False)
What to say: F.concat on array columns concatenates them into a single larger array, array_sort sorts the combined array, and array_distinct removes duplicates. These can be chained together because each array function returns an array column that the next function can operate on. This composable nature of array functions is what makes PySpark so expressive for complex array operations without ever needing to leave the columnar execution model.
Collecting Ordered Sequences and Horizontal Aggregation
The Interviewer Says
Two related questions. First, we have a user activity table showing each page a user visited in order. I want to collect the full page navigation journey for each user as an ordered list in a single row. Second, we have a student marks table with subject columns and I want to add a total and average marks column by summing across the subject columns horizontally. Walk me through both.
Input Data
user_activity
| user_id | page | visit_time |
|---|---|---|
| U01 | home | 2025-03-01 09:00:00 |
| U01 | products | 2025-03-01 09:05:00 |
| U01 | cart | 2025-03-01 09:10:00 |
| U01 | checkout | 2025-03-01 09:15:00 |
| U02 | home | 2025-03-01 10:00:00 |
| U02 | products | 2025-03-01 10:08:00 |
| U02 | home | 2025-03-01 10:20:00 |
| U03 | home | 2025-03-01 11:00:00 |
student_marks
| student_id | name | maths | science | english | hindi |
|---|---|---|---|---|---|
| S01 | Arjun | 85 | 90 | 78 | 82 |
| S02 | Priya | 92 | 88 | 95 | 79 |
| S03 | Rahul | 70 | None | 68 | 85 |
| S04 | Sneha | 88 | 92 | 84 | 90 |
How to Handle This in the Interview
Before writing anything, say: The page navigation question tests whether you know the difference between collect_list and collect_set, and critically, how to preserve the chronological order of the collected list. The horizontal aggregation question tests whether you know that SUM in SQL and PySpark aggregates vertically across rows in a group, so summing columns horizontally requires explicit column arithmetic with NULL handling.
Step 1: collect_list requires an explicit sort before collecting
- collect_list does not guarantee any ordering of the collected elements. The order depends on how the data is physically organised in partitions. To guarantee chronological ordering, sort within the window before collecting using a struct trick, or sort before groupBy.
Step 2: The struct sort trick for ordered collect_list
- The reliable approach is to create a struct combining the sort key and the value, collect the struct array using collect_list, sort the struct array using array_sort, then extract only the value from each struct element using transform.
Step 3: Horizontal sum is plain column arithmetic
- Column-wise SUM requires adding each column individually with the plus operator. COALESCE each column before adding to handle NULLs.
Step 4: Know collect_list vs collect_set
- collect_list preserves duplicates and order. collect_set removes duplicates but does not guarantee order. For a navigation journey where a user visits the same page twice, collect_list is correct because both visits are meaningful events.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.window import Window from pyspark.sql.types import ( StructType, StructField, StringType, IntegerType, TimestampType ) spark = SparkSession.builder \ .appName("CollectSequenceDemo") \ .getOrCreate() # ------------------------------------------------------- # PART 1: User Page Navigation Journey # ------------------------------------------------------- activity_data = [ ("U01", "home", "2025-03-01 09:00:00"), ("U01", "products", "2025-03-01 09:05:00"), ("U01", "cart", "2025-03-01 09:10:00"), ("U01", "checkout", "2025-03-01 09:15:00"), ("U02", "home", "2025-03-01 10:00:00"), ("U02", "products", "2025-03-01 10:08:00"), ("U02", "home", "2025-03-01 10:20:00"), ("U03", "home", "2025-03-01 11:00:00"), ] df_activity = spark.createDataFrame( activity_data, ["user_id", "page", "visit_time"] ).withColumn("visit_time", F.to_timestamp(F.col("visit_time"))) print("Raw activity data:") df_activity.show(truncate=False) # ------------------------------------------------------- # APPROACH 1: Sort before groupBy (simple but may not be reliable) # ------------------------------------------------------- # Sorting before groupBy does not guarantee order in collect_list # because Spark may reorder rows during distributed processing df_naive = df_activity \ .orderBy("user_id", "visit_time") \ .groupBy("user_id") \ .agg(F.collect_list("page").alias("pages_naive")) print("Naive approach (order NOT guaranteed):") df_naive.show(truncate=False) # ------------------------------------------------------- # APPROACH 2: Struct sort trick (RELIABLE - guaranteed order) # ------------------------------------------------------- # Step 1: Create a struct combining sort key and value df_with_struct = df_activity.withColumn( "time_page_struct", F.struct( F.col("visit_time").alias("time"), F.col("page").alias("page") ) ) # Step 2: collect_list of structs (order not guaranteed yet) df_collected = df_with_struct.groupBy("user_id") \ .agg( F.collect_list("time_page_struct").alias("journey_structs") ) # Step 3: Sort the collected struct array by the time field df_sorted_structs = df_collected.withColumn( "journey_sorted", F.array_sort( F.col("journey_structs"), lambda x, y: F.when(x["time"] < y["time"], -1) .when(x["time"] > y["time"], 1) .otherwise(0) ) ) # Step 4: Extract only the page names from sorted structs df_journey = df_sorted_structs.withColumn( "page_journey", F.transform( F.col("journey_sorted"), lambda x: x["page"] ) ).select("user_id", "page_journey") print("Page journey with guaranteed chronological order:") df_journey.show(truncate=False) # ------------------------------------------------------- # APPROACH 3: Using window function for ordered collection # ------------------------------------------------------- w_user = Window.partitionBy("user_id").orderBy("visit_time") df_window_journey = df_activity \ .withColumn( "ordered_pages", F.collect_list("page").over(w_user) ) \ .groupBy("user_id") \ .agg(F.max("ordered_pages").alias("page_journey")) print("Journey using window function approach:") df_window_journey.show(truncate=False) # ------------------------------------------------------- # PART 2: Journey analytics # ------------------------------------------------------- df_journey_stats = df_journey.withColumn( "journey_length", F.size(F.col("page_journey")) ).withColumn( "first_page", F.col("page_journey").getItem(0) ).withColumn( "last_page", F.element_at(F.col("page_journey"), -1) ).withColumn( "reached_checkout", F.array_contains(F.col("page_journey"), "checkout") ) print("Journey analytics:") df_journey_stats.show(truncate=False) # ------------------------------------------------------- # PART 3: Horizontal Column Sum (Student Marks) # ------------------------------------------------------- marks_data = [ ("S01", "Arjun", 85, 90, 78, 82), ("S02", "Priya", 92, 88, 95, 79), ("S03", "Rahul", 70, None, 68, 85), ("S04", "Sneha", 88, 92, 84, 90), ] df_marks = spark.createDataFrame( marks_data, ["student_id", "name", "maths", "science", "english", "hindi"] ) print("Raw marks:") df_marks.show() subject_cols = ["maths", "science", "english", "hindi"] # Method 1: Explicit column addition with null handling df_total = df_marks.withColumn( "total_marks", sum([F.coalesce(F.col(c), F.lit(0)) for c in subject_cols]) ).withColumn( "subjects_attempted", sum([F.when(F.col(c).isNotNull(), 1).otherwise(0) for c in subject_cols]) ).withColumn( "avg_marks", F.round( F.col("total_marks") * 1.0 / F.col("subjects_attempted"), 2 ) ) print("After adding total and average:") df_total.show() # Method 2: Convert to array and use aggregate (elegant for many columns) df_total_array = df_marks.withColumn( "marks_array", F.array(*[F.coalesce(F.col(c), F.lit(0)) for c in subject_cols]) ).withColumn( "total_marks", F.aggregate( F.col("marks_array"), F.lit(0), lambda acc, x: acc + x ) ).withColumn( "avg_marks", F.round(F.col("total_marks") * 1.0 / len(subject_cols), 2) ).drop("marks_array") print("Using array aggregate approach:") df_total_array.show() # Method 3: Grade classification on top of total df_graded = df_total.withColumn( "grade", F.when(F.col("total_marks") >= 340, "Distinction") .when(F.col("total_marks") >= 280, "First Class") .when(F.col("total_marks") >= 200, "Pass") .otherwise("Fail") ).withColumn( "rank_in_class", F.rank().over( Window.orderBy(F.col("total_marks").desc()) ) ) print("Final graded result:") df_graded.select( "student_id", "name", "total_marks", "avg_marks", "grade", "rank_in_class" ).show()
What to say: The most important thing to get right with collect_list is the ordering guarantee. Simply calling orderBy before groupBy is not reliable in distributed Spark because the sort order may not be preserved across partition boundaries when the groupBy causes a shuffle. The reliable approach is the struct sort trick, combining the sort key and value into a struct, collecting the structs, sorting the collected array using array_sort, then extracting the values. This guarantees chronological order regardless of how partitions are arranged. For the horizontal sum, Python's built-in sum function on a list of Column objects produces the correct SQL-like addition expression.
Expected Output: Page Journey
| User ID | Page Journey |
|---|---|
| U01 | [home, products, cart, checkout] |
| U02 | [home, products, home] |
| U03 | [home] |
Expected Output: Student Marks
| Student ID | Name | Maths | Science | English | Hindi | Total Marks | Avg Marks |
|---|---|---|---|---|---|---|---|
| S01 | Arjun | 85 | 90 | 78 | 82 | 335 | 83.75 |
| S02 | Priya | 92 | 88 | 95 | 79 | 354 | 88.50 |
| S03 | Rahul | 70 | null | 68 | 85 | 223 | 74.33 |
| S04 | Sneha | 88 | 92 | 84 | 90 | 354 | 88.50 |
Follow-up Questions & Answers
Q1. Why is collect_list order not guaranteed and what is the most reliable way to ensure order?
collect_list gathers elements from different partitions across the cluster into a single array. Since Spark processes partitions in parallel with no guaranteed inter-partition ordering, the order in which elements arrive at the groupBy reducer depends on partition assignment, task scheduling, and network timing. A sort before groupBy is generally maintained within a partition but the shuffle in groupBy can mix elements from different partitions in any order. The most reliable approach is the struct sort trick because it moves the ordering requirement inside the collected array itself. array_sort is applied after collection on the driver side of the aggregation, making it immune to partition ordering issues since it sorts the already-collected array deterministically.
Q2. What is the difference between collect_list and collect_set and when does it matter for navigation journeys?
collect_list keeps all elements including duplicates in the order they appear. collect_set keeps only unique elements and does not guarantee any order. For a navigation journey, a user who visits the home page three times in one session has three meaningful home page events that should all appear in the journey. Using collect_list correctly records this as home, products, home, products, checkout showing the true path. Using collect_set would show home, products, checkout which is a different journey that loses the information about the revisit pattern.
# collect_list: keeps all visits including duplicates df_activity.groupBy("user_id") \ .agg(F.collect_list("page").alias("full_journey")) # collect_set: only unique pages, order not guaranteed df_activity.groupBy("user_id") \ .agg(F.collect_set("page").alias("unique_pages")) # U02's result: [home, products] not [home, products, home]
What to say: For any sequence analysis like user journeys, funnel analysis, or event streams, collect_list is almost always the right choice because the sequence and repetition of events carry analytical meaning. collect_set is appropriate only when you want to know which distinct pages a user visited across an entire session, without caring about order or frequency.
Q3. How would you find users who followed a specific sequence of pages, for example users who went through home then products then checkout?
df_with_journey = df_journey.withColumn( "journey_string", F.concat_ws(" -> ", F.col("page_journey")) ) # Find users who visited home then products then checkout in sequence df_funnel = df_with_journey.filter( F.col("journey_string").contains("home -> products") & F.col("journey_string").contains("checkout") ) print("Users who went through the funnel:") df_funnel.show(truncate=False) # More precise: check exact subsequence using array_position df_funnel_precise = df_journey.withColumn( "home_pos", F.array_position(F.col("page_journey"), "home") ).withColumn( "products_pos", F.array_position(F.col("page_journey"), "products") ).withColumn( "checkout_pos", F.array_position(F.col("page_journey"), "checkout") ).filter( (F.col("home_pos") > 0) & (F.col("products_pos") > F.col("home_pos")) & (F.col("checkout_pos") > F.col("products_pos")) ) print("Users who precisely followed home -> products -> checkout:") df_funnel_precise.select("user_id", "page_journey").show(truncate=False)
What to say: The array_position function returns the 1-based index of the first occurrence of a value in an array, or 0 if not found. By comparing positions we can verify that the pages appeared in the correct relative order within the journey array. This is much more precise than string matching on the concatenated journey since string matching could produce false positives for journeys where the pages appear out of order.
Q4. When adding many columns together horizontally, can you make the code dynamic so it works regardless of how many subject columns there are?
# Dynamically discover subject columns (all columns except identifier columns) non_subject_cols = ["student_id", "name"] subject_cols_dynamic = [c for c in df_marks.columns if c not in non_subject_cols] print(f"Subject columns discovered dynamically: {subject_cols_dynamic}") # Sum all discovered subject columns dynamically df_dynamic_total = df_marks.withColumn( "total_marks", sum([F.coalesce(F.col(c), F.lit(0)) for c in subject_cols_dynamic]) ).withColumn( "subject_count", F.lit(len(subject_cols_dynamic)) ).withColumn( "avg_marks", F.round(F.col("total_marks") * 1.0 / F.lit(len(subject_cols_dynamic)), 2) ) df_dynamic_total.show() # Using array approach for truly dynamic column handling df_as_array = df_marks.withColumn( "all_marks", F.array(*[F.col(c).cast("double") for c in subject_cols_dynamic]) ) df_array_total = df_as_array.withColumn( "total_marks", F.aggregate( F.filter(F.col("all_marks"), lambda x: x.isNotNull()), F.lit(0.0), lambda acc, x: acc + x ) ) df_array_total.show()
What to say: Building the column list dynamically from the DataFrame schema rather than hardcoding the column names makes the code resilient to schema changes. If a new subject is added to the table next semester, the pipeline automatically includes it in the total without any code change. The list comprehension that generates the sum expression is a clean Pythonic way to handle arbitrary numbers of columns, and using Python's built-in sum on a list of PySpark Column objects produces the correct column-wise addition expression.
Q5. How would you find the subject in which each student scored highest using the horizontal data layout?
# Unpivot to find best subject per student subject_cols = ["maths", "science", "english", "hindi"] df_unpivoted = df_marks.select( "student_id", "name", F.explode( F.array(*[ F.struct( F.lit(c).alias("subject"), F.col(c).alias("score") ) for c in subject_cols ]) ).alias("subject_score") ).select( "student_id", "name", F.col("subject_score.subject").alias("subject"), F.col("subject_score.score").alias("score") ).filter(F.col("score").isNotNull()) # Rank within each student by score w_student = Window.partitionBy("student_id").orderBy(F.col("score").desc()) df_best_subject = df_unpivoted \ .withColumn("rank", F.rank().over(w_student)) \ .filter(F.col("rank") == 1) \ .drop("rank") print("Best subject per student:") df_best_subject.show()
What to say: Finding the best column per row requires unpivoting the wide format into a long format first, since window functions and ranking operate on rows not columns. I create an array of structs, each struct containing the subject name and score, then explode that array into one row per subject per student. After filtering out NULLs for absent subjects, I rank within each student by score and filter for rank one. This pattern of array-of-structs then explode is cleaner than separate UNION ALL statements for each subject when the number of subjects is large or dynamic.
Building Nested Struct from Flat DataFrame
The Interviewer Says
This is the reverse of reading nested JSON. We have a flat DataFrame with customer and address fields all at the same level. I want you to restructure it into a properly nested format where the address fields are grouped into an address struct, and then convert the entire row to a JSON string for writing to Kafka or an API. Walk me through your approach.
Input Data — Flat Format
| customer_id | name | city | state | pincode | dept | salary |
|---|---|---|---|---|---|---|
| C01 | Karan | Mumbai | MH | 400001 | Finance | 90000 |
| C02 | Asha | Delhi | DL | 110001 | IT | 75000 |
| C03 | Ravi | Bangalore | KA | 560001 | Finance | 82000 |
| C04 | Meena | Chennai | TN | 600001 | HR | 68000 |
How to Handle This in the Interview
Before writing anything, say: Building a nested struct from flat columns is the inverse of the flatten operation we saw earlier. The key function is F.struct which takes multiple column expressions and combines them into a single StructType column. This is commonly needed when writing data to APIs, Kafka topics, or any system that expects a specific nested JSON schema.
Step 1: Identify which columns belong in the nested struct
- Say: I need to decide the target schema. In this case city, state, and pincode logically belong in an address struct, while name and dept belong at the top level.
Step 2: Use F.struct to create the nested column
- F.struct takes column references and optional aliases and produces a single StructType column containing all the specified fields.
Step 3: Use to_json for serialisation
- Once the nested struct is built, F.to_json converts it to a JSON string suitable for Kafka, REST APIs, or any text-based output format.
Step 4: Verify the schema with printSchema
- Always verify the resulting schema with printSchema to confirm the nesting is correct before writing to any downstream system.
Solution
from pyspark.sql import SparkSession from pyspark.sql import functions as F from pyspark.sql.types import ( StructType, StructField, StringType, IntegerType, DoubleType ) spark = SparkSession.builder \ .appName("BuildNestedStructDemo") \ .getOrCreate() data = [ ("C01", "Karan", "Mumbai", "MH", "400001", "Finance", 90000), ("C02", "Asha", "Delhi", "DL", "110001", "IT", 75000), ("C03", "Ravi", "Bangalore", "KA", "560001", "Finance", 82000), ("C04", "Meena", "Chennai", "TN", "600001", "HR", 68000), ] df_flat = spark.createDataFrame( data, ["customer_id", "name", "city", "state", "pincode", "dept", "salary"] ) print("Flat DataFrame:") df_flat.show() df_flat.printSchema() # ------------------------------------------------------- # PART 1: Build nested address struct # ------------------------------------------------------- df_nested = df_flat.withColumn( "address", F.struct( F.col("city").alias("city"), F.col("state").alias("state"), F.col("pincode").alias("pincode") ) ).drop("city", "state", "pincode") print("After nesting address fields:") df_nested.show(truncate=False) df_nested.printSchema() # Accessing nested fields after building the struct df_nested.select( "customer_id", "name", F.col("address.city").alias("city"), F.col("address.state").alias("state") ).show() # ------------------------------------------------------- # PART 2: Multi-level nesting # ------------------------------------------------------- df_multi_nested = df_flat.select( F.col("customer_id"), F.struct( F.col("name").alias("full_name"), F.struct( F.col("city").alias("city"), F.col("state").alias("state"), F.col("pincode").alias("pincode") ).alias("address"), F.struct( F.col("dept").alias("department"), F.col("salary").alias("salary") ).alias("employment") ).alias("customer_details") ) print("Multi-level nested schema:") df_multi_nested.printSchema() df_multi_nested.show(truncate=False) # ------------------------------------------------------- # PART 3: Build struct with array field # ------------------------------------------------------- # Add mock order history as an array for richer nesting example from pyspark.sql.types import ArrayType df_with_orders = df_nested.withColumn( "recent_orders", F.array( F.struct( F.lit("O001").alias("order_id"), F.lit(5000).alias("amount"), F.lit("delivered").alias("status") ), F.struct( F.lit("O002").alias("order_id"), F.lit(8000).alias("amount"), F.lit("pending").alias("status") ) ) ) print("With nested array of order structs:") df_with_orders.printSchema() df_with_orders.show(truncate=False) # ------------------------------------------------------- # PART 4: Convert to JSON string for Kafka / API # ------------------------------------------------------- df_as_json = df_multi_nested.withColumn( "payload", F.to_json(F.col("customer_details")) ) print("As JSON string (for Kafka):") df_as_json.select("customer_id", "payload").show(truncate=False) # Full row as JSON df_full_json = df_flat.withColumn( "full_payload", F.to_json( F.struct( F.col("customer_id"), F.col("name"), F.struct( F.col("city"), F.col("state"), F.col("pincode") ).alias("address"), F.col("dept"), F.col("salary") ) ) ) print("Full row as JSON payload:") df_full_json.select("full_payload").show(truncate=False) # ------------------------------------------------------- # PART 5: Write to Kafka using nested JSON # ------------------------------------------------------- df_kafka_ready = df_multi_nested.select( F.col("customer_id").cast(StringType()).alias("key"), F.to_json(F.col("customer_details")).alias("value") ) print("Kafka-ready DataFrame:") df_kafka_ready.show(truncate=False) # Write to Kafka # df_kafka_ready.write \ # .format("kafka") \ # .option("kafka.bootstrap.servers", "broker:9092") \ # .option("topic", "customer_updates") \ # .save() # ------------------------------------------------------- # PART 6: Reusable nesting function for any column groups # ------------------------------------------------------- def nest_columns(df, nested_col_name, columns_to_nest, drop_originals=True): df_with_nested = df.withColumn( nested_col_name, F.struct(*[F.col(c).alias(c) for c in columns_to_nest]) ) if drop_originals: df_with_nested = df_with_nested.drop(*columns_to_nest) return df_with_nested df_auto_nested = nest_columns( df_flat, nested_col_name="address", columns_to_nest=["city", "state", "pincode"] ) df_auto_nested = nest_columns( df_auto_nested, nested_col_name="employment", columns_to_nest=["dept", "salary"] ) print("Auto-nested using reusable function:") df_auto_nested.printSchema() df_auto_nested.show(truncate=False) # ------------------------------------------------------- # PART 7: Verify round trip - nest then flatten # ------------------------------------------------------- df_flattened_back = df_nested.select( "customer_id", "name", "dept", "salary", F.col("address.city").alias("city"), F.col("address.state").alias("state"), F.col("address.pincode").alias("pincode") ) print("Round trip: flattened back to original schema:") df_flattened_back.show()
What to say: F.struct is the core function for building nested schemas. It takes column references and produces a StructType column containing all of them as named fields. The resulting column behaves exactly like a nested JSON object, accessible via dot notation and serialisable to JSON using to_json. The reusable nesting function is something I build in every project that handles complex API or Kafka payloads, because the business schema changes frequently and having a configuration-driven nesting function means schema changes only require updating the column group configuration rather than rewriting transformation code.
Expected Output
After nesting address fields: +-----------+-----+-------+------+---------------------------+ |customer_id|name |dept |salary|address | +-----------+-----+-------+------+---------------------------+ |C01 |Karan|Finance|90000 |{Mumbai, MH, 400001} | |C02 |Asha |IT |75000 |{Delhi, DL, 110001} | |C03 |Ravi |Finance|82000 |{Bangalore, KA, 560001} | |C04 |Meena|HR |68000 |{Chennai, TN, 600001} | +-----------+-----+-------+------+---------------------------+ Schema: root |-- customer_id: string |-- name: string |-- dept: string |-- salary: long |-- address: struct | |-- city: string | |-- state: string | |-- pincode: string Kafka payload: {"full_name":"Karan","address":{"city":"Mumbai","state":"MH","pincode":"400001"},"employment":{"department":"Finance","salary":90000}}
Follow-up Questions & Answers
Q1. What is the difference between F.struct and F.create_map in PySpark?
F.struct creates a StructType column where each field has a fixed name and can have a different type. The fields are accessed by name using dot notation. F.create_map creates a MapType column which is a key-value dictionary where all keys must be the same type and all values must be the same type. struct is appropriate for records with known, fixed field names like an address. create_map is appropriate for dynamic key-value collections where the keys vary per row.
# struct: fixed field names, different types allowed F.struct( F.col("city").alias("city"), # string F.col("pincode").alias("pincode"), # string F.col("lat").alias("lat") # double ) # Access: col("address.city") # create_map: dynamic keys, all same type F.create_map( F.lit("city"), F.col("city"), F.lit("state"), F.col("state"), F.lit("pincode"), F.col("pincode") ) # Access: col("address")["city"]
What to say: I use struct when the schema is known and fixed since it gives type safety and clean dot notation access. I use create_map when the set of keys is dynamic or when I am building a metadata dictionary where the keys themselves are data values. For Kafka payloads and API responses, struct is almost always preferred since the JSON schema is usually well defined by the consuming system.
Q2. How would you add a computed field inside the struct, for example a full address string alongside the individual fields?
df_nested_computed = df_flat.withColumn( "address", F.struct( F.col("city").alias("city"), F.col("state").alias("state"), F.col("pincode").alias("pincode"), F.concat_ws(", ", F.col("city"), F.col("state"), F.col("pincode") ).alias("full_address") ) ) print("Struct with computed field:") df_nested_computed.select( "customer_id", F.col("address.city"), F.col("address.full_address") ).show(truncate=False)
What to say: Any column expression can appear inside F.struct, including computed expressions, literals, and function results. The struct simply packages whatever expressions you provide into a single named container. This means I can add derived fields like full address strings, formatted display values, or flags directly inside the struct alongside the raw field values, which is cleaner than adding them as separate top-level columns.
Q3. How would you update a single field inside an existing nested struct without rebuilding the entire struct?
# To update address.city, you cannot do col("address.city") = new_value # You must rebuild the struct with the new value for that field df_updated_nested = df_nested.withColumn( "address", F.struct( F.upper(F.col("address.city")).alias("city"), # uppercase city F.col("address.state").alias("state"), # unchanged F.col("address.pincode").alias("pincode") # unchanged ) ) print("After updating city inside address struct:") df_updated_nested.select("customer_id", "address").show(truncate=False)
What to say: There is no in-place update for a field inside a struct in PySpark. The entire struct must be rebuilt using F.struct with the updated value for the specific field and the original values for all other fields. This is the same immutability constraint that applies to all PySpark transformations, DataFrames are never modified in place, a new DataFrame is always returned. For a struct with many fields this means listing all of them in the rebuild, which is why the reusable nesting function that reads the field list from the schema rather than hardcoding it becomes valuable for wide structs.
Q4. How would you handle a situation where different rows need different schemas inside the struct?
PySpark enforces a fixed schema for every row in a DataFrame, meaning all rows in a struct column must have the same fields with the same types. You cannot have row-specific schemas in a StructType column. If different records genuinely have different structures, the options are to use a MapType which accepts dynamic keys, to use a JSON string column where each row can contain any valid JSON, or to use a union schema that includes all possible fields with NULL for fields that do not apply to a specific row.
# Option 1: Use JSON string for truly variable structure df_variable = df_flat.withColumn( "flexible_payload", F.when( F.col("dept") == "Finance", F.to_json(F.struct( F.col("customer_id"), F.col("salary"), F.lit("annual_bonus").alias("bonus_type") )) ).otherwise( F.to_json(F.struct( F.col("customer_id"), F.col("dept") )) ) ) # Option 2: Union schema with NULL for missing fields df_union_schema = df_flat.withColumn( "details", F.struct( F.col("dept").alias("department"), F.when(F.col("dept") == "Finance", F.col("salary")).otherwise(F.lit(None)).alias("salary"), F.when(F.col("dept") == "IT", F.lit("Tech")).otherwise(F.lit(None)).alias("team_type") ) )
What to say: The JSON string approach gives maximum flexibility since any valid JSON can be stored in any row without schema constraints. The trade-off is that downstream consumers cannot use native struct field access and must parse the JSON string themselves. The union schema approach is cleaner for systems that support native struct types but requires all fields to be declared upfront even if most rows will have NULL for most fields. I choose between them based on how variable the structure truly is and what the consuming system expects.
Q5. How would you validate that the nested struct you built matches a target schema before writing to production?
from pyspark.sql.types import ( StructType, StructField, StringType, LongType ) target_schema = StructType([ StructField("customer_id", StringType(), True), StructField("name", StringType(), True), StructField("dept", StringType(), True), StructField("salary", LongType(), True), StructField("address", StructType([ StructField("city", StringType(), True), StructField("state", StringType(), True), StructField("pincode", StringType(), True), ]), True) ]) def validate_schema(df, expected_schema, strict=False): actual_schema = df.schema def compare_schemas(actual, expected, path=""): for expected_field in expected.fields: field_path = f"{path}.{expected_field.name}" if path else expected_field.name actual_field = next( (f for f in actual.fields if f.name == expected_field.name), None ) if actual_field is None: print(f"MISSING FIELD: {field_path}") return False if str(actual_field.dataType) != str(expected_field.dataType): print(f"TYPE MISMATCH: {field_path} " f"expected {expected_field.dataType} " f"got {actual_field.dataType}") if strict: return False if isinstance(expected_field.dataType, StructType): compare_schemas( actual_field.dataType, expected_field.dataType, field_path ) if strict: for actual_field in actual.fields: if not any(f.name == actual_field.name for f in expected.fields): print(f"UNEXPECTED FIELD: {path}.{actual_field.name}" if path else actual_field.name) return True is_valid = compare_schemas(actual_schema, expected_schema) if is_valid: print("Schema validation PASSED") else: raise ValueError("Schema validation FAILED") validate_schema(df_nested, target_schema)
What to say: Schema validation before writing to production is a practice I apply to every pipeline that produces data consumed by other systems. An unexpected schema change, like a field name typo or a type that defaulted to LongType instead of IntegerType, can cause the consuming system to fail with a cryptic error long after the pipeline finished. The validation function I wrote recursively compares nested struct schemas field by field and raises a ValueError if any discrepancy is found, failing the pipeline at the source rather than corrupting the target.