DataFrames and Spark SQL
Direct answer: A DataFrame is a distributed table with a schema. Its unresolved logical plan is analyzed and optimized by Catalyst, then executed as a physical plan. Prefer built-in expressions over Python UDFs because Spark can understand, push down, prune, and code-generate built-ins.
Memory map: read with an explicit schema -> select/filter early -> transform with expressions -> aggregate/join/window -> inspect the plan -> write appropriately sized columnar files.
MASTER MEMORY MAP β Day 2
π§ SPARKSESSION = "Entry point for EVERYTHING"
RDD vs DataFrame vs Dataset = "Low -> High -> Typed"
RDD: Low-level, no schema, no optimizer, Python/Java/Scala
DataFrame: High-level, schema, Catalyst/Tungsten optimized, SQL-like
Dataset: High-level, TYPED (compile-time safety), JVM only (Scala/Java)
PySpark: Only RDD + DataFrame (no Dataset in Python!)
SPARKSESSION"Entry point for EVERYTHING"
spark.read.* -> read data
spark.sql("...") -> run SQL
spark.sparkContext -> access RDD API
spark.createDataFrame() -> create DF from RDD or list
spark.catalog.* -> manage databases, tables, functions
READING FILES"Format.Option.Schema.Load"
format: csv, json, parquet, orc, delta, jdbc, avro
option: header, inferSchema, delimiter, mode
schema: StructType([StructField(...)]) β ALWAYS explicit in production!
load: .load("/path/") or .(path) shortcut
READ MULTIPLE FILES"Glob/List/Directory"
Glob: spark.read.csv("/data/2024/*") <- all files matching pattern
List: spark.read.csv(["/f1", "/f2"]) <- specific files
Dir: spark.read.parquet("/data/") <- all parquet in dir
TRANSFORMATIONS"SWGJW-NAU"
Sselect() / withColumn()
Wwhere() / filter()
GgroupBy() + agg()
Jjoin()
WwithColumnRenamed() / drop()
Nna.fill() / na.drop()
Aalias() / cast()
Uunion() / unionByName()
WINDOW FUNCTIONS"PARTITION + ORDER + FRAME"
Window.partitionBy("col").orderBy("col")
rowsBetween(Window.unboundedPreceding, Window.currentRow)
COLUMN OPERATIONS"col / lit / when / cast"
col("name") -> reference a column
lit(100) -> create a constant column
when().otherwise() -> conditional logic (CASE WHEN)
cast("double") -> type conversion
NULL HANDLING"isNull / fillna / dropna / coalesce"
isNull() / isNotNull() -> filter nulls
na.fill() / na.drop() -> handle null rows
coalesce(a, b, c) -> first non-null value
STRING FUNCTIONS"concat / trim / regexp / split"
concat, concat_ws, substring, trim, ltrim, rtrim
regexp_replace, regexp_extract, split, lower, upper
DATE FUNCTIONS"current / diff / add / format"
current_date(), current_timestamp()
datediff(), months_between(), date_add(), date_sub()
date_format(), to_date(), to_timestamp()
Recall Stack: data model and schema
Answer First: DataFrames add named, typed columns to distributed data, allowing Spark to validate expressions and optimize a logical plan. Use explicit production schemas to avoid inference scans and silent type drift.
Memory Map: source -> schema -> unresolved plan -> analyzed plan -> optimized plan -> physical plan.
Catalyst query planning
Answer First: Catalyst turns DataFrame and SQL expressions into an analyzed logical plan, applies logical optimization rules, compares physical strategies, and selects an executable physical plan. Inspect the result with explain; do not infer optimization from source code alone.
Memory Map: parse -> analyze -> optimize -> plan -> execute.
Catalyst planning phases
β
1. ANALYSIS
Resolve columns, functions, tables, and types against the catalog.
β
2. LOGICAL OPTIMIZATION
Apply rules such as predicate pushdown, column pruning,
constant folding, and eligible join reordering.
β
3. PHYSICAL PLANNING
Generate candidate scan/join/exchange strategies; use available
statistics and costs to choose a physical plan.
β
4. EXECUTION PREPARATION
Apply physical rules and whole-stage code generation where eligible.
Predicate pushdown and column pruning
result = (
spark.read.parquet("data/")
.filter(col("year") == 2024)
.select("name", "amount")
)
blocked = df.filter(my_udf(col("year")) == 2024)
Inspect the plan
df.explain()
df.explain(True)
df.explain("formatted")
df.explain("cost")
df.explain("codegen")
Answer First: Prefer DataFrames for schema-aware ETL and SQL optimization; use RDDs for genuinely low-level record control, and remember that typed Datasets are a Scala/Java API rather than a PySpark API.
Memory Map: RDD (objects) -> DataFrame (named rows) -> Dataset (typed JVM records).
SECTION 1: RDD vs DataFrame vs Dataset
Definition: A DataFrame is a distributed collection of data organized into named columns, equivalent to a table in a relational database, optimized by Spark's Catalyst optimizer and Tungsten execution engine.
Simple Explanation: Think of an RDD as a raw list of items with no labels. A DataFrame is like an Excel spreadsheet with column headers. A Dataset adds strict data typing (only in Scala/Java).
Real-world Analogy: RDD is a box of unsorted papers. DataFrame is papers organized in a filing cabinet with labeled folders. Dataset is a filing cabinet where each folder only accepts a specific document type.
| Feature | RDD | DataFrame | Dataset (Scala/Java) |
|---|
| API Level | Low-level | High-level SQL | High-level typed |
| Schema | No | Yes (column names) | Yes (typed case class) |
| Type Safety | Python runtime | Runtime only | COMPILE TIME |
| Catalyst Optim. | None | Full | Full |
| Tungsten | None | Full | Full |
| Performance | Slowest | Fast | Fast |
| Language | Python/Scala/Java | All languages | Scala/Java ONLY |
| Null Handling | Manual | Automatic | Automatic |
| When to use | Unstructured data | Structured data | N/A in PySpark |
| Complex custom | SQL-like ops | | |
| logic | Most cases | | |
β
Pro Tip
Interview Tip: "In PySpark, the choice is RDD vs DataFrame. I use DataFrame by default β it is faster (Catalyst + Tungsten), has a cleaner API, and supports SQL syntax. I use RDD only when the DataFrame API cannot express my logic or for unstructured text/binary data."
What NOT to say: "Dataset is available in PySpark." It is not β Dataset is Scala/Java only. In PySpark, DataFrame IS the Dataset[Row] equivalent.
Answer First: SparkSession is the unified entry point for DataFrame, SQL, catalog, streaming, and underlying SparkContext access; build or reuse one session, then create DataFrames with an explicit schema when practical.
Memory Map: builder -> config -> getOrCreate -> read/createDataFrame -> lazy plan.
SECTION 2: SparkSession + DataFrame Creation
What is SparkSession?
Definition: SparkSession is the unified entry point for all Spark functionality β reading data, running SQL, accessing the catalog, and configuring the application.
Simple Explanation: Before you do anything in Spark, you need a SparkSession. It is the single door you walk through to access everything Spark offers.
β
Pro Tip
Interview Tip: Always mention getOrCreate() β it prevents creating multiple sessions in the same application. Mention that SparkSession replaced the older SparkContext + SQLContext + HiveContext pattern.
What NOT to say: "You need a SparkContext to use DataFrames." SparkSession is the entry point since Spark 2.0. SparkContext is still used internally but you access it via spark.sparkContext.
Creating SparkSession
from pyspark.sql import SparkSession
spark = SparkSession.builder \
.appName("DataPipeline") \
.master("yarn") \
.config("spark.executor.memory", "8g") \
.config("spark.executor.cores", "4") \
.config("spark.executor.instances", "10") \
.config("spark.sql.shuffle.partitions", "400") \
.config("spark.sql.adaptive.enabled", "true") \
.config("spark.sql.autoBroadcastJoinThreshold", str(50 * 1024 * 1024)) \
.enableHiveSupport() \
.getOrCreate()
DataFrame Creation Methods
Definition: A DataFrame can be created from files (CSV, JSON, Parquet, etc.), databases (JDBC), in-memory data (lists, RDDs), or existing tables.
data = [("Alice", 30, "Engineering"), ("Bob", 25, "Marketing")]
columns = ["name", "age", "department"]
df = spark.createDataFrame(data, columns)
rdd = spark.sparkContext.parallelize([("Alice", 30), ("Bob", 25)])
df = rdd.toDF(["name", "age"])
import pandas as pd
pdf = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25]})
df = spark.createDataFrame(pdf)
df = spark.table("database_name.table_name")
df = spark.range(0, 100, 1)
β
Pro Tip
Interview Tip: When asked "how do you create a DataFrame?", list at least 3 methods: from files, from in-memory data, and from existing tables. Mention spark.createDataFrame() for testing and spark.read for production.
Answer First: A production schema is a contract for column names, types, and nullability. Explicit schemas avoid inference scans and make malformed or drifting input visible.
Memory Map: StructType -> StructField -> name/type/nullability -> validation.
SECTION 3: Schema Definition (StructType, StructField)
Definition: A schema in PySpark defines the structure of a DataFrame β column names, data types, and nullability β using StructType (the table) and StructField (each column).
Simple Explanation: A schema is the blueprint of your data. Just like a building blueprint specifies where every room goes, a schema specifies what every column looks like before any data arrives.
Real-world Analogy: Schema is like the column headers and data validation rules in an Excel template β it says "Column A must be text, Column B must be a number, Column C cannot be blank."
from pyspark.sql.types import (
StructType, StructField,
StringType, IntegerType, LongType, DoubleType, FloatType,
BooleanType, DateType, TimestampType, ArrayType, MapType
)
booking_schema = StructType([
StructField("booking_id", StringType(), nullable=False),
StructField("customer_id", LongType(), nullable=True),
StructField("flight_code", StringType(), nullable=True),
StructField("amount", DoubleType(), nullable=True),
StructField("booking_date", DateType(), nullable=True),
StructField("is_cancelled", BooleanType(), nullable=True),
StructField("address", StructType([
StructField("city", StringType(), True),
StructField("country", StringType(), True)
]), True),
StructField("tags", ArrayType(StringType()), True),
StructField("metadata", MapType(StringType(), StringType()), True)
])
df = spark.read.schema(booking_schema).csv("/data/bookings.csv", header=True)
| Aspect | inferSchema=True | Explicit Schema |
|---|
| Performance | Extra pass over data | No extra pass |
| Type accuracy | Often wrong (123 as Long) | You control every type |
| Null handling | Guesses nullability | You define nullable |
| Streaming support | NOT supported | Required |
| Schema enforcement | None | Fail fast on bad data |
| Production use | NEVER | ALWAYS |
β
Pro Tip
Interview Tip: Always say "In production I define schemas explicitly using StructType. inferSchema triggers an additional read pass over the data and can misdetect types." Mention that streaming sources REQUIRE explicit schemas.
What NOT to say: "I just use inferSchema=True." This signals you have not worked on production pipelines. Also do not say "I use DDL strings for complex schemas" β StructType is the standard.
Answer First: Match the reader to the source, supply schema and failure mode deliberately, and parallelize remote reads only with safe partition bounds. Prefer columnar formats for analytical reuse.
Memory Map: format -> schema -> options -> paths/partitions -> scan plan.
SECTION 4: Reading Data (CSV, JSON, Parquet, JDBC, Delta)
β
Pro Tip
Definition: spark.read is the DataFrameReader API that loads data from external storage into a DataFrame, supporting multiple formats and configuration options.
Simple Explanation: spark.read is how you bring outside data into Spark. You specify the format, set options (like whether there is a header row), and point it to the file path.
df_csv = spark.read \
.option("header", "true") \
.option("inferSchema", "true") \
.option("delimiter", ",") \
.option("nullValue", "N/A") \
.option("dateFormat", "yyyy-MM-dd") \
.csv("/data/bookings.csv")
df_json = spark.read \
.option("multiLine", "true") \
.option("mode", "PERMISSIVE") \
.json("/data/events/*.json")
df_parquet = spark.read.parquet("/data/warehouse/bookings/")
df_orc = spark.read.orc("/data/hive_table/")
df_jdbc = spark.read \
.format("jdbc") \
.option("url", "jdbc:postgresql://db-host:5432/mydb") \
.option("dbtable", "bookings") \
.option("user", "etl_user") \
.option("password", "secret") \
.option("driver", "org.postgresql.Driver") \
.option("numPartitions", "10") \
.option("partitionColumn", "booking_id") \
.option("lowerBound", "1") \
.option("upperBound", "1000000") \
.load()
df_avro = spark.read.format("avro").load("/data/kafka_output/")
df_delta = spark.read.format("delta").load("/data/delta/bookings/")
df_delta = spark.table("bookings_db.bookings")
Mode | Behavior on Bad Records
--------------|----------------------------------------------------
PERMISSIVE | Sets bad fields to null, stores raw in _corrupt_record
DROPMALFORMED | Silently drops bad rows
FAILFAST | Throws exception immediately on bad data
Reading Multiple Files from Multiple Sources
df = spark.read.csv("/data/2024/*/bookings_*.csv", header=True)
df = spark.read.parquet(
"/data/2024/jan/bookings.parquet",
"/data/2024/feb/bookings.parquet",
"/data/2024/mar/bookings.parquet"
)
files = ["/data/2024/jan/bookings.parquet", "/data/2024/feb/bookings.parquet"]
df = spark.read.parquet(*files)
df = spark.read.parquet("/data/2024/")
from pyspark.sql.functions import input_file_name
df = spark.read.option("header", "true") \
.csv("/data/2024/*/bookings_*.csv") \
.withColumn("source_file", input_file_name())
df_csv = spark.read.option("header", True).csv("/landing/batch/")
df_json = spark.read.json("/landing/api/events/")
df_parquet = spark.read.parquet("/landing/warehouse/")
cols = ["booking_id", "customer_id", "amount", "booking_date"]
df_all = df_csv.select(cols) \
.union(df_json.select(cols)) \
.union(df_parquet.select(cols))
df_all = df_csv.unionByName(df_json, allowMissingColumns=True) \
.unionByName(df_parquet, allowMissingColumns=True)
df_s3 = spark.read.parquet("s3a://my-bucket/data/")
df_adls = spark.read.parquet("abfss://container@storage.dfs.core.windows.net/data/")
df_hdfs = spark.read.parquet("hdfs://namenode:8020/data/")
df_local = spark.read.parquet("file:///local/path/data/")
df_combined = spark.read.parquet(
"s3a://bucket/data/2023/",
"s3a://bucket/data/2024/",
"hdfs://namenode/archive/2022/"
)
β
Pro Tip
Interview Tip: When discussing file reading, always mention: (1) explicit schema over inferSchema, (2) Parquet as the preferred format for analytics, (3) partition pruning for performance. For JDBC, mention numPartitions + partitionColumn for parallel reads.
What NOT to say: "I just use spark.read.csv(path) with defaults." This shows you do not understand production considerations like schema enforcement, read modes, or performance tuning.
Answer First: Transformations extend a lazy plan; actions trigger execution. Prefer built-in column expressions so Catalyst can reason about filters, projections, types, and code generation.
Memory Map: select -> filter -> derive -> aggregate -> action. Keep projections narrow and avoid long withColumn chains.
Definition: Transformations are lazy operations that define a computation plan on a DataFrame without executing it. They produce a new DataFrame (DataFrames are immutable).
Simple Explanation: Transformations are instructions you stack up. Nothing actually runs until you call an action (like .show(), .count(), .collect()). Spark waits so it can optimize the entire chain at once.
Real-world Analogy: Writing a recipe (transformations) vs actually cooking it (actions). You write all the steps first, then the chef (Catalyst optimizer) rearranges them for efficiency before cooking.
select
Definition: Projects a set of columns or expressions from a DataFrame, equivalent to SELECT in SQL.
df.select("id", "name", "amount")
df.select(col("id"), col("amount") * 1.1)
df.select("*", (col("amount") * 1.1).alias("new_amount"))
filter / where
Definition: Returns rows that satisfy a given condition. filter() and where() are identical β aliases of each other.
df.filter(col("age") > 30)
df.filter("age > 30")
df.where((col("country") == "IN") & (col("amount") > 100))
df.filter(col("status").isin("CONFIRMED", "PENDING"))
df.filter(col("name").startswith("A"))
df.filter(col("name").like("A%"))
df.filter(~col("is_cancelled"))
β
Pro Tip
Interview Tip: Mention that filter() and where() are the same β interviewers sometimes test this. Also mention that Spark pushes filter predicates down to the data source (predicate pushdown) for formats like Parquet and JDBC.
withColumn
Definition: Returns a new DataFrame with a column added or replaced. If the column name already exists, it replaces the column.
df.withColumn("tax", col("amount") * 0.18)
df.withColumn("amount", col("amount").cast("double"))
df.withColumn("status", when(col("amount") > 1000, "high")
.when(col("amount") > 100, "medium")
.otherwise("low"))
β
Pro Tip
What NOT to say: "I chain 20 withColumn calls to add columns." Chaining many withColumn calls creates a deep logical plan that slows Catalyst. Use select() with multiple expressions instead for better performance.
drop
Definition: Returns a new DataFrame with specified columns removed.
df.drop("col1", "col2")
df.drop(col("temporary_column"))
distinct / dropDuplicates
Definition: distinct() removes duplicate rows considering ALL columns. dropDuplicates() removes duplicates based on a SUBSET of columns.
df.distinct()
df.dropDuplicates(["customer_id", "booking_date"])
β
Pro Tip
Interview Tip: If asked "how do you deduplicate?", mention dropDuplicates for subset-based dedup. For "keep the latest record per key", use window function with row_number() β not dropDuplicates, because dropDuplicates gives you an arbitrary row, not the most recent.
What NOT to say: "I use distinct() to deduplicate by key." distinct() considers ALL columns. If you only want unique keys, use dropDuplicates(["key_col"]).
withColumnRenamed / sort
df.withColumnRenamed("old_name", "new_name")
df.orderBy("amount")
df.orderBy(col("amount").desc())
df.orderBy(col("date").asc(), col("amount").desc())
Answer First: Build transformations from Spark column expressions, not Python row logic, so types remain explicit and Catalyst can inspect the expression tree.
Memory Map: col/lit -> condition -> cast -> alias -> projected column.
SECTION 6: Column Operations (col, lit, when/otherwise, cast)
Definition: Column operations are functions that create, transform, or reference individual columns within a DataFrame expression.
Simple Explanation: col() points to an existing column. lit() creates a constant value column. when().otherwise() is your IF-ELSE logic. cast() converts data types.
col() and lit()
from pyspark.sql.functions import col, lit
df.select(col("name"), col("amount") * 2)
df.withColumn("country", lit("India"))
df.withColumn("multiplier", lit(1.18))
tax_rate = 0.18
β
Pro Tip
Interview Tip: lit() is needed when you want to add a constant column or use a Python variable as a column value. Without lit(), Python values sometimes work due to implicit conversion, but lit() makes intent explicit.
when / otherwise (Conditional Logic)
Definition: when().otherwise() is the PySpark equivalent of SQL's CASE WHEN. It evaluates conditions in order and returns the first matching result.
from pyspark.sql.functions import when
df.withColumn("category",
when(col("amount") > 1000, "high")
.when(col("amount") > 100, "medium")
.otherwise("low")
)
df.withColumn("flag",
when((col("status") == "CANCELLED") & (col("amount") > 500), "refund_priority")
.when(col("status") == "CANCELLED", "standard_refund")
.otherwise("no_action")
)
df.withColumn("tier",
when(col("total_spend") > 10000, "platinum")
.when((col("total_spend") > 5000) & (col("years") > 3), "gold")
.when(col("total_spend") > 1000, "silver")
.otherwise("bronze")
)
cast (Type Conversion)
Definition: Converts a column from one data type to another.
df.withColumn("amount", col("amount").cast("double"))
df.withColumn("booking_date", col("booking_date").cast("date"))
df.withColumn("amount_int", col("amount").cast(IntegerType()))
What NOT to say: "I use int() or float() to convert column types." Those are Python functions, not Spark functions. Always use .cast() for DataFrame column type conversion.
Answer First: Aggregations collapse rows by key and usually shuffle; reduce data early, group on intentional keys, and use bounded aggregate state.
Memory Map: filter -> group keys -> partial aggregate -> shuffle -> final aggregate.
SECTION 7: Aggregations (groupBy, agg, sum, avg, count, min, max)
Definition: Aggregation operations group rows by one or more columns and compute summary statistics (count, sum, average, etc.) for each group.
Simple Explanation: Aggregation is like creating a pivot table in Excel β you pick the grouping columns and then calculate totals, averages, or counts for each group.
Real-world Analogy: Counting how many passengers boarded each flight and the total revenue per flight β that is a groupBy("flight_code") with count and sum aggregations.
from pyspark.sql.functions import count, sum, avg, max, min, countDistinct, collect_list, collect_set
df.groupBy("country") \
.agg(
count("*").alias("booking_count"),
sum("amount").alias("total_amount"),
avg("amount").alias("avg_amount"),
max("amount").alias("max_amount"),
min("amount").alias("min_amount"),
countDistinct("customer_id").alias("unique_customers")
)
df.groupBy("country", "booking_year") \
.agg(
count("*").alias("bookings"),
sum("amount").alias("revenue")
)
df.groupBy("customer_id") \
.agg(
collect_list("product").alias("all_products"),
collect_set("product").alias("unique_products")
)
df.groupBy("country").count()
df.groupBy("country").sum("amount")
df.groupBy("country").avg("amount")
df.groupBy("country").max("amount")
| Function | What it does | Null behavior |
|---|
| count("*") | Counts all rows including nulls | Counts everything |
| count("col") | Counts non-null values in column | Ignores nulls |
| countDistinct() | Counts unique non-null values | Ignores nulls |
| sum() | Sum of values | Ignores nulls |
| avg() / mean() | Average of values | Ignores nulls |
| max() / min() | Maximum / minimum value | Ignores nulls |
| collect_list() | Collects into array (with duplicates) | Includes nulls |
| collect_set() | Collects into array (unique only) | Excludes nulls |
| first() / last() | First / last value in group | Depends on ignorenulls |
β
Pro Tip
Interview Tip: Always mention that count("*") counts all rows including nulls, while count("column_name") skips nulls. This is a common trick question. Also mention that collect_list and collect_set can cause OOM if groups are very large.
What NOT to say: "count() and count(column) are the same." They are not. count("*") includes null rows; count("col") excludes them.
Recall Stack: joins and windows
Answer First: A join combines rows and may redistribute data; a window keeps row cardinality while adding group context. Choose a join strategy from data size and distribution, then verify the physical plan.
Memory Map: keys -> sizes -> distribution -> strategy -> exchange; for windows: partition -> order -> frame -> function.
SECTION 8: Joins
Definition: A join combines rows from two DataFrames based on a related column (join key), similar to SQL joins.
Simple Explanation: Joins connect two tables using a shared column. Think of matching employee IDs in a "people" table with the same IDs in a "salary" table to get each person's salary.
Real-world Analogy: You have a guest list (names + invite codes) and a seating chart (invite codes + table numbers). Joining them on invite code gives you name + table number.
from pyspark.sql.functions import broadcast, col
result = df1.join(df2, on="customer_id", how="inner")
result = df1.join(df2, on=["customer_id", "date"], how="left")
result = df1.join(df2, on="customer_id", how="right")
result = df1.join(df2, on="customer_id", how="full")
result = df1.crossJoin(df2)
result = df1.join(df2, on="customer_id", how="left_semi")
result = df1.join(df2, on="customer_id", how="left_anti")
result = df1.join(df2, df1["cust_id"] == df2["customer_id"], how="inner")
result = result.drop(df2["customer_id"])
result = large_df.join(broadcast(small_df), on="airport_code")
| Join Type | Left rows | Right rows | When no match | Use case |
|---|
| inner | Matched | Matched | Row dropped | Only common records |
| left | ALL | Matched | Right cols = NULL | Keep all from primary table |
| right | Matched | ALL | Left cols = NULL | Keep all from lookup table |
| full | ALL | ALL | Opposite side = NULL | Merge two full datasets |
| cross | ALL x ALL | ALL x ALL | N/A (cartesian) | Generate all combinations |
| left_semi | Matched | NONE | Row dropped | EXISTS / IN subquery |
| left_anti | Unmatched | NONE | Row kept | NOT EXISTS / NOT IN subquery |
β
Pro Tip
Interview Tip: Semi and anti joins are interview favorites. Explain them as "filter joins" β they filter the left table based on existence in the right table but never add right-side columns. Always mention broadcast joins for small-large table joins.
What NOT to say: "Semi join returns columns from both tables." It only returns columns from the LEFT table. Also never say "I always use inner join" β show awareness of when to use left/anti/semi.
β
Pro Tip
Follow-up they will ask: "What happens with duplicate keys in a join?" Answer: rows multiply. If key "A" appears 3 times in left and 2 times in right, inner join produces 6 rows for key "A".
Answer First: A window retains each input row while adding ordered group context. Define partition, ordering, and frame explicitly because each controls both meaning and shuffle/sort cost.
Memory Map: partitionBy -> orderBy -> frame -> rank/lag/aggregate.
SECTION 9: Window Functions
Definition: Window functions perform calculations across a set of rows (a "window") that are related to the current row, without collapsing rows like GROUP BY does.
Simple Explanation: GROUP BY gives you one row per group. Window functions give you one result per ROW, but that result is computed from a group of related rows. You keep all your original rows.
Real-world Analogy: In a race, GROUP BY tells you "the fastest time per age group." A window function tells you "each runner's rank within their age group" β every runner keeps their row, but now has a rank attached.
Defining a Window
from pyspark.sql.window import Window
from pyspark.sql.functions import (
row_number, rank, dense_rank, lag, lead,
sum, avg, max, min, count,
ntile, percent_rank, cume_dist,
first, last
)
windowSpec = Window \
.partitionBy("department") \
.orderBy(col("salary").desc())
Ranking Functions (row_number, rank, dense_rank)
Definition: Ranking functions assign a position number to each row within a partition based on the ordering.
df = df.withColumn("row_num", row_number().over(windowSpec))
df = df.withColumn("rank", rank().over(windowSpec))
df = df.withColumn("drank", dense_rank().over(windowSpec))
| Salary | row_number | rank | dense_rank |
|---|
| 100 | 1 | 1 | 1 |
| 90 | 2 | 2 | 2 |
| 90 | 3 | 2 | 2 |
| 80 | 4 | 4 | 3 |
| 70 | 5 | 5 | 4 |
β
Pro Tip
Interview Tip: The most common interview question is "get top N per group." Always use row_number() or dense_rank() with a window function, filter by rank, then drop the rank column. Know the difference: row_number breaks ties arbitrarily, rank skips numbers after ties, dense_rank never skips.
What NOT to say: "I use GROUP BY with LIMIT to get top N per group." That does not work β LIMIT applies globally, not per group. You MUST use a window function.
TOP N Per Group Pattern
window = Window.partitionBy("department").orderBy(col("salary").desc())
top2 = df.withColumn("rank", dense_rank().over(window)) \
.filter(col("rank") <= 2) \
.drop("rank")
lag / lead (Access Previous/Next Row)
Definition: lag() accesses a value from a previous row; lead() accesses a value from the next row, within the window partition.
lag_spec = Window.partitionBy("customer_id").orderBy("purchase_date")
df = df.withColumn("prev_purchase", lag("amount", 1, 0).over(lag_spec))
df = df.withColumn("next_purchase", lead("amount", 1).over(lag_spec))
df = df.withColumn("mom_change",
round((col("revenue") - lag("revenue", 1).over(lag_spec))
/ lag("revenue", 1).over(lag_spec) * 100, 2))
Running Totals and Rolling Averages
Definition: A running total accumulates values from the beginning of the partition to the current row. A rolling average computes the average over a fixed sliding window of rows.
running_spec = Window.partitionBy("region") \
.orderBy("sale_date") \
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
df = df.withColumn("running_total", sum("revenue").over(running_spec))
rolling_spec = Window.partitionBy("user_id") \
.orderBy("event_date") \
.rowsBetween(-6, 0)
df = df.withColumn("rolling_7day_avg", avg("daily_count").over(rolling_spec))
| Frame Type | Syntax | Based on |
|---|
| ROWS | rowsBetween(-2, 0) | Physical row positions |
| RANGE | rangeBetween(-7, 0) | Logical value range |
| Unbounded | Window.unboundedPreceding | From start of partition |
| Current | Window.currentRow | Current row |
ntile / percentile / first / last
quartile_spec = Window.orderBy("spend")
df = df.withColumn("quartile", ntile(4).over(quartile_spec))
df = df.withColumn("percentile", percent_rank().over(quartile_spec))
first_spec = Window.partitionBy("customer_id").orderBy("purchase_date")
df = df.withColumn("first_purchase_amount",
first("amount", ignorenulls=True).over(first_spec))
What NOT to say: "Window functions are the same as GROUP BY." They are fundamentally different β GROUP BY collapses rows, window functions preserve every row. Also do not say "rowsBetween and rangeBetween are the same" β rows is physical position, range is logical value.
Recall Stack: SQL and the UDF boundary
Answer First: DataFrame and SQL expressions reach the same optimizer. Built-ins are the default; use a Pandas UDF only when no suitable built-in exists, and a row-wise Python UDF as the last resort.
Memory Map: built-in SQL expression -> higher-order function -> Pandas UDF -> Python UDF.
SECTION 10: Temp Views and Running SQL
Definition: A temporary view registers a DataFrame as a named table that can be queried with SQL. Session-scoped views are visible only within the current SparkSession; global views are visible across all sessions in the application.
Simple Explanation: Creating a temp view is like giving your DataFrame a table name so you can write SQL queries against it. The data is not copied β it is just a reference.
df.createOrReplaceTempView("bookings")
df.createOrReplaceGlobalTempView("bookings")
result = spark.sql("""
SELECT
country,
COUNT(*) as booking_count,
SUM(amount) as total_revenue,
ROUND(AVG(amount), 2) as avg_booking
FROM bookings
WHERE booking_date >= '2024-01-01'
AND is_cancelled = false
GROUP BY country
HAVING COUNT(*) > 100
ORDER BY total_revenue DESC
""")
spark.sql("""
SELECT *,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) as rank
FROM employees
""")
spark.sql("SELECT * FROM bookings WHERE amount > 1000").groupBy("country").count()
β
Pro Tip
Interview Tip: Mention that createOrReplaceTempView is session-scoped and disappears when the session ends. For shared views across notebooks in Databricks, use createOrReplaceGlobalTempView accessed via the global_temp database.
What NOT to say: "Temp views persist after the application ends." They do not β they live only as long as the SparkSession. Also do not confuse temp views with managed/external tables which persist in the metastore.
Answer First: Prefer built-in expressions; use a Python UDF only when no equivalent exists, and consider a vectorized Pandas UDF when Arrow-compatible batch execution fits.
Memory Map: built-in first -> SQL/JVM plan -> Pandas UDF -> scalar Python UDF last.
SECTION 11: UDFs (User-Defined Functions)
Definition: A UDF is a custom function that extends Spark's built-in functions, allowing you to apply arbitrary Python/Scala logic to DataFrame columns.
Simple Explanation: When Spark's built-in functions cannot do what you need, you write your own function and register it as a UDF. But UDFs are slow because data must be serialized between the JVM and Python.
Real-world Analogy: Built-in Spark functions are like the kitchen tools already in a restaurant. A UDF is bringing your own tool from home β it works, but it slows everything down because the kitchen was not designed for it.
Regular Python UDF
from pyspark.sql.functions import udf
from pyspark.sql.types import StringType, DoubleType, ArrayType
def clean_phone(phone):
"""Remove non-digit chars from phone number"""
import re
return re.sub(r'\D', '', phone) if phone else None
clean_phone_udf = udf(clean_phone, StringType())
df = df.withColumn("clean_phone", clean_phone_udf(col("phone")))
spark.udf.register("clean_phone", clean_phone, StringType())
spark.sql("SELECT clean_phone(phone) FROM customers")
UDF with Decorator
@udf(returnType=DoubleType())
def calculate_tax(amount, rate):
return amount * rate if amount and rate else 0.0
df = df.withColumn("tax", calculate_tax(col("amount"), col("tax_rate")))
Pandas UDF (Vectorized β Much Faster)
from pyspark.sql.functions import pandas_udf
import pandas as pd
@pandas_udf(StringType())
def normalize_name(names: pd.Series) -> pd.Series:
"""Vectorized: processes entire column as pandas Series"""
return names.str.strip().str.title().fillna("Unknown")
df = df.withColumn("clean_name", normalize_name(col("name")))
@pandas_udf(DoubleType())
def calculate_discount(amount: pd.Series, tier: pd.Series) -> pd.Series:
discount_map = {"gold": 0.15, "silver": 0.10, "bronze": 0.05}
discount = tier.map(discount_map).fillna(0)
return amount * discount
df = df.withColumn("discount", calculate_discount(col("amount"), col("tier")))
| Type | Speed | Why |
|---|
| Built-in Spark functions | Fastest | No serialization, Catalyst optimized, codegen |
| Pandas UDF (vectorized) | Fast | Arrow-based batch transfer, columnar |
| Regular Python UDF | Slowest | Row-by-row serialization, Python<->JVM overhead |
β
Pro Tip
Interview Tip: When asked about UDFs, immediately mention the performance penalty and say "I always try to use built-in Spark SQL functions first. If I must use a UDF, I use Pandas UDF for vectorized processing." Explain WHY they are slow: data serialization between JVM and Python interpreter.
What NOT to say: "UDFs are fine for production at scale." They should be a last resort. Also do not say "Pandas UDFs and regular UDFs have the same performance" β Pandas UDFs are 3x-100x faster due to Apache Arrow.
Recall Stack: storage and nested data
Answer First: Preserve types with columnar formats, partition only by useful low/moderate-cardinality predicates, and size output files from measured data volume. Flatten arrays/maps explicitly when downstream rows require it.
Memory Map: format -> mode -> partition columns -> file count -> schema evolution; nested data is struct / array / map -> select / explode.
SECTION 12: Writing Files
Definition: df.write is the DataFrameWriter API that saves a DataFrame to external storage in various formats with configurable write modes and partitioning.
df.write.mode("overwrite").parquet("/output/bookings/")
df.write \
.mode("overwrite") \
.partitionBy("booking_year", "booking_month") \
.parquet("/output/bookings_partitioned/")
df.write \
.mode("overwrite") \
.bucketBy(32, "customer_id") \
.sortBy("customer_id") \
.saveAsTable("default.bookings_bucketed")
df.write \
.format("delta") \
.mode("overwrite") \
.save("/delta/bookings/")
df.write \
.option("header", "true") \
.option("delimiter", "|") \
.mode("overwrite") \
.csv("/output/bookings.csv")
df.write \
.format("jdbc") \
.option("url", "jdbc:postgresql://db:5432/mydb") \
.option("dbtable", "bookings") \
.option("user", "user") \
.option("password", "pass") \
.mode("append") \
.save()
df.repartition(10).write.parquet("/output/")
df.coalesce(1).write.csv("/output/single_file/")
| Mode | If path exists | If path does NOT exist |
|---|
| overwrite | Replaces all data | Creates new |
| append | Adds to existing data | Creates new |
| ignore | Does nothing (no error) | Creates new |
| error (default) | Throws error | Creates new |
β
Pro Tip
Interview Tip: Always mention partitionBy for write optimization β it enables partition pruning on reads. Mention that bucketBy only works with saveAsTable, not save(). For controlling output file count, use repartition() before write.
What NOT to say: "I use coalesce(1) to write a single file in production." This forces all data through one partition/core and is extremely slow for large datasets. Only acceptable for small lookup files.
Answer First: Null is unknown, not an ordinary value. Choose whether to preserve, drop, fill, or compare null-safely based on business semantics.
Memory Map: detect -> decide semantics -> drop/fill/coalesce -> validate counts.
SECTION 13: Handling NULL Values
Definition: NULL represents a missing or unknown value. PySpark provides isNull(), isNotNull(), na.fill(), na.drop(), and coalesce() for null handling.
Simple Explanation: NULLs are empty cells in your data. You need to decide: drop the row, fill with a default value, or handle them in your logic. Ignoring nulls causes silent bugs.
Real-world Analogy: A survey form with blank answers. You can throw away incomplete forms (dropna), write "N/A" in blank fields (fillna), or use the first available answer from a backup list (coalesce).
from pyspark.sql.functions import col, coalesce, lit, isnull
df.filter(col("amount").isNull())
df.filter(col("amount").isNotNull())
df.na.drop()
df.na.drop(how="all")
df.na.drop(subset=["customer_id", "amount"])
df.na.drop(how="any", thresh=3)
df.na.fill(0)
df.na.fill("")
df.na.fill({"amount": 0, "country": "UNKNOWN"})
df.withColumn("phone",
coalesce(col("mobile_phone"), col("home_phone"), col("work_phone"), lit("N/A"))
)
from pyspark.sql.functions import when
df.withColumn("rate",
when(col("impressions") != 0,
col("clicks") / col("impressions"))
.otherwise(None))
df.filter(col("a").eqNullSafe(col("b")))
Operation | NULL behavior
--------------------|-------------------------------------------
NULL + 5 | NULL (any arithmetic with NULL = NULL)
NULLβ NULL | NULL (not True!)
NULL <=> NULL | True (null-safe equality)
count("*") | Counts rows including nulls
count("col") | Skips null values
sum/avg/max/min | Ignores nulls
ORDER BY | NULLs are LAST by default (ascending)
GROUP BY | NULL is treated as a group
JOIN on NULL keys | NULL keys do NOT match (NULL != NULL)
π‘ Insight
Interview Tip: The key insight is that NULL == NULL returns NULL, not True. This means NULL join keys never match. Always mention eqNullSafe or <=> for null-safe comparisons. In aggregations, nulls are silently ignored β know this for accurate counts.
What NOT to say: "NULL equals NULL in Spark." It does not. NULL == NULL evaluates to NULL, which is falsy. This is standard SQL three-valued logic.
Answer First: Normalize strings with built-in expressions so cleanup stays vectorized and visible in the logical plan.
Memory Map: trim/case -> split/extract -> replace -> concatenate -> validate.
SECTION 14: String Functions
Definition: PySpark provides built-in string functions for text manipulation including concatenation, trimming, pattern matching, and extraction.
from pyspark.sql.functions import (
concat, concat_ws, substring, trim, ltrim, rtrim,
lower, upper, initcap, length, lpad, rpad,
regexp_replace, regexp_extract, split,
instr, locate, translate, reverse,
col, lit
)
df.withColumn("full_name", concat(col("first_name"), lit(" "), col("last_name")))
df.withColumn("full_name", concat_ws(" ", col("first_name"), col("middle"), col("last_name")))
df.withColumn("upper_name", upper(col("name")))
df.withColumn("lower_name", lower(col("name")))
df.withColumn("title_name", initcap(col("name")))
df.withColumn("clean", trim(col("name")))
df.withColumn("clean", ltrim(col("name")))
df.withColumn("clean", rtrim(col("name")))
df.withColumn("area_code", substring(col("phone"), 1, 3))
df.withColumn("clean_phone", regexp_replace(col("phone"), r"[^0-9]", ""))
df.withColumn("no_special", regexp_replace(col("text"), r"[^a-zA-Z0-9 ]", ""))
df.withColumn("domain", regexp_extract(col("email"), r"@(.+)", 1))
df.withColumn("parts", split(col("full_name"), " "))
df.withColumn("first", split(col("full_name"), " ")[0])
df.withColumn("last", split(col("full_name"), " ")[1])
df.withColumn("padded_id", lpad(col("id"), 10, "0"))
df.withColumn("name_len", length(col("name")))
| Function | Example Input | Output |
|---|
| upper("hello") | "hello" | "HELLO" |
| lower("HELLO") | "HELLO" | "hello" |
| initcap("hello") | "hello world" | "Hello World" |
| trim(" hi ") | " hi " | "hi" |
| substring(s,1,3) | "abcdef" | "abc" |
| length("hello") | "hello" | 5 |
| lpad("42",5,"0") | "42" | "00042" |
| concat_ws | NULL handling | Skips nulls |
| concat | NULL handling | Returns NULL if any null |
β
Pro Tip
Interview Tip: Know the difference between concat and concat_ws: concat returns NULL if ANY input is NULL, while concat_ws (with separator) skips NULLs. This is frequently asked.
What NOT to say: "I use Python string operations in a UDF for text manipulation." Always use built-in Spark string functions β they are optimized by Catalyst and avoid serialization overhead.
Answer First: Parse timestamps with an explicit format and timezone policy before deriving calendar fields or intervals.
Memory Map: raw string -> parse -> normalize timezone -> derive/diff -> format.
SECTION 15: Date Functions
Definition: PySpark provides built-in functions for date/timestamp creation, extraction, arithmetic, and formatting.
from pyspark.sql.functions import (
current_date, current_timestamp,
datediff, months_between, date_add, date_sub,
date_format, to_date, to_timestamp,
year, month, dayofmonth, dayofweek, dayofyear,
hour, minute, second, weekofyear, quarter,
last_day, next_day, trunc, date_trunc,
col, lit
)
df.withColumn("today", current_date())
df.withColumn("now", current_timestamp())
df.withColumn("parsed_date", to_date(col("date_str"), "yyyy-MM-dd"))
df.withColumn("parsed_ts", to_timestamp(col("ts_str"), "yyyy-MM-dd HH:mm:ss"))
df.withColumn("formatted", date_format(col("booking_date"), "MMM dd, yyyy"))
df.withColumn("year_month", date_format(col("booking_date"), "yyyy-MM"))
df.withColumn("next_week", date_add(col("booking_date"), 7))
df.withColumn("last_week", date_sub(col("booking_date"), 7))
df.withColumn("days_diff", datediff(col("end_date"), col("start_date")))
df.withColumn("months_diff", months_between(col("end_date"), col("start_date")))
df.withColumn("year", year(col("booking_date")))
df.withColumn("month", month(col("booking_date")))
df.withColumn("day", dayofmonth(col("booking_date")))
df.withColumn("dow", dayofweek(col("booking_date")))
df.withColumn("quarter", quarter(col("booking_date")))
df.withColumn("week", weekofyear(col("booking_date")))
df.withColumn("month_start", trunc(col("booking_date"), "month"))
df.withColumn("year_start", trunc(col("booking_date"), "year"))
df.withColumn("hour_start", date_trunc("hour", col("timestamp_col")))
df.withColumn("month_end", last_day(col("booking_date")))
df.withColumn("next_monday", next_day(col("booking_date"), "Monday"))
| Function | Example | Output |
|---|
| current_date() | - | 2024-03-15 |
| datediff(end, start) | (Mar 15, Mar 10) | 5 |
| date_add(date, 7) | (Mar 15, 7) | Mar 22 |
| months_between(end, start) | (Jun 15, Mar 15) | 3.0 |
| year(date) | Mar 15, 2024 | 2024 |
| date_format(date, "MMM yyyy") | 2024-03-15 | "Mar 2024" |
| to_date(str, "yyyy-MM-dd") | "2024-03-15" | Date object |
| trunc(date, "month") | 2024-03-15 | 2024-03-01 |
| last_day(date) | 2024-03-15 | 2024-03-31 |
β
Pro Tip
Interview Tip: Know that datediff(end, start) takes the end date FIRST, which is counterintuitive. Also know that dayofweek returns 1 for Sunday (not Monday), which is a common gotcha.
What NOT to say: "I use Python datetime in a UDF for date calculations." Always use built-in Spark date functions. They are Catalyst-optimized and handle distributed data correctly.
Answer First: Navigate structs by field, arrays by element operations, and maps by key; explode only when row multiplication is intentional and project both map key and value aliases.
Memory Map: inspect schema -> select field/key -> transform -> explode if needed -> flatten.
SECTION 16: Nested Data β Struct, Array, Map
Definition: PySpark supports complex nested data types: StructType (nested objects), ArrayType (lists), and MapType (key-value pairs), commonly found in JSON data.
Simple Explanation: Real-world data is not always flat tables. JSON from APIs has nested objects (structs), lists (arrays), and dictionaries (maps). PySpark can read, query, and flatten all of these.
from pyspark.sql.functions import explode, explode_outer, col, flatten, map_keys, map_values
df.select(
col("id"),
col("address.city").alias("city"),
col("address.country").alias("country"),
col("address.zip").alias("zip")
)
df.withColumn("tag", explode(col("tags"))) \
.select("id", "tag")
df.withColumn("tag", explode_outer(col("tags"))) \
.select("id", "tag")
from pyspark.sql.functions import size
df.withColumn("tag_count", size(col("tags")))
from pyspark.sql.functions import array_contains
df.filter(array_contains(col("tags"), "business"))
from pyspark.sql.functions import flatten
df.withColumn("flat_tags", flatten(col("nested_tags")))
df.withColumn("status", col("metadata")["status"])
df.withColumn("status", col("metadata").getItem("status"))
df.select("id", explode("metadata").alias("key", "value"))
json_data = """
{"booking_id": "B001", "customer": {"name": "Alice", "email": "a@b.com"}, "tags": ["biz", "premium"]}
"""
df = spark.read.json(sc.parallelize([json_data]))
df_flat = df.select(
col("booking_id"),
col("customer.name").alias("customer_name"),
col("customer.email").alias("customer_email"),
explode(col("tags")).alias("tag")
)
| Function | NULL array input | Empty array input | Output |
|---|
| explode | Drops row | Drops row | One row per element |
| explode_outer | Keeps row (NULL) | Drops row | One row per element |
| posexplode | Drops row | Drops row | Row + position index |
β
Pro Tip
Interview Tip: Always mention the difference between explode and explode_outer β explode drops null arrays silently, which can cause data loss. Use explode_outer when you want to preserve rows with null arrays.
What NOT to say: "I use UDFs to parse nested JSON." Spark handles nested JSON natively with dot notation for structs and explode for arrays. UDFs are unnecessary and slow for this.
Scenario ownership
CDC deduplication, self-joins, anti-joins, running totals, forward fill, nested JSON, and multi-source reads are practiced in Scenarios and Labs.
Quick Reference: DataFrame API vs SQL Equivalents
DataFrame API | SQL Equivalent
---------------------------------------|----------------------------------
df.select("a", "b") | SELECT a, b FROM table
df.filter(col("a") > 10) | WHERE a > 10
df.groupBy("a").agg(count("*")) | GROUP BY a ... COUNT(*)
df.orderBy(col("a").desc()) | ORDER BY a DESC
df.limit(10) | LIMIT 10
df.distinct() | SELECT DISTINCT *
df.join(df2, on="key", how="left") | LEFT JOIN df2 ON key = key
df.withColumn("b", col("a") * 2) | SELECT *, a * 2 AS b
df.drop("col") | (no direct equivalent, use SELECT)
df.union(df2) | UNION ALL
df.na.fill(0) | COALESCE(col, 0)
df.filter(col("a").isNull()) | WHERE a IS NULL