Take a Break
5:00
Inhale…
Give your mind a break — no phone, no music, just idle time or a quick walk.
Apache Spark DataFrames, Catalyst Optimizer, PySpark Operations, and Spark SQL
Prerequisite Knowledge
This lecture builds on the following concepts from earlier lectures. If any feel unfamiliar, review the linked notes before proceeding.
Previously Covered in This Subject
- Apache Spark and its place in the Hadoop ecosystem — covered in Lecture 3 (Evolution to Apache Spark)
- Relational joins over distributed data — covered in Lecture 8 (Relational Joins in Apache Hive)
- SQL-style abstraction and query execution over a cluster — covered in Lecture 7 (Hive) and Lecture 6 (Hive data warehousing)
- Data ingestion into the analytics stack — covered in Lecture 5 (Sqoop ingestion)
This lecture moves from low-level Spark RDDs to the schema-full DataFrame API: why DataFrames exist and how the Catalyst Optimizer rewrites plans (10.1), how to ingest data and shape columns and rows (10.2), how to filter, cast, limit, and sort (10.3), how to group and aggregate (10.4), how relational joins behave and how shuffle versus broadcast joins run on a cluster (10.5), and how Spark SQL sits on the same optimizer with plan parity to the DataFrame API (10.6).
10.1 History, Architecture, and Catalyst Optimizer of Spark DataFrames
10.1.1 Evolution from RDDs to Schema-Full DataFrames
Hook: You already have RDDs — immutable, fault-tolerant bags of objects spread across a cluster. So why did Spark invent a second, table-like abstraction on top of them? The short answer: RDDs make you think like a distributed systems engineer; DataFrames let you think like a data analyst — and they give Spark enough structure to optimize your work for you.
In early versions of Apache Spark (Spark 1.x), the core abstraction for distributed computation was the Resilient Distributed Dataset (RDD). An RDD (an immutable, fault-tolerant collection of elements partitioned across worker nodes) gave you powerful low-level functional primitives such as map, flatMap, and filter. Those primitives were enough to express almost any computation, but they created two operational problems in day-to-day analytics work:
- High learning curve. RDD programs force you to write explicit functional lambda chains. Because RDDs are schema-less, records are opaque Python or Java objects — often tuples — so you reach fields by positional index (
_1,_2) instead of by name. - No engine-level optimization. An RDD transformation treats each element as an arbitrary object. The Spark engine cannot look inside those objects to push filters earlier, drop unused columns, or choose a cheaper join plan before execution.
Intuition + Analogy: An RDD is like a warehouse full of unlabeled sealed boxes. You can ship boxes around, open each one with a custom script (map/filter), and rebuild lost boxes from a packing list (lineage) — but the warehouse manager cannot peek inside to rearrange work efficiently. A DataFrame is the same warehouse after you slap a shipping label on every box: named fields, known types, and nullability. Now the manager (Catalyst) can sort, prune, and route boxes without opening them one by one. The analogy breaks when you need true custom object logic that does not fit columns — then you still drop to RDDs or UDFs.
To lower the barrier for people who already know Python pandas and relational SQL tables, Spark introduced DataFrames.
A DataFrame is a distributed collection of data organized into named columns. Conceptually it matches a table in a relational database or a pandas DataFrame, but it runs distributed and is rewritten by the Catalyst query optimizer.
Unlike RDDs, DataFrames are schema-full: every DataFrame carries an explicit schema of column names, data types, and nullability flags.
Under the hood, a DataFrame is a high-level wrapper over RDDs. Every DataFrame operation is compiled into a logical plan, optimized, then executed as RDD transformations and actions:
\[ \text{DataFrame Operation} \xrightarrow{\text{Compilation}} \text{Logical Plan} \xrightarrow{\text{Catalyst Optimizer}} \text{Optimized RDD Transformations \& Actions} \]
In older Spark releases the same abstraction was called a SchemaRDD — a name that made the “RDD with a schema” idea explicit. Modern APIs use DataFrame (and Dataset in Scala/Java), but the compilation story above is unchanged.
| Dimension | RDD | DataFrame |
|---|---|---|
| Schema | Schema-less opaque objects | Named columns, types, nullability |
| Field access | Positional (_1, _2) or custom getters |
Column names / col() / SQL expressions |
| Optimization | Little automatic rewrite | Catalyst + (from 2.2+) cost-based optimization |
| Feel | Functional, low-level | Table / pandas / SQL-like |
| When to pick | Custom object pipelines, fine control | Analytics, ETL, SQL-friendly workloads |
When to pick which: Prefer DataFrames (or Spark SQL) for structured analytics so Catalyst can optimize. Drop to RDDs when you need element-level control that does not map cleanly to columns.
Q: Why were DataFrames introduced in Spark when RDDs were already available as the core building block? A: RDDs required low-level functional code and manual tuple-field indexing, so the learning curve was steep. DataFrames gave a familiar table-like interface with named columns (like pandas and SQL). Critically, schemas let Spark inspect structure and send operations through the Catalyst Optimizer, which usually yields much better execution efficiency than hand-written RDD chains for the same relational work.
Pitfalls:
- Thinking DataFrames replace RDDs. They wrap and compile down to RDDs; they do not delete the RDD layer.
- Treating a DataFrame like a local pandas object. Transformations are lazy and distributed; printing
dfdoes not materialize rows. - Assuming schema-less equals “more flexible and always better.” Flexibility without names and types costs you optimization and readability.
Recap + Bridge: DataFrames are schema-full, named-column wrappers over RDDs that compile through Catalyst into optimized RDD work. Next we look at how Catalyst rewrites a lazy logical plan into a cheaper physical plan — including cost-based choices when statistics exist.
Real-world domain connection: Teams that migrate pandas prototypes to production Spark keep the mental model of “tables with columns,” while Catalyst turns those expressions into cluster-efficient jobs — the same reason enterprise stacks expose one DataFrame/SQL surface to Python, Scala, and Java users (see 10.8.1).
10.1.2 Catalyst Optimizer and Cost-Based Optimization (CBO)
Hook: If you write filter then select then join, must Spark obey that order on the wire? No — and that is the point of Catalyst. The engine is free to rewrite your request into a cheaper physical plan as long as the answer is the same.
When a program modifies a DataFrame, Spark does not run each line immediately. DataFrame transformations are lazy: Spark builds an abstract logical execution plan — a tree of relational operators describing what you asked for, not yet how to run it.
Spark then hands that plan to the Catalyst Optimizer. Catalyst walks the tree and applies rule-based rewrites (and, with statistics, cost-based choices) to produce an optimized physical execution plan.
Purpose of Catalyst: Turn a correct but possibly expensive logical plan into a cheaper physical plan that still returns the same results.
What goes in: An unresolved / analyzed logical plan built from DataFrame API calls or spark.sql(...) text.
What comes out: A physical plan (join strategy, exchange/shuffle boundaries, projection order) that executors run as RDD stages.
Typical rewrite steps (conceptual order):
- Analyze — bind names to catalog/schema; fail early on unknown columns.
- Logical optimize — constant folding, predicate pushdown, column pruning, filter reordering.
- Physical planning — choose operators (e.g., broadcast hash join vs sort-merge join).
- Code generation / execution — run tasks on partitions (Tungsten-style execution sits under Spark SQL in the stack described in course texts).
In a cluster with one master and many workers, wide transformations such as groupBy and join need a shuffle: move data so that matching keys land on the same node. Network I/O is usually the dominant cost. Catalyst’s job is to shrink that cost — push filters before shuffles, drop unused columns early, and pick join strategies that avoid shipping huge tables when a small table can be broadcast instead.
Starting in Spark 2.2+, Spark added a Cost-Based Optimizer (CBO). The CBO uses table and column statistics (row counts, cardinality, size) to estimate costs of alternative plans and pick a lower-cost one when rules alone are not enough.
Because PySpark DataFrames and Scala/Java DataFrames both compile through the same Catalyst pipeline, query efficiency is unified across languages. For extreme object-allocation-heavy workloads, Scala/Java can still win on memory behavior because they stay on the JVM without a Python worker process — but the optimizer path itself is shared.
Scope / assumptions:
- Assumption: Transformations stay expressible as relational operators (or well-behaved expressions). Opaque Python UDFs can block many Catalyst rewrites.
- Assumption (CBO): Useful statistics exist; without them, Spark falls back mainly to rule-based optimization and defaults (e.g., broadcast thresholds).
- Breaks when: You force huge shuffles with poorly keyed joins, or you disable broadcast and join two giant tables with no selective filters.
Visual intuition: picture the logical plan as a tree drawn top-down — Join at the root, Filter and Scan as leaves. After optimization, a Filter that used to sit above a Join may move under it (predicate pushdown), and a skinny Project may appear early so only two columns ride the shuffle instead of fifty. Takeaway: same answer, less network and CPU.
Pitfalls:
- Expecting eager execution. Calling
withColumnonly extends the plan; actions (count,show,write) trigger work. - Assuming language choice changes Catalyst. PySpark and Scala share the optimizer; language mainly changes driver/worker serialization costs.
- Ignoring wide vs narrow transforms. A local
map-style mapPartitions is cheap; a poorly filteredjoincan dominate runtime.
Recap + Bridge: Catalyst lazily rewrites logical plans into physical ones; CBO adds statistics-aware plan choice from Spark 2.2+. To use DataFrames you still need an entry point — that is SparkSession (wrapping the older SparkContext).
Real-world domain connection: ETL jobs that filter early and broadcast small dimension tables routinely cut shuffle volume by orders of magnitude compared with naive RDD-style join code — the same idea as 10.5.2 and 10.8.2.
10.1.3 Spark Session vs. Spark Context Entry Points
Intuition + Analogy: SparkContext (sc) was the old front desk that only knew how to hand out RDD tickets. SparkSession (spark) is the renovated lobby that still contains that desk, plus the SQL/Hive counters — one badge gets you DataFrames, SQL, and the underlying context.
In Spark 1.x, the cluster entry point was SparkContext (often bound to the variable sc). It connected the driver to the cluster and created RDDs.
In Spark 2.0+, Spark unified entry points into SparkSession (often spark). SparkSession encapsulates SparkContext, SQLContext, and HiveContext, and is the standard entry point for DataFrame and Spark SQL work.
Inside interactive shells such as pyspark, spark and sc are usually pre-created. In standalone scripts, Jupyter, or Colab, you must build SparkSession yourself:
from pyspark.sql import SparkSession
# Initialize a SparkSession object
spark = SparkSession.builder \
.appName("Spark DataFrame Foundation") \
.getOrCreate()
Trace — creating a session and confirming the embedded context.
- Import
SparkSession. - Call
SparkSession.builder.appName(...).getOrCreate()— reuses an existing session if one is active. - Optional check:
spark.sparkContextreturns the liveSparkContext(same idea as classicsc). - You are ready for
spark.read,spark.createDataFrame, andspark.sql.
Sense-check: if spark.version prints a 2.x/3.x string and spark.read exists, you are on the unified API, not Spark 1.x-only SQLContext setup.
Pitfalls:
- Creating many sessions in one process. Prefer
getOrCreate()so you do not fight over a single JVM context. - Forgetting an app name in shared clusters. Vague default names make jobs hard to find in the Spark UI.
- Mixing legacy
HiveContextconstruction with modernSparkSessionpatterns without need — preferSparkSession.builder.enableHiveSupport()when Hive is required.
Exam note: Know that DataFrames are schema-full wrappers over RDDs, that Catalyst (and CBO) optimize lazy logical plans, and that SparkSession replaces fragmented 1.x entry points. Next: ingest files and declare schemas so Catalyst has real column metadata to work with (10.2).
10.2 Ingestion, Schema Definition, and Column/Row Operations
10.2.1 Data Ingestion Formats and Automatic Schema Inference
Hook: Catalyst can only optimize what it understands. If Spark does not know your column names and types, every downstream select, filter, and join starts from a weaker plan — so ingestion is where schema discipline begins.
SparkSession exposes a unified reader (spark.read) for structured and semi-structured sources: CSV, JSON, Parquet, ORC, XML, JDBC databases (for example MySQL), many NoSQL connectors, and streaming sources.
You can use format-specific helpers or a generic format(...).load(...) path:
# Ingest JSON dataset using format descriptor
df_json = spark.read.format("json").load("2015-summary.json")
# Ingest CSV dataset using direct format reader
df_csv = spark.read.csv("online_retail.csv", header=True, inferSchema=True)
When you omit an explicit schema on semi-structured data (for example JSON), Spark infers types with an initial scan:
- Text fields →
StringType - Whole numbers without decimals →
LongTypeorIntegerType
Course texts also stress Parquet/ORC/JSON as first-class Spark SQL sources: columnar formats (Parquet/ORC) store metadata that helps projection pushdown, while JSON is convenient but often costlier to parse at scale.
Scope: Automatic inference needs an extra pass over the files. On multi-terabyte lakes that pass is expensive. Production pipelines usually declare schemas up front so read jobs start typed work immediately.
Pitfalls:
inferSchema=Trueon huge CSV as a habit. Fine for exploration; costly in nightly jobs.- Trusting inferred nullability and precision. A column that “looks like int” in a sample may contain dirty strings later.
- Forgetting
header=Trueon CSV. Without it, the header becomes a data row and names become_c0,_c1, …
Recap + Bridge: spark.read is the front door; inference is convenient but not free. Next we declare schemas explicitly with StructType / StructField so Catalyst and your code agree on types.
10.2.2 Explicit Schema Definition with StructType and StructField
Intuition + Analogy: Declaring a schema is like printing a customs form before the cargo arrives: every field has a name, a type, and a “may be empty?” checkbox. Spark then validates and plans against that form instead of guessing from a peek at the crates.
PySpark schemas use StructType and StructField from pyspark.sql.types. A StructType is an ordered list of fields. Each StructField carries:
- Column name — string identifier.
- Data type — e.g.
StringType(),IntegerType(),DoubleType(),LongType(),TimestampType(). - Nullable flag —
True/Falsefor whether nulls are allowed.
Worked example — flight summary schema.
from pyspark.sql.types import StructType, StructField, StringType, IntegerType
# Define explicit custom schema
flight_schema = StructType([
StructField("DEST_COUNTRY_NAME", StringType(), True),
StructField("ORIGIN_COUNTRY_NAME", StringType(), True),
StructField("count", IntegerType(), True)
])
# Load dataset enforcing the custom schema
df_flights = spark.read.format("json") \
.schema(flight_schema) \
.load("2015-summary.json")
Walkthrough:
- Build three fields: two nullable strings (destination/origin country) and a nullable integer
count. - Attach the schema with
.schema(flight_schema)before.load(...). - Spark parses JSON into that shape; mismatched values become null (or cause read issues depending on mode), instead of silently widening types mid-job.
Sense-check: df_flights.printSchema() should show the three fields with the types you declared — not a surprise LongType from inference.
Setting nullable=False on columns you know are always present lets Catalyst skip some null-check branches in generated code, which can improve physical execution when the guarantee is real.
Assumption: nullable=False is a promise. If nulls appear anyway, you can get runtime failures or surprising drops depending on source and mode — only tighten nullability when the data contract is solid.
10.2.3 DataFrame Column Access and Manipulation
Hook: In pandas, df["count"] often feels like “give me the data now.” In Spark, col("count") is a reference in a plan — nothing is projected until a transformation like select (and later an action) uses it.
PySpark’s main column constructors are col(), column(), and expr() from pyspark.sql.functions:
from pyspark.sql.functions import col, column, expr
# Column reference methods
c1 = col("DEST_COUNTRY_NAME")
c2 = column("ORIGIN_COUNTRY_NAME")
c3 = expr("count + 10")
col / column name a field; expr parses a SQL expression string into a Column. None of these alone materialize row values.
Column Projection with select() and selectExpr()
select() projects columns; selectExpr() accepts SQL expression strings:
# Projecting specific columns using select
df_flights.select(col("DEST_COUNTRY_NAME"), col("ORIGIN_COUNTRY_NAME")).show(5)
# Applying inline SQL expressions with selectExpr
df_flights.selectExpr(
"DEST_COUNTRY_NAME as destination",
"ORIGIN_COUNTRY_NAME as origin",
"count",
"count * 3 as triple_count",
"ORIGIN_COUNTRY_NAME == DEST_COUNTRY_NAME as is_domestic"
).show(5)
lit() adds a constant value as a column:
from pyspark.sql.functions import lit
# Append a literal column 'one' containing integer 1
df_flights.select("*", lit(1).alias("one")).show(5)
Adding, Renaming, and Dropping Columns
| Method | Role |
|---|---|
withColumn(colName, col) |
Add or replace a column with an expression |
withColumnRenamed(existing, new) |
Rename one column |
drop(*colNames) |
Remove columns |
Worked example — add, rename, drop.
# Add a new column 'new_count' equal to count multiplied by 3
df_with_col = df_flights.withColumn("new_count", expr("count * 3"))
# Rename 'new_count' to 'triple_count'
df_renamed = df_with_col.withColumnRenamed("new_count", "triple_count")
# Drop columns
df_dropped = df_renamed.drop("triple_count", "DEST_COUNTRY_NAME")
If a sample row had count = 10:
- After
withColumn,new_count = 30. - After rename, the column is
triple_count(still 30);new_countis gone from the schema. - After
drop, neithertriple_countnorDEST_COUNTRY_NAMEremains.
Sense-check: each step returns a new DataFrame variable — chain assignments deliberately.
DataFrame Immutability
DataFrames are immutable. withColumn / drop do not mutate the old object’s buffers; they return a new DataFrame whose logical plan includes the change.
# The original df_flights remains unchanged after calling withColumn
df_flights.withColumn("test_col", lit(100))
# df_flights still contains only the original 3 columns
print(df_flights.columns) # ['DEST_COUNTRY_NAME', 'ORIGIN_COUNTRY_NAME', 'count']
# To persist transformations, reassign the result to a variable
df_flights = df_flights.withColumn("test_col", lit(100))
Immutability underpins fault tolerance: if a worker dies, Spark recomputes lost partitions from the immutable lineage back to source data.
Teaching moment — immutability: Calling df.withColumn(...) without assignment looks like it “worked” in the sense that it returns a DataFrame, but the name df still points at the old plan. Always assign (or chain into a new name) if you need the change downstream.
Pitfalls:
- Discarding the return value of
withColumn. Classic bug; originaldfunchanged. - Confusing Column objects with arrays of values. You cannot index
col("count")like a Python list of ints. - Overusing
select("*", …)on wide tables before a shuffle — prune early when you can.
Recap + Bridge: Columns are plan references; select / withColumn / drop build new immutable DataFrames. Next we build tiny DataFrames from Row objects when files are not the source.
10.2.4 Row Objects and In-Memory DataFrame Construction
A Row is one record. spark.createDataFrame(...) builds a DataFrame from a list of Rows or tuples — useful for tests, demos, and small driver-side tables (still subject to driver memory limits).
Worked example — Delhi → Jaipur → Mumbai flights.
from pyspark.sql import Row
# Construct individual Row instances
row1 = Row("Delhi", "Jaipur", 3)
row2 = Row("Jaipur", "Mumbai", 2)
row3 = Row("Mumbai", "Trivandrum", 1)
# Assemble rows into a list sequence
row_list = [row1, row2, row3]
# Create DataFrame without explicit schema (columns default to _1, _2, _3)
df_custom_raw = spark.createDataFrame(row_list)
df_custom_raw.show()
# Assign explicit column names using toDF()
df_custom = df_custom_raw.toDF("origin", "destination", "flight_count")
df_custom.show()
- Three positional
Rows become three records. - Without names, Spark labels columns
_1,_2,_3— the same positional habit as schema-less RDD tuples. toDF("origin", "destination", "flight_count")attaches human names.
Sense-check: df_custom.count() is 3; printSchema shows three named fields after toDF.
Pitfalls:
- Leaving
_1,_2names in “real” code. Fine for a 30-second demo; painful in joins and SQL views. - Building huge tables with
createDataFrameon the driver. Driver OOM risk — preferspark.readfor large data. - Mixing positional Rows with a mismatched explicit schema length. Field count must align.
Exam note: Be ready to write StructType/StructField, use withColumn/withColumnRenamed/drop, and explain why immutability forces reassignment. Next: filter rows, cast types, limit, and sort (10.3).
Real-world domain connection: Lakehouse jobs typically pair Parquet/ORC reads with checked-in schema definitions (or metastore tables) so evolving JSON dumps cannot silently change types overnight — the same production advice as avoiding inference on multi-TB scans.
10.3 Data Filtering, Type Casting, Limiting, and Sorting
10.3.1 Row Filtering with filter() and where()
Hook: Most analytics questions start as “only keep the rows where …” — in Spark that is a boolean predicate on columns, expressed either as Column algebra or as a SQL string.
filter() and where() are synonyms in PySpark. Prefer where if you think in SQL; prefer filter if you think in pandas-style functional APIs. Both keep rows for which the predicate is true.
# Filtering rows where count is greater than 6 using col()
df_filtered = df_flights.filter(col("count") > 6)
# Equivalent filtering using where() with SQL string expression
df_filtered_where = df_flights.where("count > 200")
# Combining multiple logical predicates
df_multi_filter = df_flights.where(
(col("count") > 100) & (col("ORIGIN_COUNTRY_NAME") != col("DEST_COUNTRY_NAME"))
)
Combine Column predicates with & (and), | (or), and ~ (not). Parentheses matter because Python operator precedence is not SQL precedence.
Worked example — keep busy international routes (filter half).
Suppose three rows:
| ORIGIN | DEST | count |
|---|---|---|
| India | USA | 150 |
| India | India | 80 |
| USA | Canada | 220 |
filter(col("count") > 6)keeps all three (all counts exceed 6).where("count > 200")keeps only USA→Canada (220).(col("count") > 100) & (origin != dest)keeps India→USA and USA→Canada; drops the domestic India→India row even though its count is 80.
Sense-check: row counts after each filter must be \(\le\) the previous DataFrame’s count.
Q: Why does df.where("count > 100") work while df.where("count") throws a parsing error? A: "count > 100" is a valid SQL boolean predicate. "count" alone is not a boolean expression — the SQL parser does not treat that string as “the truth value of column count.” To filter on a Column object, use col("count") inside a comparison (or another boolean expression), not a bare name string.
Pitfalls:
- Using Python
and/orbetween Columns. Use&/|and wrap comparisons in parentheses. - Passing a non-boolean SQL fragment to
where. Bare column names as strings fail. - Filtering after an expensive wide transform when you could filter before — push predicates early when logically equivalent.
10.3.2 Strict Type Casting with cast()
Intuition + Analogy: Some databases quietly turn "42" into the number 42. Spark is stricter — like a ticket scanner that refuses a photo of a ticket unless you convert it to the expected barcode format with an explicit cast.
Numeric work on string columns needs an explicit .cast(...). You may pass a type object (IntegerType()) or a type name string ("string", "double").
Worked example — cast count both ways (cast half).
from pyspark.sql.types import IntegerType
# Explicitly cast string column 'count_str' to IntegerType
df_casted = df_flights.select(
col("count").cast(IntegerType()).alias("count_int"),
col("count").cast("string").alias("count_str")
)
count_intis integral for arithmetic and aggregations.count_stris textual (useful for concatenation or export).- A dirty value like
"N/A"cast toIntegerTypebecomesnullinstead of crashing the job.
Sense-check: printSchema on df_casted shows IntegerType and StringType for the two aliases.
Scope: Silent nulls on failed casts can shrink sums and averages without raising. Validate with isNull counts when cleaning messy CSVs.
10.3.3 Row Truncation with limit() vs. show()
| API | Kind | Returns | Use |
|---|---|---|---|
limit(n) |
Transformation | New DataFrame (≤ n rows) | Chain more transforms |
show(n) |
Action | None (prints to console) |
Quick peek |
# limit(3) returns a new DataFrame object with 3 rows
df_top3 = df_flights.limit(3)
# show(3) executes the plan and prints formatted output to console
df_flights.show(3)
Pitfall: Writing df_top = df.show(3) sets df_top to None. Use limit when you need a DataFrame.
10.3.4 Deduplication with distinct()
distinct() keeps unique full-row combinations (after any prior select).
# Get distinct origin countries
df_unique_origins = df_flights.select("ORIGIN_COUNTRY_NAME").distinct()
print("Total raw rows:", df_flights.count()) # e.g., 256
print("Unique origin count:", df_unique_origins.count()) # e.g., 125
Sense-check on the lecture numbers: If the raw frame has 256 rows and 125 distinct origins, many origins appear on multiple routes. distinct after projecting only ORIGIN_COUNTRY_NAME collapses those duplicates.
distinct is a wide transformation (shuffle) in general — expensive on huge frames; prefer keyed aggregations when you only need uniqueness for metrics.
10.3.5 Sorting and Ordering with sort() and orderBy()
sort() and orderBy() behave the same. Control direction with asc / desc helpers or .asc() / .desc() on Columns.
from pyspark.sql.functions import asc, desc
# Sort by count in descending order
df_sorted_desc = df_flights.orderBy(desc("count"))
# Multi-column sorting: count descending, origin country ascending
df_multi_sort = df_flights.sort(
col("count").desc(),
col("ORIGIN_COUNTRY_NAME").asc()
)
# Find top destination with maximum flights
df_flights.orderBy(expr("count").desc()).show(1)
Global order requires a shuffle (or equivalent exchange). Sorting only to show(1) still plans an ordered limit — fine for demos; for production “top-k,” consider whether a partial aggregation already answers the question cheaper.
Pitfalls:
- Assuming partition-local order equals global order after a narrow map.
- Sorting wide rows before dropping columns — project first when possible.
- Confusing
limitwithoutorderBy—limit(n)alone is not “top n by metric.”
Recap + Bridge: Filter with boolean predicates, cast explicitly, use limit (not show) to truncate DataFrames, distinct for uniqueness, and orderBy/sort for global order. Next: summarize many rows with groupBy and aggregations (10.4).
Real-world domain connection: Retail and flight-analytics notebooks typically filter bad rows, cast price/quantity columns, then sort for “top country” dashboards — the same building blocks before grouped KPIs in 10.4.
10.4 Data Grouping and Aggregation
10.4.1 Full-Table vs. Grouped Aggregations
Hook: Sometimes you need one number for the whole table (“how many rows?”). Sometimes you need one number per category (“how many invoices per country?”). Spark draws that line between full-table aggregations and groupBy + agg.
Intuition + Analogy: A full-table count() is like totaling every receipt in a single cash drawer. A groupBy("Country") aggregation is like sorting receipts into country-labeled folders first, then totaling each folder. The folders are the grouping keys; the totals are aggregate functions.
Full-table aggregation reduces the entire DataFrame to summary values (often one row of metrics, or a scalar action like count()).
Grouped aggregation partitions rows by key columns, then reduces each partition with functions such as count, sum, avg, min, and max.
Procedural spine for groupBy:
- Purpose: Compute per-key metrics without writing manual shuffle code.
- Inputs: A DataFrame, grouping column(s), and aggregate expressions.
- Outputs: A DataFrame with grouping keys plus metric columns.
- Cluster steps: Local partial aggregates → shuffle by key → final aggregates on each key’s home partition.
# Compute total record count across dataset
total_records = df_flights.count()
from pyspark.sql.functions import sum, avg, max, min, count
# Group flights by origin country and calculate total flight count
df_grouped_count = df_flights.groupBy("ORIGIN_COUNTRY_NAME").count()
# Group online retail transactions by Country and compute multiple summary metrics
df_retail_summary = df_retail.groupBy("Country").agg(
count("InvoiceNo").alias("total_invoices"),
sum("Quantity").alias("total_quantity"),
avg("UnitPrice").alias("avg_unit_price"),
max("Quantity").alias("max_single_order")
)
Worked example — retail KPIs by country.
Tiny extract of df_retail:
| Country | InvoiceNo | Quantity | UnitPrice |
|---|---|---|---|
| United Kingdom | A1 | 10 | 2.0 |
| United Kingdom | A2 | 5 | 4.0 |
| France | B1 | 8 | 3.0 |
| France | B2 | 12 | 3.0 |
After groupBy("Country").agg(...):
| Country | total_invoices | total_quantity | avg_unit_price | max_single_order |
|---|---|---|---|---|
| United Kingdom | 2 | 15 | 3.0 | 10 |
| France | 2 | 20 | 3.0 | 12 |
Arithmetic check:
- UK quantity \(10+5=15\); France \(8+12=20\).
- UK average unit price \((2.0+4.0)/2=3.0\); France \((3.0+3.0)/2=3.0\).
- Max single order: UK 10, France 12.
Sense-check: sum(total_invoices) across countries equals the number of input rows when each row is one invoice line counted once via count("InvoiceNo") on this toy table.
When groupBy runs, Spark hashes/partitions by key, may aggregate partially on the map side, shuffles so identical keys co-locate, then finishes reductions. That shuffle is why unselective grouping on high-cardinality keys can dominate job time — the same wide-transform cost story as joins (10.5).
Visual intuition: draw countries on the x-axis and total_quantity on the y-axis as a bar chart; each bar is one group’s reducer output. Tall bars are heavy keys; many tiny bars mean high cardinality and more shuffle groups.
Scope / assumptions:
- Assumption: Aggregate functions match column types (
sum/avgon numerics). - Breaks when: You
groupBya near-unique key (almost one row per group) — you pay shuffle cost for little compression. - Null keys: Rows with null grouping keys form their own group; do not assume they disappear.
Pitfalls:
- Calling
groupBywithout an aggregation and expecting a normal DataFrame of rows — you need.agg(...)/.count()etc. - Shadowing Python builtins by writing
from pyspark.sql.functions import sum, max, minthen using Pythonsum(...)on a list later in the same scope. - Forgetting aliases — default names like
sum(Quantity)are awkward in later SQL views.
Recap + Bridge: Full-table metrics collapse everything; groupBy + agg emit per-key metrics after a shuffle-friendly partial aggregate pattern. Next we combine two tables with relational joins and see how shuffle vs broadcast joins implement that on the cluster (10.5).
Real-world domain connection: Country-level invoice dashboards in retail ETL are textbook Spark SQL/DataFrame workloads — the same grouped aggregates appear again as SQL GROUP BY once temporary views are registered (10.6).
10.5 Relational Joins and Distributed Execution Mechanics
10.5.1 Relational Join Types and Behavior
Hook: Filtering and grouping reshape one table. Joins answer questions that live across two tables — “which students have a known graduate program?” — and Spark gives seven join flavors with very different row- and column-level outcomes.
Intuition + Analogy: Think of person as a student roster and graduate_program as a school catalog. An inner join is the mixer that only keeps students whose program ID appears in the catalog and glues school columns onto those students. A left semi join is a bouncer: it only checks the guest list and lets matching students through — it does not hand them a school brochure (no right-side columns). A left anti join is the bouncer for people not on the list.
Demo tables:
person— columnsid,name,graduate_program,spark_status
(0, "Gita", 0, [100, 200])(1, "Rita", 1, [500])(2, "Meeta", 1, [600])(3, "Ganesh", 3, [300, 400])— program3is missing from the catalog
graduate_program— columnsid,school,degree
(0, "IIT Madras", "M.Tech")(1, "IISc Bangalore", "M.Tech")(2, "BITS Pilani", "M.Tech")— no student references program2
Generic PySpark join form:
# Generic join invocation syntax
df_joined = df_left.join(df_right, join_expression, join_type)
# Join predicate matching graduate_program ID to school ID
join_expr = person["graduate_program"] == graduate_program["id"]
Matching keys: Gita↔0, Rita↔1, Meeta↔1. Unmatched: Ganesh (left orphan), BITS Pilani (right orphan).
1. Inner Join ("inner")
Keeps rows whose keys exist in both sides; drops orphans; output includes columns from both tables.
person.join(graduate_program, join_expr, "inner").show()
Result people: Gita (IIT Madras), Rita (IISc Bangalore), Meeta (IISc Bangalore). Ganesh and BITS Pilani are out; no null-padded unmatched rows.
2. Outer / Full Outer Join ("outer" / "full_outer")
Keeps all rows from both sides; fills missing opposite-side attributes with null.
person.join(graduate_program, join_expr, "outer").show()
Result: Gita, Rita, Meeta, Ganesh (null school fields), and BITS Pilani (null student fields).
3. Left Outer Join ("left" / "left_outer")
Keeps all left rows; right attributes null when unmatched; drops right-only rows.
person.join(graduate_program, join_expr, "left").show()
Result: Gita, Rita, Meeta, Ganesh (null school). BITS Pilani excluded.
4. Right Outer Join ("right" / "right_outer")
Keeps all right rows; left attributes null when unmatched; drops left-only rows.
person.join(graduate_program, join_expr, "right").show()
Result: Gita, Rita, Meeta, and BITS Pilani (null student fields). Ganesh excluded.
5. Left Semi Join ("left_semi")
Keeps left rows that have a match on the right. Output schema = left columns only.
person.join(graduate_program, join_expr, "left_semi").show()
Result: Gita, Rita, Meeta with columns [id, name, graduate_program, spark_status] only — like an existence filter, not a column merge.
6. Left Anti Join ("left_anti")
Keeps left rows that lack a right match. Output schema = left columns only.
person.join(graduate_program, join_expr, "left_anti").show()
Result: Only Ganesh (program key 3 missing).
7. Cross Join ("cross")
Cartesian product: every left row with every right row.
person.crossJoin(graduate_program).show()
For sizes \(M\) and \(N\), output size is \(M \times N\). Here \(4 \times 3 = 12\) rows.
Worked checklist — who survives each join?
| Join type | Gita | Rita | Meeta | Ganesh | BITS Pilani row | Right cols in output? |
|---|---|---|---|---|---|---|
| inner | yes | yes | yes | no | no | yes |
| full outer | yes | yes | yes | yes (null school) | yes (null person) | yes |
| left | yes | yes | yes | yes (null school) | no | yes |
| right | yes | yes | yes | no | yes (null person) | yes |
| left_semi | yes | yes | yes | no | n/a | no |
| left_anti | no | no | no | yes | n/a | no |
| cross | all 4 people × 3 schools = 12 rows | yes |
Sense-check: semi/anti never invent right-side fields; outer joins are the ones that introduce null padding for orphans.
Q: How do left_semi and left_anti joins differ from standard inner and left outer joins about output schema? A: Inner and left outer joins combine columns from both tables. left_semi and left_anti act as relational filters on key existence and project only left-table columns.
| Goal | Prefer |
|---|---|
| Matched rows with columns from both sides | inner |
| Keep all left rows + optional right attrs | left / left_outer |
| Keep all right rows + optional left attrs | right / right_outer |
| Keep everything, null-pad gaps | outer |
| Filter left to keys that exist on right | left_semi |
| Filter left to keys missing on right | left_anti |
| All pairs (rare; expensive) | cross |
When to pick which: Use semi/anti when you only need existence logic; use outer forms when unmatched entities must remain visible; avoid cross joins unless you truly need \(M \times N\) pairs.
Pitfalls:
- Expecting school name columns from
left_semi. They will not appear — useinner/leftif you need them. - Forgetting duplicate join keys can multiply rows (one-to-many).
- Running
crossJoinon large frames — quadratic blow-up.
Recap + Bridge: Seven join types differ in which orphans survive and whether right columns are projected. How Spark physically runs a join is a separate decision — shuffle versus broadcast (10.5.2).
10.5.2 Distributed Execution Strategies: Shuffle Join vs. Broadcast Join
Hook: The same logical inner join can be either a network-heavy shuffle or a fast local hash probe. Catalyst chooses a physical strategy; .explain() shows which one you got.
# Inspect physical execution plan for join operation
person.join(graduate_program, join_expr, "inner").explain()
+-----------------------------------------------------------------------------------+
| Distributed Join Strategies |
| |
| 1. Shuffle Join (Big-Table to Big-Table) |
| Worker Node 1 [Keys 0,1] ------------Shuffle Network------------> Executor A |
| Worker Node 2 [Keys 0,1] ------------Shuffle Network------------> Executor B |
| |
| 2. Broadcast Join (Big-Table to Small-Table) |
| Driver Node ---> Broadcasts Small Table Once ---> Worker Node 1 (Local Join) |
| ---> Worker Node 2 (Local Join) |
+-----------------------------------------------------------------------------------+
Purpose: Co-locate matching keys so a join can execute.
Shuffle join (sort-merge / shuffle hash):
- When: Both sides are large; neither fits comfortably as a broadcast.
- How: Shuffle both tables by join key so equal keys share a worker; then sort-merge or hash-join locally.
- Cost: Heavy network I/O, serialization, and latency.
Broadcast join (broadcast hash join):
- When: One side is small (default under about 10 MB via
spark.sql.autoBroadcastJoinThreshold). - How: Driver broadcasts the small table once to every worker; each worker hash-joins its local big-table partitions.
- Cost: One-time broadcast of the small side; no shuffle of the large side.
Teaching moment: Broadcast join sends the small table once to all nodes and joins locally, avoiding a full shuffle of the big table — the lecture’s core performance intuition for dimension lookups.
Trace — student table (tiny) joined to a huge events table.
graduate_programis a few KB → within broadcast threshold.- Catalyst picks BroadcastHashJoin;
.explain()shows a broadcast of the small side. - Each executor keeps a local hash map of programs and probes events partitions in place.
- If both tables were multi-terabyte facts with no small side, explain would show Exchange / SortMergeJoin-style shuffle instead.
Sense-check: if the “small” table secretly grew to gigabytes, broadcast can OOM executors — thresholds exist for a reason.
Scope:
- Assumption: Broadcast side fits in executor memory on every node.
- Breaks when: Skewed keys overload one reducer in a shuffle join, or auto-broadcast fires on a table that is larger than you think because statistics are stale.
Pitfalls:
- Reading
.explain()and ignoring Broadcast vs Exchange lines. - Forcing broadcast of a large dimension → executor OOM.
- Assuming join type (left/inner) equals physical strategy — logical type and physical strategy are independent axes.
Exam note: Memorize the seven join outcomes on the person/program example, and explain shuffle vs broadcast in one sentence each. Next: run the same logic through Spark SQL with plan parity (10.6).
Real-world domain connection: Nightly warehouse ETL joins billion-row fact tables to small store/currency dimensions with broadcast joins to cut shuffle — expanded in 10.8.2.
10.6 Spark SQL Interoperability and In-Memory Execution
10.6.1 Spark SQL Architecture and Workflow
Hook: If your teammates already write SQL, must they learn the entire PySpark DSL before they can query a lake? Spark SQL says no — register a view, then run SELECT through the same session and optimizer.
Intuition + Analogy: A temporary view is a nameplate on a DataFrame: the data still lives in the distributed plan, but SQL can refer to it like a table. spark.sql does not copy the lake into a separate SQL engine; it parses SQL into the same Catalyst tree you build with the DataFrame API.
Course materials describe Spark SQL as the structured-data component of the Spark stack: DataFrames (historically SchemaRDDs), SQL/Hive contexts (now under SparkSession), JDBC/ODBC connectivity, and support for sources such as Hive, JSON, and Parquet — with Catalyst optimization and Tungsten-style execution underneath.
Procedural workflow (four steps):
- Get
SparkSession—sparkmust exist. - Ingest or create a DataFrame — files, JDBC, or
createDataFrame. - Register a temporary view —
createOrReplaceTempView("view_name")(legacy:registerTempTable). - Run SQL —
spark.sql("SELECT ...")returns another DataFrame.
Worked example — retail country rollup in SQL.
# 1. Ingest dataset
df_retail = spark.read.csv("online_retail.csv", header=True, inferSchema=True)
# 2. Register temporary view
df_retail.createOrReplaceTempView("invoice_table")
# 3. Execute SQL query
df_sql_result = spark.sql("""
SELECT
Country,
AVG(Quantity) as avg_qty,
SUM(Quantity) as total_qty
FROM invoice_table
GROUP BY Country
HAVING SUM(Quantity) > 1000
ORDER BY total_qty DESC
""")
# 4. Display results (spark.sql returns a DataFrame object)
df_sql_result.show(5)
Walkthrough:
- CSV becomes
df_retail. invoice_tableis a session-scoped name for that plan.- SQL expresses the same grouped metrics as 10.4’s
groupBy().agg(...), plusHAVINGandORDER BY. df_sql_resultis a DataFrame — you can.filter,.write, or register yet another view.
Sense-check: countries with SUM(Quantity) <= 1000 must be absent after HAVING; row count of the SQL result equals the number of surviving groups, not the raw invoice count.
Pitfalls:
- Forgetting to register the view → “table or view not found.”
- Expecting temp views to survive across spark-submit jobs — they are session-scoped, not a metastore catalog (unless you create permanent tables).
- Treating
spark.sqlas returning printed text — it returns a DataFrame; useshowto print.
10.6.2 Relational Joins via Spark SQL
Once views exist, multi-table joins are ordinary SQL:
# Register temporary tables for person and graduate_program DataFrames
person.createOrReplaceTempView("person_table")
graduate_program.createOrReplaceTempView("graduate_table")
# Execute standard relational join in SQL
df_joined_sql = spark.sql("""
SELECT
p.id as student_id,
p.name,
g.school,
g.degree
FROM person_table p
JOIN graduate_table g ON p.graduate_program = g.id
""")
df_joined_sql.show()
This SQL JOIN matches the DataFrame "inner" join on join_expr from 10.5.1 (Gita, Rita, Meeta with school names). Outer/semi/anti forms are also available via SQL keywords where supported by Spark’s dialect.
Recap: Same person/program example, either API — choose the syntax your reviewers read most easily.
10.6.3 Plan Parity Between DataFrame API and Spark SQL
Both the DataFrame API and spark.sql(...) parse into logical plans that Catalyst optimizes into physical plans and RDD execution:
DataFrames API (PySpark/Scala) \
==> Logical Plan ==> Catalyst Optimizer ==> RDD Execution
Raw SQL Queries (spark.sql) /
There is no inherent performance penalty for SQL versus DSL for the same relational operators. Pick the interface for clarity; trust Catalyst for the plan.
Trace — parity check.
- Build
df_retail.groupBy("Country").agg(sum("Quantity").alias("total_qty")). - Run the equivalent
SELECT Country, SUM(Quantity) ... GROUP BY Countryviaspark.sql. - Call
.explain()on both results — join/aggregate/exchange shapes should match for the same logic.
Sense-check: if one path wraps a Python UDF and the other stays in pure SQL expressions, plans (and speeds) can diverge — parity assumes comparable expressions.
Scope: Plan parity holds for relational work expressed in both APIs. Opaque UDFs, different caching, or different configuration can still change runtime.
Exam note: Know the four-step SQL workflow, that spark.sql returns a DataFrame, and that Catalyst gives plan parity with the DataFrame API. Appendices next cover assignment submission norms and industry uses of these ideas.
Real-world domain connection: Mixed teams put engineers on PySpark ETL, analysts on Spark SQL dashboards, and still share one optimized runtime — the multi-language story in 10.8.1.
10.7 Exam Guidance Summary
10.7.1 Assignment Submission and Video Demo Requirements
Exam note: Assignments such as Assignment 2 expect both code artifacts and structured execution proof (PPT or PDF) plus a short video demo — not code alone.
Document structure — include screenshots that verify:
- Local environment connection and setup commands.
- Program source sections that show the required algorithms.
- Console commands and intermediate outputs.
- Input parameters and final console outputs.
Observer clarity: Use clean, annotated layouts. Do not submit unformatted wall-of-text dumps.
Video demo format: Keep videos short and focused on command execution and runtime outputs (directory navigation, Sqoop ingestion, Hive queries, MapReduce results on the prompt). Avoid long unedited recordings.
Infrastructure guidance: Native Hadoop 3.x on Windows 10 often fails on WinUtils path issues. Prefer Hadoop 2.7/2.6, Linux/Ubuntu, or managed clouds (AWS EMR, Databricks).
Evaluation weightage: Command execution typically carries about 40%–50% of credit; wiring an end-to-end pipeline (for example Sqoop → Hive → MapReduce analytics) carries about 50%–60%.
Pitfalls:
- Submitting code without screenshot proof of runs.
- Recording a long desktop video with no clear command outcomes.
- Burning hours on a broken Windows Hadoop 3 install instead of using a supported environment.
10.8 Key Industry Applications
10.8.1 Multi-Language Enterprise Data Analytics
Enterprise analytics platforms use Spark DataFrames so mixed teams share one optimized runtime. Data engineers often build ETL in Scala or Java for tight JVM integration; data scientists use PySpark; business analysts query the same lakes with Spark SQL. All three surfaces compile through Catalyst into optimized RDD execution, which is why language choice is mostly about team skill and packaging — not a different optimizer. This unified architecture is a central theme in Spark: The Definitive Guide and matches the plan-parity story in 10.6.3.
Recap: One lake, three APIs, one Catalyst pipeline — operational consistency without forcing every role onto one language.
10.8.2 Broadcast Join Optimization in Large-Scale ETL
In production data lakes, joining multi-billion-row transaction (fact) tables to small dimension tables — store locations, currency rates, product status codes — via shuffle joins floods the network. Broadcasting the small dimension to every worker lets each node join locally against its fact partitions, cutting shuffle I/O and speeding nightly batch ETL, often by orders of magnitude. That is the same broadcast-vs-shuffle mechanism introduced in 10.5.2, applied at warehouse scale.
Recap + Bridge: Prefer broadcast when dimensions fit the threshold; reserve shuffle joins for large-to-large matches. Together with DataFrames, Catalyst, and Spark SQL, these patterns are the practical core of this lecture.
BDA Lecture 10 notes
Sections Breakdown
Why schema-full DataFrames replaced bare RDDs, how Catalyst and CBO rewrite lazy logical plans, and SparkSession vs SparkContext entry points.
Reading CSV/JSON/Parquet with spark.read, declaring StructType schemas, projecting and reshaping columns, and building in-memory frames from Row objects.
Row predicates with filter/where, explicit cast(), limit vs show, distinct, and sort/orderBy.
Full-table vs groupBy+agg summaries, shuffle mechanics, and a worked retail KPIs-by-country example.
Seven join types on the person/graduate_program demo, and how shuffle vs broadcast joins execute on a cluster.
Four-step temp-view workflow, SQL joins, and plan parity between the DataFrame API and spark.sql.
Assignment submission norms: code artifacts, screenshot proof, and a short execution video.
Multi-language enterprise analytics on one Catalyst runtime and broadcast-join optimization in large-scale ETL.
Exam Revision Notes
Below is the distilled, exam-ready core. Every entry comes from the full explanation above. Use this section for rapid review; return to the main notes when a point needs more context.
History, Architecture, and Catalyst Optimizer of Spark DataFrames
Must-know: DataFrames add named schemas so Catalyst can optimize; they compile to RDD plans; SparkSession unifies entry points.
\[ \text{DataFrame Operation} \xrightarrow{\text{Compilation}} \text{Logical Plan} \xrightarrow{\text{Catalyst Optimizer}} \text{Optimized RDD Transformations \& Actions} \]
⚠️ Top pitfall: Treating DataFrames as eager local pandas objects or believing they replace RDDs entirely.
Self-check: Why can Catalyst optimize DataFrame code better than opaque RDD lambdas?
Connects to: 10.2, 10.5, 10.6
Ingestion, Schema Definition, and Column/Row Operations
Must-know: Explicit schemas beat inference at scale; withColumn returns a new DataFrame — assign it; unnamed Rows become _1,_2,_3.
⚠️ Top pitfall: Calling withColumn without assigning and assuming the original DataFrame changed.
Self-check: What three arguments does StructField take?
Connects to: 10.1, 10.3
Data Filtering, Type Casting, Limiting, and Sorting
Must-know: where needs a boolean SQL expression; cast failures become null; limit returns a DataFrame, show does not.
⚠️ Top pitfall: Assigning df.show(n) to a variable or using Python and/or between Columns.
Self-check: Why does where("count") fail while where("count > 100") works?
Connects to: 10.2, 10.4
Data Grouping and Aggregation
Must-know: groupBy shuffles by key after optional map-side partial aggregates; use agg for multiple metrics.
⚠️ Top pitfall: Grouping without aggregation, or grouping on near-unique keys and wondering why jobs are slow.
Self-check: What cluster steps occur when groupBy runs?
Connects to: 10.3, 10.5, 10.6
Relational Joins and Distributed Execution Mechanics
Must-know: Semi/anti keep left schema only; broadcast avoids shuffling the large side when one table is small.
\[ M \times N \quad \text{(cross join cardinality)} \]
⚠️ Top pitfall: Expecting right-table columns from left_semi, or broadcasting an oversized dimension.
Self-check: Which people appear in left_anti for the lecture demo tables?
Connects to: 10.1, 10.4, 10.6, 10.8
Spark SQL Interoperability and In-Memory Execution
Must-know: Register a temp view then spark.sql; result is a DataFrame; SQL and DataFrame API share Catalyst plans.
⚠️ Top pitfall: Assuming temp views persist like Hive metastore tables across applications.
Self-check: List the four steps to run a Spark SQL query on a CSV.
Connects to: 10.1, 10.4, 10.5, 10.8
Exam Guidance Summary
Must-know: Submit screenshots + short execution video; pipeline integration outweighs isolated commands slightly.
⚠️ Top pitfall: Native Hadoop 3 on Windows without WinUtils; missing run evidence.
Self-check: Roughly what fraction of credit is end-to-end pipeline integration?
Connects to: 10.2
Key Industry Applications
Must-know: PySpark/Scala/SQL share Catalyst; broadcast small dimensions instead of shuffling huge facts.
⚠️ Top pitfall: Shuffle-joining large facts to tiny dimensions and blaming the cluster size alone.
Self-check: Why can analysts using Spark SQL see similar performance to PySpark ETL for the same join?
Connects to: 10.1, 10.5, 10.6