Programming Spark: key-value operations and PySpark DataFrames, exam-style

After this lesson you will write the hamlet word count with pair RDDs and say why reduceByKey beats groupByKey, and you will answer a past-paper SparkQL exercise — create a DataFrame, add a row, explode, join, group, keep the large totals — with DataFrame methods only, no SQL.

The third notebook of the Spark exercise session explodes the pizza menu into one row per ingredient, then computes a few aggregates per pizza. On the menu, Calzone costs 7.95. Run the cell, and the line for Calzone reports a "Sum Price" of 23.84999942779541.

Nothing is broken. Spark answered exactly the question the code asks — just not the one its author had in mind. Keep the number in mind: by the end of the lesson you will know where the 23.85 comes from, where the long tail of digits comes from, and how to avoid the same slip in the exam's "total … for each …" questions.

RDDs by hand: create, transform, act

The exercise session opens with one sentence: Apache Spark "is a multi-language, unified analytics engine for big data processing, with built-in modules for executing data engineering, data science, and machine learning tasks on single-node machines or clusters" (p. 1712). An engine, not a store: the lecture's "Importers for most data formats and from most database technology" (p. 1227) reach Cassandra, Elasticsearch, MongoDB, Neo4j, Redis and CSV, JSON, Avro or Parquet files, and the session draws Spark at the hub of streaming, ETL, machine-learning, analysis, graph and data-lake tools (p. 1716; p. 1717 sorts the tools by kind). Spark reads the data where it lives, computes, and writes the result back.

Every program starts from an entry point. The lecture calls it the SparkContext: "Main entry point to Spark functionality", "Available in shell as variable sc", and "In standalone programs, you'd make your own" (p. 1228). Since Spark 2.0 you build a SparkSession — the first line of every session notebook, and a safe first line for an exam answer — and reach the context through it:

# the entry point (E08, p. 1726); sc is the SparkContext of p. 1228
from pyspark.sql import SparkSession

spark = SparkSession.builder.appName("SMBUD").getOrCreate()
sc = spark.sparkContext

The session spells the name RDD out (p. 1720): Resilient, "Fault-tolerant"; Distributed, "Computed across multiple nodes"; Dataset, a "Collection of partitioned data". Its next slide lists four characteristics (p. 1721): RDDs "Are immutable", "Work with lazy evaluation", "Are divided into partitions" and "Are fault-tolerant, which is achieved through lineage graphs" — lesson 38 shows how each works.

An RDD comes from one of three sources (p. 1229): a Python collection, cut into partitions by parallelize; a text file read by textFile, one element per line; or any existing Hadoop InputFormat. The path chooses the file system — "file.txt" is local, "hdfs://namenode:9000/path/file" is HDFS, a glob such as "directory/*.txt" reads many files — so the same program runs on a laptop and on a cluster.

# two RDDs: a Python list, and the lines of a file
nums = sc.parallelize([1, 2, 3])
lines = sc.textFile("hamlet.txt")

Transformations build, actions run

Three transformations do most of the work (p. 1230). map passes each element through a function: one in, one out. filter keeps the elements for which a predicate is true. flatMap maps each element to zero or more elements and flattens the result: range(x) is the sequence 0, 1, …, x − 1, so 1, 2 and 3 become [0], [0, 1] and [0, 1, 2] — six elements in all.

# the three basic transformations of p. 1230, with what they hold
squares = nums.map(lambda x: x*x)               # [1, 4, 9]
even = squares.filter(lambda x: x % 2 == 0)     # [4]
nums.flatMap(lambda x: range(x))                # [0, 0, 1, 0, 1, 2]

None of these lines computes anything. A transformation returns a new RDD and records how to build it; an action makes Spark run the recorded steps and either return a value to the driver program or write the result out (p. 1231):

# the basic actions of p. 1231: only these make Spark work
nums.collect()                          # [1, 2, 3], to the driver
nums.take(2)                            # [1, 2]
nums.count()                            # 3
nums.reduce(lambda x, y: x + y)         # 6
nums.saveAsTextFile("hdfs://file.txt")  # part files, not one file

collect is fine for three numbers and fatal for a terabyte: it copies the whole dataset into the memory of the driver, while take(n) fetches only n elements. saveAsTextFile writes a directory of part files, one per partition. The exercise session sorts the operations into two lists (p. 1722):

Table 1. The RDD operations of the session's slide (p. 1722), in its two lists.
KindOperations on the slideWhat a call returns
Transformations: lazymap(), filter(), flatMap(), distinct(), union(), reduceByKey(), groupByKey(), sortBy()a new RDD; nothing runs yet
Actions: eagercollect(), count(), take(n), reduce(), saveAsTextFile(), first()a value for the driver, or files; the job runs

"reduce and reduceByKey sound alike, so they behave alike." No. reduce() is an action: it returns one value to the driver. reduceByKey() is a transformation: it returns a new RDD of pairs, and nothing runs until an action. The slide puts them in opposite lists. The slide of p. 1231 also says "Merge elements with an associative function"; Spark's documentation asks for a commutative one too, because Spark reduces each partition on its own and then merges the partial results, so neither the grouping nor the order of the merges is up to you. Subtraction is neither: on PySpark 4.2, sc.parallelize([1, 2, 3, 4], 1).reduce(lambda x, y: x - y) gives −8, ((1 − 2) − 3) − 4, and with 2 partitions it gives 0, (1 − 2) − (3 − 4).

Pairs: reduceByKey, the word count and the shuffle

"Spark's "distributed reduce" transformations operate on RDDs of key-value pairs" (p. 1232). Nothing has to be declared: in Python a pair is a 2-tuple, pair[0] is the key and pair[1] the value (Scala writes pair._1 and pair._2, Java builds a Tuple2), and an RDD whose elements are pairs gets the …ByKey operations. The slide's example has three pets (p. 1233):

# one value per key: merge the values with the function
pets = sc.parallelize([("cat", 1), ("dog", 1), ("cat", 2)])
pets.reduceByKey(lambda x, y: x + y)    # [('cat', 3), ('dog', 1)]
# all the values of a key; the pairs sorted by key
pets.groupByKey()     # [('cat', [1, 2]), ('dog', [1])]
pets.sortByKey()      # [('cat', 1), ('cat', 2), ('dog', 1)]

reduceByKey merges all the values of one key with the function; groupByKey only collects them, one list per key; sortByKey orders the pairs by key. Under the code the slide adds the sentence that carries the "why": "reduceByKey also automatically implements combiners on the map side". A combiner is the trick of the MapReduce word count of lesson 30: each machine first merges the values of the same key that it holds, and only the partial results travel. groupByKey cannot do that, because it must deliver every single value. Same answer, different traffic — Fig. 1.

partition 1: the two lines (to, 1) (be, 1) (or, 1) (not, 1) (to, 1) (be, 1) 3 values 3 values 6 in all reducer 1 reducer 2 (be, 2) (not, 1) (or, 1) (to, 2)

Before: groupByKey — every pair travels

partition 1, after the combiner (to, 2) (be, 2) (or, 1) (not, 1) 6 pairs merged into 4 2 values 2 values 4 in all reducer 1 reducer 2 (be, 2) (not, 1) (or, 1) (to, 2)

After: reduceByKey — combined on the map side

Fig. 1. Both of the slide's lines in one partition, two reducers, words grouped as on p. 1234 (the slide gives each line its own partition, and then the combiner has nothing to merge: try both in the widget below). Same counts both ways; groupByKey sends 6 values through the network, reduceByKey 4 — it first adds the two (to, 1) and the two (be, 1). On a real block of text, with thousands of (the, 1), the saving is huge.

The word count of lesson 30 took a page of Java. Here it is three chained calls on the lines of hamlet.txt (p. 1234):

# the word count of p. 1234: three transformations, then an action
lines = sc.textFile("hamlet.txt")
counts = lines.flatMap(lambda line: line.split(" ")) \
              .map(lambda word: (word, 1)) \
              .reduceByKey(lambda x, y: x + y)
counts.collect()
# [('be', 2), ('not', 1), ('or', 1), ('to', 2)] on the slide's lines

Read it as a flow of data. flatMap turns each line into its words ("to be or" gives to, be, or); map turns each word into the pair (word, 1); reduceByKey is the shuffle plus the reduce: each partition's partial count of a word travels to the machine responsible for that word — a hash of the key decides which — and is added up there. On the slide the arrows from the two lines cross: (be, 2) and (not, 1) end on one machine, (or, 1) and (to, 2) on the other. Only collect() starts the job.

The slide breaks the chain over three lines with nothing at the line ends; typed like that, Python takes the first line as the whole statement and rejects the next. End each line with a backslash, as above, or wrap the whole chain in parentheses — the form of the exam answers below.

Two RDDs of pairs can be joined on their keys (p. 1235). join returns one pair per matching combination, the two values side by side; cogroup returns one pair per key, with the list of values from each side:

# join: one output pair per matching (left value, right value)
visits = sc.parallelize([("index.html", "1.2.3.4"),
                         ("about.html", "3.4.5.6"),
                         ("index.html", "1.3.3.1")])
pageNames = sc.parallelize([("index.html", "Home"),
                            ("about.html", "About")])
visits.join(pageNames)
# ('index.html', ('1.2.3.4', 'Home'))
# ('index.html', ('1.3.3.1', 'Home'))
# ('about.html', ('3.4.5.6', 'About'))
# cogroup: one output pair per key, with both lists of values
visits.cogroup(pageNames)
# ('index.html', (['1.2.3.4', '1.3.3.1'], ['Home']))
# ('about.html', (['3.4.5.6'], ['About']))

Two visits of index.html and one name give two join rows but one cogroup row. Finally, "All the pair RDD operations take an optional second parameter for number of tasks" (p. 1236): the result is cut into that many partitions, each worked by one reduce task. Too few and the cluster idles; too many and the scheduling and the tiny output files cost more than they save.

# 5 reduce tasks: the result has 5 partitions
words.reduceByKey(lambda x, y: x + y, 5)

Under the hood: stages, and why iterations fly

When an action runs, the DAG scheduler turns the recorded transformations into work for the cluster. The slide lists what it does: "General task graphs", "Automatically pipelines functions", "Data locality aware", "Partitioning aware to avoid shuffles" (p. 1237). Its figure, redrawn as Fig. 2, shows all four.

A B C D E F groupBy map filter join Stage 1 Stage 2 Stage 3 no shuffle shuffle
Fig. 2. The DAG scheduler's figure of p. 1237, redrawn. Squares are partitions; B's are solid because they are cached. Thin crossing lines are wide dependencies — every input partition may feed every output partition, so a shuffle cuts a stage there; thick parallel arrows are narrow ones.

The graph is cut into stages at every shuffle, because a shuffle is a barrier: no partition after it can start before all the partitions before it are done. Inside a stage the narrow operations — each input partition feeds exactly one output partition (lesson 38) — are pipelined: map and filter on C run as one task per partition, in one pass, and D is never stored. Tasks go where their partition already is (data locality, as with HDFS blocks). B already came out of a groupBy, partitioned by key, so the join takes it as it is, and B is cached, so Stage 1 is skipped.

The exercise session says the same in two views (pp. 1723–1724). In the logical view "An RDD is seen as a single dataset", the transformations "form a lineage graph", and "everything is just a plan". In the physical view "An RDD is split into partitions", "Each partition lives on a worker node", is "processed independently and in parallel", and "Fault tolerance is achieved by recomputing only the lost partitions". The API also has more operators than map and reduce (p. 1238): outer joins, cogroup, cross (the Cartesian product, called cartesian in PySpark), zip, sample, fold, and partitionBy, the tool behind "partitioning aware".

Why the bars are so short

Hadoop was born when "RAM was extremely expensive, so Hadoop was designed to rely heavily on hard disk drives (HDDs) for storage and processing" (p. 1713): every MapReduce job reads its input from HDFS and writes its output back. Spark came when RAM had become affordable, and "was designed to leverage RAM for in-memory computation, drastically reducing the time required for iterative tasks such as machine learning and interactive queries" (p. 1715). Between the two, the session's slide on HDFS (p. 1714) has one sentence to correct before you quote it.

Fig. 3 puts the lecture's benchmark numbers on one scale.

Hadoop Spark Spark faster by PageRank, 30 machines 171 s 23 s 7.4× PageRank, 60 machines 80 s 14 s 5.7× K-means clustering 155 s 4.1 s 38× Logistic regression 110 s 0.96 s 115×
Fig. 3. Time per iteration as printed on pp. 1239–1240, drawn on one scale. The speed-ups on the right are computed from the printed numbers. The slide of p. 1240 draws K-means and logistic regression on two different scales; here all four share one, which is why Spark's 0.96 s is a sliver.

K-means and logistic regression — both classic methods of statistics — pass over the same data once per iteration and update a tiny model, centroids or a weight vector. With the data cached in memory, an iteration costs one pass in RAM; as a chain of Hadoop jobs, it costs a read from HDFS, a job start and a write back. PageRank gains less because every iteration still shuffles the ranks across the network. Doubling the machines more than halved Hadoop's time per iteration (171 to 80 s) but cut Spark's only from 23 to 14 s: the shorter an iteration, the larger the share of fixed costs that more machines do not split.

The last benchmark slide is the strongest: "On-Disk Sort Record: Time to sort 100TB" — Hadoop, 2100 machines, 72 minutes; Spark, 207 machines, 23 minutes; "Also sorted 1PB in 4 hours" (p. 1241, from the Daytona GraySort benchmark of sortbenchmark.org). 100 TB does not fit in memory, so RAM is not the whole story: a third of the time on a tenth of the machines is about 30 times more data sorted per machine per minute. That is the engine itself — stages, pipelining, a better shuffle.

The DataFrame: an RDD that knows its columns

The lecture introduces the DataFrame with a dictionary entry (p. 1246):

DataFrame — "1. A distributed collection of rows organized into named columns. 2. An abstraction for selecting, filtering, aggregating and plotting structured data (cf. R, Pandas). 3. Archaic: Previously SchemaRDD (cf. Spark < 1.3)."

Picture a pandas DataFrame whose rows are spread over many machines, that cannot be changed in place, and whose operations wait for an action. The next slide gives the reasons (p. 1247): it is "A distributed collection of rows with the same schema (RDDs suffer from type erasure)"; it "Can be constructed from external data sources or RDDs into essentially an RDD of Row objects"; it "Supports relational operators (e.g. where, groupby) as well as Spark operations"; and it is "Evaluated lazily → unmaterialized logical plan". Type erasure is a Java argument — at run time an RDD of Person objects cannot tell Spark which fields a Person has; in Python, an RDD of tuples has no column names and no types either. A DataFrame carries both, so Spark can check a query, store the rows compactly and optimise the plan before running it.

The session's slide puts it in one line: "DataFrames, on the other hand, are RDDs with an added schema that defines column names and data types". RDDs stay "Best for unstructured data or custom transformations", and "DataFrames are preferred today because they run much faster, use less memory, are easier to write and maintain, and integrate with SQL and other paradigms/systems" (p. 1725). The speed comes from what the schema allows: Spark sees the whole query and optimises it with Catalyst (lesson 40), while a Python lambda in RDD code is a black box it can only call.

The data model is nested (p. 1248): primitive SQL types — boolean, integer, double, decimal, string, date, timestamp — and complex types, "structs, arrays, maps, and unions", with "First class support for complex data types". That is why an exam table can hold a list in a cell: the Hospital row of the paper of 30 Jan 2026 has Heating_Systems = [HS_1, HS2, ...], a column of type array.

A query can be written in two ways (p. 1249): "Relational operations (select, where, join, groupBy) via a DSL", whose "Operators take expression objects" and "build up an abstract syntax tree (AST), which is then optimized by Catalyst" — or, "Alternatively, register as temp SQL table and perform traditional SQL query strings". The slide's example is Scala, where === is column equality; in PySpark it reads:

# female employees per department (p. 1249)
from pyspark.sql import functions as F

(employees
    .join(dept, employees["deptId"] == dept["id"])
    .where(employees["gender"] == "female")
    .groupBy(dept["id"], dept["name"])
    .agg(F.count(employees["name"])))

The count names its table, employees["name"]: if both tables have a column called name, a bare count("name") is ambiguous and Spark refuses it.

DSL: the exam's SparkQL df.where(...) SQL string spark.sql(...) logical plan (an AST) Catalyst then RDDs not allowed in 2026
Fig. 4. Two routes to one plan (p. 1249): the DSL — a chain of DataFrame methods — or a SQL string on a temporary view. Catalyst optimises the plan either way and Spark runs it as RDD operations (lesson 40); the paper of 30 Jan 2026 allows only the first route.

What the papers call "SparkQL" is this API, and the paper of 30 Jan 2026 adds to each query: "Note that you are not allowed to use SQL syntax. Use PySpark instead." So no spark.sql("SELECT …") — and, the safe reading, no SQL expression string inside filter either. The session shows one query in Python, SQL and Java (p. 1726); its PySpark box is the skeleton of every answer:

# the PySpark box of p. 1726: session, data, query, action
from pyspark.sql import SparkSession

spark = SparkSession.builder.appName("PythonExample").getOrCreate()
df = spark.read.csv("hdfs://data/sales.csv", header=True, inferSchema=True)
df.groupBy("region").sum("amount").show()

The SQL box next to it is not quite the same query: it names the total total_sales and sorts it, largest first, while sum("amount") makes a column called sum(amount), in no particular order. The PySpark twin of the SQL box uses agg with alias — the form the exam answers need:

# the SQL box of p. 1726, without SQL
df.groupBy("region").agg(F.sum("amount").alias("total_sales")) \
  .orderBy(F.col("total_sales").desc()).show()

The Java box has a slip of its own: it reads the CSV without inferSchema, so amount stays a string and sum("amount") stops with "amount" is not a numeric column (checked on PySpark 4.2). header=True takes the column names from the first line; inferSchema=True reads the file once more to guess the types.

Above the engine sit the libraries — Spark SQL, which "manages Datasets and DataFrames", Spark Streaming, MLlib and GraphX — and below it a cluster manager that hands out the machines: YARN, Mesos, the Standalone Scheduler or Kubernetes (pp. 1727–1728; Spark 4.0 has since dropped Mesos, so the current documentation lists the other three). The engine serves batch data, "collecting data over a period of time and then processing it all at once", for high throughput, and real-time streams, processed "continuously as it arrives, with low latency": Spark Streaming (the older API) and Structured Streaming (the modern one) group the incoming events into micro-batches and run each with the batch engine (p. 1719).

The use-case slide (p. 1718) adds SQL analytics, exploratory data analysis "also on petabyte-scale datasets", and machine learning developed "on a single computer" and then run, the exact same code, "on large, fault-tolerant clusters with thousands of machines". The session notebooks are the single-computer end: one process on your laptop, .master("local").

The word count, once more, as a DataFrame. split turns each line into an array of words, explode makes one row per element, and groupBy("word").count() does what reduceByKey did:

# the word count with the DataFrame API: no lambdas, no pairs
lines_df = spark.read.text("hamlet.txt")      # one column, "value"
counts_df = (lines_df
    .select(F.explode(F.split(F.col("value"), " ")).alias("word"))
    .groupBy("word")
    .count())
counts_df.show()

Its physical plan, printed by counts_df.explain() on PySpark 4.2, has the same three moves as Fig. 1's right panel: a partial count inside each partition, an exchange of rows by a hash of the word, a final count. The widget runs both versions side by side.

Word count two ways

Each line of the box is one partition, as in the slide's figure (up to four lines of twelve words). Choose the API, then step from the input to the result; the code line that does the current step is highlighted. Which reducer gets a word is decided as Spark does it for DataFrames — pmod(hash(word), n), checked on PySpark 4.2. The RDD API hashes with Python's hash instead, so on a real cluster its buckets may differ; the counts never do.

  1. Keep the slide's two lines, reduceByKey and 2 reducers. Before stepping: how many values cross the network in step 5? Then try groupByKey.
    What you should see

    6 both times. Each line holds every word once, so the combiner has nothing to merge: the map side of step 4 sends (to, 1), (be, 1), (or, 1) and (not, 1), (to, 1), (be, 1). The result is be 2, not 1, or 1, to 2 either way.

  2. Choose "both lines in one partition". Predict the values through the shuffle for reduceByKey and for groupByKey, then check.
    What you should see

    4 against 6. Now the partition holds to and be twice: reduceByKey sends (to, 2) and (be, 2) instead of two pairs each; groupByKey still ships all six (word, 1). This is the one thing the widget is for: the result never changes, only the traffic — "reduceByKey also automatically implements combiners on the map side" (p. 1233).

  3. Switch to the DataFrame. Which of the two RDD versions does it match in step 4 and step 5?
    What you should see

    reduceByKey: the count is computed in two halves, a partial count inside each partition and a final one after the exchange, so it sends 4 values with one partition and 6 with the slide's two. That is the partial_count of the plan printed by explain().

  4. Move the slider from 1 to 4 reducers. What changes in the result, and what in step 6?
    What you should see

    The counts stay the same; only the reducer that owns each word changes. With 2 reducers be, not and to land on reducer 1 and or on reducer 2; with 4, be and to share reducer 1 while not and or get one each — and reducer 2 gets nothing at all. The slide's figure draws a different split: any split works, as long as one word goes to one reducer.

The session's menu, one operation at a time

The practice of the exercise session is not in the slide PDF: it is in four notebooks published next to it on WeBeeP (E08 - Spark.zip) — data upload, filtering, grouping and joining, all on a pizza menu. The assistants' advice for every practical topic applies word for word: "focus on understanding how the query shapes the data. Think on how the dataset would change after each operation in the query is performed." The notebooks build the menu with an explicit schema, StructField(name, type, nullable):

# the menu of notebooks 1–3, built with an explicit schema
from pyspark.sql.functions import col, explode, array_contains
from pyspark.sql.functions import sum, avg, count, max, countDistinct
from pyspark.sql.types import (StructType, StructField, StringType,
                               FloatType, ArrayType)

schema = StructType([
    StructField("Pizza Name", StringType(), True),
    StructField("Price", FloatType(), True),
    StructField("Ingredients", ArrayType(StringType()), True)])
df_data = [("Margherita", 5.95, ["Tomato Sauce", "Mozzarella Cheese", "Basil"]),
           ("Calzone", 7.95, ["Tomato Sauce", "Mozzarella Cheese", "Prosciutto Cotto"]),
           ("Diavola", 5.95, ["Tomato Sauce", "Mozzarella Cheese", "Spicy Salame"]),
           ("Prosciutto", 7.95, ["Tomato Sauce", "Mozzarella Cheese", "Prosciutto Cotto"]),
           ("Speck & Brie", 7.95, ["Tomato Sauce", "Mozzarella Cheese", "Speck", "Brie"]),
           ("Tonno & Cipolle", 7.95, ["Tomato Sauce", "Mozzarella Cheese", "Tuna", "Onions"]),
           ("Fries", 3.95, ["Potatoes"])]
df = spark.createDataFrame(data=df_data, schema=schema)

With a list of column names instead of a schema — spark.createDataFrame(df_data, ["Pizza Name", "Price", "Ingredients"]) — Spark infers the types: string, double, array of strings. Either way the rows are those of Table 2. A DataFrame cannot be changed, so to "add one element" — the 1-point question of the 2026 paper — you build a one-row DataFrame with the same columns and union it, which matches the columns by position and returns a new DataFrame:

# adding a row: union returns a new DataFrame
new_row = spark.createDataFrame(
    [("Bufalina", 8.5, ["Tomato Sauce", "Buffalo Mozzarella"])], schema)
bigger_df = df.union(new_row)     # 8 rows; df still has 7
Table 2. The session's menu, df (notebooks 1–3). Price is a FloatType column.
Pizza NamePriceIngredients
Margherita5.95[Tomato Sauce, Mozzarella Cheese, Basil]
Calzone7.95[Tomato Sauce, Mozzarella Cheese, Prosciutto Cotto]
Diavola5.95[Tomato Sauce, Mozzarella Cheese, Spicy Salame]
Prosciutto7.95[Tomato Sauce, Mozzarella Cheese, Prosciutto Cotto]
Speck & Brie7.95[Tomato Sauce, Mozzarella Cheese, Speck, Brie]
Tonno & Cipolle7.95[Tomato Sauce, Mozzarella Cheese, Tuna, Onions]
Fries3.95[Potatoes]

Filter: conditions are columns

A condition for filter, or for its synonym where, is an expression on columns: df.Price, df["Pizza Name"] or col("Pizza Name") — a name with a space needs one of the last two. Conditions are combined with & (and), | (or) and ~ (not), each comparison in its own parentheses (notebook 2):

# Price above 7.9 and the name Calzone: one row, Calzone
df.filter((df.Price > 7.9) & (col("Pizza Name") == "Calzone")).show()

The notebook's "N.B. Parenthesis are essential!" has a reason. Python binds & more tightly than > and ==, so without parentheses it first computes 7.9 & col("Pizza Name"), then asks whether a column is true — and the query never reaches Spark:

# the same filter without parentheses: Python stops before Spark runs
df.filter(df.Price > 7.9 & col("Pizza Name") == "Calzone").show()
# PySparkValueError: [CANNOT_CONVERT_COLUMN_INTO_BOOL] Cannot convert
# column into bool: please use '&' for 'and', '|' for 'or',
# '~' for 'not' when building DataFrame boolean expressions.

Python's and gives the same error. Text has its own methods: contains("&"), startswith("To"), endswith("one"), like("%on%") with SQL's wildcards, rlike with a regular expression; isin([...]) tests a list of values, and array_contains(df.Ingredients, "Basil") looks inside a list column.

"df.filter(df.Price == 7.95) returns the four pizzas at 7.95." It returns none. A FloatType column stores 32 bits, so 7.95 is kept as 7.949999809265137, while the literal 7.95 is a 64-bit double: never equal. The notebook writes "PAY ATTENTION TO FLOATS!" and compares with the string "7.95" instead — a trick that worked under Spark 3 and fails under Spark 4: on PySpark 4.2, df.Price != "7.95" returns all seven pizzas. Compare a float with > or <, or declare money as DoubleType.

Explode, group, aggregate

explode turns a list column into rows: one row per element, every other column copied (notebook 1). The seven pizzas have 3 + 3 + 3 + 3 + 4 + 4 + 1 = 21 ingredients, so 21 rows come out; without an alias the new column is called col. It is MongoDB's $unwind of lesson 15.

# one row per (pizza, ingredient): 21 rows
exploded_df = df.select(col("Pizza Name"), df.Price,
                        explode(df.Ingredients).alias("Ingredient"))

select keeps only the columns you name. withColumn adds or replaces one column and keeps all the others: df.withColumn("Ingredient", explode(df.Ingredients)) gives the same 21 rows with the Ingredients list still attached.

groupBy followed by count() or agg(...) collapses the rows to one per key (notebook 3). agg takes any number of aggregate functions, each renamed with alias: count, sum, avg, max, countDistinct for different values, and collect_list or collect_set, which gather a group's values back into a list, with or without duplicates — the reverse of explode. SQL's HAVING is a filter after the agg, on the alias:

# at least four ingredients: Speck & Brie 4, Tonno & Cipolle 4
exploded_df.groupBy("Pizza Name") \
    .agg(count("Ingredient").alias("Number of Ingredients")) \
    .filter(col("Number of Ingredients") >= 4) \
    .show()

Now the puzzle of the hook. The same notebook also computes sum("Price").alias("Sum Price") per pizza on exploded_df. Calzone's price was copied into each of its three ingredient rows, so the sum is three times the price: the code asks for "the price, once per ingredient, added up". The digits come from the schema: 7.95 stored as a float is 7.949999809265137, and three of those added as doubles make 23.84999942779541. After an explode, sum and count what each new row stands for — here the ingredients, in the exam the heating systems — never a value of the parent row, which the explode repeated; take that with max, or aggregate before exploding.

Join

Notebook 4 replaces the list with a foreign key — a pizza table (Pizza Name, Price, Ingredients_ID), which the notebook calls df and this lesson pizza_df — and joins it with ingredient_df (Ingredients_ID, Ingredients). Two pizzas point to IG_7 and IG_8, which the other table lacks, and IG_5 and IG_6 are never used, so every join type gives a different count (Table 3).

# the inner join of notebook 4: 4 rows, both key columns kept
pizza_df.join(ingredient_df,
    pizza_df.Ingredients_ID == ingredient_df.Ingredients_ID,
    "inner").show()
Table 3. The join types of notebook 4 on its two tables (6 pizzas, 6 ingredient sets, 4 keys in common), as run on PySpark 4.2.
howrowswhat comes out
"inner"4the pairs that match
"left", "leftouter"6every pizza; NULL where no set matches
"right", "rightouter"6every ingredient set; NULL where no pizza uses it
"full", "outer", "fullouter"84 matches + 2 pizzas alone + 2 sets alone
"leftsemi"4the pizzas that have a match, pizza columns only
"leftanti"2the pizzas without a match

A join on an expression keeps both key columns, so a later select("Ingredients_ID") stops with [AMBIGUOUS_REFERENCE] Reference `Ingredients_ID` is ambiguous. Join on the name instead — on="Ingredients_ID" — and Spark keeps one key column, placed first; or rename a column before the join. The notebook calls leftsemi a "left inner join": it is not one — it returns each matching pizza once, with no columns of the other table, like SQL's WHERE … IN (…).

A PySpark playground on the session's tables

Type a DataFrame query in the box, or load an example, and press Run (or Ctrl+Enter). The belt shows the table after every operation of the chain — choose one to see it; a highlighted column is new at that step. The tables are listed under the box. Everything runs in your browser, in a small interpreter written for this lesson and checked against PySpark 4.2 on these tables; it knows the methods of this lesson, not all of Python. Without orderBy, Spark promises no row order, so yours may differ.

  1. Load "notebook 1 · explode the ingredients". Before you run it: how many rows will the result have? Run it, then click the first and the last chip of the belt.
    What you should see

    21: 3 + 3 + 3 + 3 + 4 + 4 + 1. The first chip is df with 7 rows, the last the exploded table with 21; Calzone now has three rows, each with its price. The one insight of this widget: read a query as a sequence of tables, and watch the row count — explode multiplies rows, filter removes them, groupBy collapses them to one per key.

  2. Load "notebook 3 · sum, avg, count, max per pizza". Predict Calzone's Sum Price and Number of Ingredients, then run.
    What you should see

    23.84999942779541 and 3: the hook's number. Sum Price is the price counted once per ingredient row; Price, the max, gives the menu price back, and so does Average Price, as 7.949999809265137 — the float digits.

  3. Load "30 Jan 2026 (c) · totals over 100, names", run it and step along the belt: how many rows after each operation? Then load the wrong-order version and predict its result before running it.
    What you should see

    3, 5, 5, 3, 2, 2: explode makes one row per heating system, the join keeps them all, the groupBy collapses them to one per hospital, the HAVING filter keeps 110 and 130, the select keeps the names. The wrong order returns nothing: the filter on Heat_Power before the groupBy looks at single systems, and none reaches 100.

  4. Load "notebook 4 · join on an expression". Change "inner" to "left", "full" and "leftanti", predicting the row count before each run. Then add .select("Ingredients_ID") before .show().
    What you should see

    6, 8 and 2, as in Table 3. The select stops with [AMBIGUOUS_REFERENCE]: the joined table has two columns called Ingredients_ID. "notebook 4 · join on the key name" shows the fix: on="Ingredients_ID" keeps one.

The exam exercise, end to end

The paper of 30 Jan 2026 (Exercise 4 - Spark, 5 PT) starts from the ER diagram of a hospital heating management system and draws two tables: Hospital (Hospital_ID, Name, Heating_Systems), with rows such as HOSP_1 | ... | [HS_1, HS2, ...], and Heating System (Heat_ID, H_KW_Cons, Heat_Power). "Consider only the attributes defined." Its first question:

"Write a Python code to create a dataframe for the first table and to add one element in the dataframe. (1PT)"

# (a) the Hospital table, plus one hospital: union and re-assign
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("Hospitals").getOrCreate()

hospital_cols = ["Hospital_ID", "Name", "Heating_Systems"]
hospital_rows = [("HOSP_1", "Milano Care Center", ["HS_1", "HS_2"]),
                 ("HOSP_2", "Como General", ["HS_3"])]
hospital_df = spark.createDataFrame(hospital_rows, hospital_cols)
new_row = spark.createDataFrame(
    [("HOSP_3", "Lecco Care Clinic", ["HS_4", "HS_5"])], hospital_cols)
hospital_df = hospital_df.union(new_row)

The rows are ours, as are those of the heating table (Table 4): the paper prints only "...". Spark infers string, string and array of strings. Write every value of a column the same way — 120.0 in every row, not 120 in one and 40.5 in another — or createDataFrame stops with [CANNOT_MERGE_TYPE].

Table 4. Our test rows for heating_df (H_KW_Cons, Heat_Power as doubles). HS_1 and HS_2 share a consumption on purpose.
Heat_IDH_KW_ConsHeat_Power
HS_1120.040.0
HS_2120.070.0
HS_390.030.0
HS_4300.080.0
HS_5150.050.0

The third question: "Write a SparkQL query to compute and return the total Heat_Power for each hospital, returning only the names of the hospitals whose total is greater than 100. (2 PT) Note that you are not allowed to use SQL syntax. Use PySpark instead." Translate it phrase by phrase, and look at the table after every step:

  1. The list cannot be joined as it is: a join compares one value with one value. Explode it — one row per (hospital, heating system):

    .select("Hospital_ID", "Name",
            F.explode("Heating_Systems").alias("Heat_ID"))
    Hospital_IDNameHeat_ID
    HOSP_1Milano Care CenterHS_1
    HOSP_1Milano Care CenterHS_2
    HOSP_2Como GeneralHS_3
    HOSP_3Lecco Care ClinicHS_4
    HOSP_3Lecco Care ClinicHS_5
  2. Bring in Heat_Power with a join on the column name — Spark keeps one Heat_ID, placed first:

    .join(heating_df, "Heat_ID")
    Heat_IDHospital_IDNameH_KW_ConsHeat_Power
    HS_1HOSP_1Milano Care Center120.040.0
    HS_2HOSP_1Milano Care Center120.070.0
    HS_3HOSP_2Como General90.030.0
    HS_4HOSP_3Lecco Care Clinic300.080.0
    HS_5HOSP_3Lecco Care Clinic150.050.0
  3. "the total Heat_Power for each hospital": group by the key and carry the name — two hospitals could share a name:

    .groupBy("Hospital_ID", "Name")
    .agg(F.sum("Heat_Power").alias("Total_Heat_Power"))
    Hospital_IDNameTotal_Heat_Power
    HOSP_1Milano Care Center110.0
    HOSP_2Como General30.0
    HOSP_3Lecco Care Clinic130.0
  4. "whose total is greater than 100": a HAVING, so a filter after the agg, on the alias:

    .filter(F.col("Total_Heat_Power") > 100)
    Hospital_IDNameTotal_Heat_Power
    HOSP_1Milano Care Center110.0
    HOSP_3Lecco Care Clinic130.0
  5. "returning only the names": a last select. The answer is two names.

    .select("Name")
    Name
    Milano Care Center
    Lecco Care Clinic

Put together, with the chain in parentheses so that it can span lines:

# (c) total Heat_Power per hospital; names of those above 100
from pyspark.sql import functions as F

totals = (hospital_df
    .select("Hospital_ID", "Name",
            F.explode("Heating_Systems").alias("Heat_ID"))
    .join(heating_df, "Heat_ID")
    .groupBy("Hospital_ID", "Name")
    .agg(F.sum("Heat_Power").alias("Total_Heat_Power"))
    .filter(F.col("Total_Heat_Power") > 100)
    .select("Name"))
totals.show()

Move the filter before the groupBy — .filter(F.col("Heat_Power") > 100) — and the answer is empty: no single heating system reaches 100; only the totals do. A filter before the grouping removes rows, like WHERE; after it, it removes groups, like HAVING. Table 5 collects the phrases of the three papers and their methods.

Table 5. From the papers' words to DataFrame methods (with from pyspark.sql import functions as F).
The paper saysYou write
"create a dataframe … add one element"spark.createDataFrame(rows, cols), then df = df.union(one_row_df)
a list of ids in a cell, [HS_1, HS2, ...]F.explode("Heating_Systems").alias("Heat_ID"), then join on the id
"for each hospital".groupBy("Hospital_ID", "Name")
"the total Heat_Power".agg(F.sum("Heat_Power").alias("Total_Heat_Power"))
"the total number of …"F.count("Film ID"), or .groupBy(...).count()
"unique", "(distinct)"F.countDistinct("H_KW_Cons")
"whose name includes the word "Care"".filter(F.col("Name").contains("Care"))
"whose total is greater than 100".filter(F.col("Total_Heat_Power") > 100) after the agg
"returning only the names".select("Name")
"Return only the top 10 …" (2026, in the MongoDB exercise).orderBy(F.col("Total_Heat_Power").desc()).limit(10)
"born after 08/09/1994"F.col("birth_date") > F.to_date(F.lit("08/09/1994"), "dd/MM/yyyy")
two conditions(F.col("a") > 100) & (F.col("b") == "x")
"not allowed to use SQL syntax"no spark.sql(...), no SQL string in filter

The same recipe — explode, join, filter, group, filter the totals, select — solves the other past papers. Try them in the exam questions below before opening the model answers, and test your code in the playground: its tables hold our test rows for each paper.

Exam-style questions

Three practical exercises from past papers, each with its ER scenario and the points printed on the paper; one exercise of ours in the same form; two theory questions, where a wrong closed answer may cost points. No solutions are published: the model answers are ours, run on PySpark 4.2 on test rows built to hit every condition — the rows of the playground.

Exam of 30 Jan 2026, exercise 4 (a hospital heating management system). ER: Hospital (Hospital_ID, Name); Heating System (Heater_Sys_ID, Heating_Power), with the children HVAC System (Hourly_KWh_Cons, Cost_Per_KWh) and Gas Heating System (Hourly_SQM_Cons, Cost_Per_SQM), ISA (T, E); Has H. Sys.: Hospital (1:N) – Heating System (1:1). The exercise draws the tables Hospital (Hospital_ID, Name, Heating_Systems = [HS_1, HS2, ...]) and Heating System (Heat_ID, H_KW_Cons, Heat_Power).

"Write a SparkQL query to compute and return the total number of unique H_KWh_Consumption for each Hospital whose name includes the word "Care". Note that you are not allowed to use SQL syntax. Use PySpark instead. (2 PT)"

Show model answer

The text says H_KWh_Consumption, the table H_KW_Cons: I use the table's column name.

# different consumptions per hospital whose name contains "Care"
from pyspark.sql import functions as F

unique_cons = (hospital_df
    .filter(F.col("Name").contains("Care"))
    .select("Hospital_ID", "Name",
            F.explode("Heating_Systems").alias("Heat_ID"))
    .join(heating_df, "Heat_ID")
    .groupBy("Hospital_ID", "Name")
    .agg(F.countDistinct("H_KW_Cons").alias("Unique_Consumptions")))
unique_cons.show()

Keep the hospitals whose name contains "Care", explode the list into one row per heating system, join with the heating table to get H_KW_Cons, group by hospital and count the distinct consumptions.

On the test rows: Milano Care Center 1 — its two systems both consume 120 — and Lecco Care Clinic 2; Como General is filtered out. With F.count Milano would say 2: "unique" is the whole point of countDistinct. Check also the explode before the join and the parentheses around the chain. Filtering on the name first is not required, but it explodes fewer rows.

Exam of 21 Jan 2025, exercise 5 (tracking Netflix's digital content). ER: Digital Content (ID, title, duration, description, language, original (Y/N)), with the child Film (budget, number_oscars), ISA (T, E); Person (Personal_ID, name, surname, birth_date, nationality, biography), with the child Director (number_oscars), ISA (T, E); Direct: Film (1:1) – Director (1:N). The exercise draws the tables Director (Personal ID, Name, …) and Film (Director ID, Film ID, …).

"Write a SparkQL query to collect the total number of original films directed by directors whose nationality is equal to the film's language (1.5 PT) N.B. Consider the check between nationality and language works."

"Write a SparkQL query to collect the total budget of films whose titles include the word "Curse" and whose director was born after 08/09/1994, for each director (1.5 PT)"

Show model answer

Assumed columns, from the ER: directors (Personal ID, Name, nationality, birth_date as a date); films (Director ID, Film ID, title, language, original as "Y"/"N", budget).

# (a) original films whose language is the director's nationality
from pyspark.sql import functions as F

joined = films.join(directors,
                    films["Director ID"] == directors["Personal ID"])
n_original = (joined
    .filter((F.col("original") == "Y") &
            (F.col("nationality") == F.col("language")))
    .agg(F.count("Film ID").alias("n_original_films")))
n_original.show()
# (b) budget of "Curse" films per director born after 8 Sep 1994
budget_per_director = (joined
    .filter(F.col("title").contains("Curse") &
            (F.col("birth_date") >
             F.to_date(F.lit("08/09/1994"), "dd/MM/yyyy")))
    .groupBy("Director ID", "Name")
    .agg(F.sum("budget").alias("total_budget")))
budget_per_director.show()

On our test rows: (a) 2; (b) Anna Rossi (born 1995) 100.0 and Marco Verdi (1999) 30.0, the sum of his two Curse films; John Smith (1980) is excluded. The date is where answers break: comparing birth_date with the string "08/09/1994" stops with [CAST_INVALID_INPUT] on Spark 4, so parse it with to_date and the paper's day/month/year format, or write F.lit("1994-09-08"). Without the parentheses around the date comparison the query stops with [DATATYPE_MISMATCH]. The join keeps both id columns, which is harmless here: they have different names.

Exam of 30 May 2025 (its header prints June 30, 2025), exercise 4 (tracking puzzles' production and sales). ER: Puzzle (Puzzle ID, Name, Collection Name, Number of Pieces, Width, Height, Description); Frame (Frame ID, Width, Height, Material, Color); Fit: Puzzle (1, N) – Frame (1, N). "N.B. All sizes are in cm." The exercise draws the tables Frame (Frame ID (PK), …) and Puzzle (Frame ID (FK) = [X, Y, …], Puzzle ID (PK), …).

"Write a SparkQL query to return the frames that perfectly fit the puzzle they are fit for. (1.5 PT)"

"Write a SparkQL query to count the total number of (distinct) frames with a width and height greater than 100 and 70 cm, respectively, that are associated with a puzzle from the "Landscape Collection". (1.5 PT)"

Show model answer
# one row per (puzzle, frame); puzzle sizes renamed before the join
from pyspark.sql import functions as F

pz = puzzles.select(F.explode("Frame ID").alias("Frame ID"),
                    "Puzzle ID", "Collection Name",
                    F.col("Width").alias("P_Width"),
                    F.col("Height").alias("P_Height"))
fitted = pz.join(frames, "Frame ID")
# (a) frames whose width and height equal those of their puzzle
perfect = (fitted
    .filter((F.col("Width") == F.col("P_Width")) &
            (F.col("Height") == F.col("P_Height")))
    .select("Frame ID", "Puzzle ID"))
perfect.show()
# (b) distinct frames over 100 x 70 cm on Landscape Collection puzzles
n_frames = (fitted
    .filter((F.col("Width") > 100) & (F.col("Height") > 70) &
            (F.col("Collection Name") == "Landscape Collection"))
    .agg(F.countDistinct("Frame ID").alias("n_frames")))
n_frames.show()

On our test rows: (a) FR1–PZ1, FR3–PZ2, FR4–PZ3; (b) 2, because FR1 fits two Landscape puzzles and is counted once — a plain count says 3. The rename is not decoration: without it the joined table has two Width and two Height columns, and F.col("Width") stops with [AMBIGUOUS_REFERENCE] (the slip example of the playground). Equality between two double columns is safe; equality between a float column and a literal is not.

An exercise of ours in the form of the papers, on the ER of the exam of 6 Sep 2024 (a restaurant), whose paper had no Spark exercise. ER: Dish (Dish_ID, Name, Price, Recipe, Description); Ingredient (Ingredient_ID, Name, Allergies (Yes/No)); Use: Dish (1:N) – Ingredient (1:N). Consider the tabular representations Dish (Dish_ID, Name, Price, Ingredients = [ING_1, ING_2, ...]) and Ingredient (Ingredient_ID, Name, Allergies). Consider only the attributes defined.

(a) Write a Python code to create a dataframe for the first table and to add one element in the dataframe.

(b) Write a SparkQL query to compute and return the total number of ingredients that may cause allergies for each dish whose name includes the word "Pizza". Note that you are not allowed to use SQL syntax. Use PySpark instead.

(c) Write a SparkQL query to compute and return, for each ingredient, the average price of the dishes that use it, returning only the ingredients used in at least two dishes. Note that you are not allowed to use SQL syntax. Use PySpark instead.

Show model answer
# (a) three dishes, then a fourth one by union
from pyspark.sql import SparkSession
from pyspark.sql import functions as F

spark = SparkSession.builder.appName("Restaurant").getOrCreate()
dish_cols = ["Dish_ID", "Name", "Price", "Ingredients"]
dish_rows = [
    ("DISH_1", "Pizza Margherita", 6.5, ["ING_1", "ING_2", "ING_3"]),
    ("DISH_2", "Pizza Bianca", 7.0, ["ING_2", "ING_4"]),
    ("DISH_3", "Octopus with Potatoes", 25.0, ["ING_5", "ING_6"])]
dishes = spark.createDataFrame(dish_rows, dish_cols)
new_dish = spark.createDataFrame(
    [("DISH_4", "Pizza Tonno", 8.0, ["ING_1", "ING_2", "ING_7"])],
    dish_cols)
dishes = dishes.union(new_dish)
# (b) allergenic ingredients per pizza; dish Name renamed first
n_allergens = (dishes
    .filter(F.col("Name").contains("Pizza"))
    .select("Dish_ID", F.col("Name").alias("Dish_Name"),
            F.explode("Ingredients").alias("Ingredient_ID"))
    .join(ingredients, "Ingredient_ID")
    .filter(F.col("Allergies") == "Yes")
    .groupBy("Dish_ID", "Dish_Name")
    .agg(F.count("Ingredient_ID").alias("n_allergens")))
n_allergens.show()
# (c) average dish price per ingredient used in two dishes or more
avg_price = (dishes
    .select("Dish_ID", "Price",
            F.explode("Ingredients").alias("Ingredient_ID"))
    .join(ingredients, "Ingredient_ID")
    .groupBy("Ingredient_ID", "Name")
    .agg(F.avg("Price").alias("avg_dish_price"),
         F.count("Dish_ID").alias("n_dishes"))
    .filter(F.col("n_dishes") >= 2)
    .select("Name", "avg_dish_price"))
avg_price.show()

On these rows and the seven ingredients of the playground: (b) Pizza Tonno 2 (mozzarella, tuna), Pizza Margherita 1, Pizza Bianca 1; (c) Tomato 7.25 and Mozzarella 7.166666666666667. Both tables have a column called Name: without the rename in (b), groupBy("Dish_ID", "Name") after the join stops with [AMBIGUOUS_REFERENCE]. In (c) averaging the dish's price is right, because each dish appears once per ingredient row; summing it per dish would be the Calzone slip. A pizza with no allergenic ingredient would be missing from (b), not shown with 0 — say so if you think the question wants it.

Which of the following Spark RDD operations are actions? Select all that apply.

  • collect() It returns the content of the RDD to the driver as a list: an action (p. 1722).
  • reduceByKey() It returns a new RDD of pairs, so it is a transformation, lazy like map; the action is reduce().
  • count() It returns a number to the driver, so the job must run: an action.
  • flatMap() It returns a new RDD: a transformation, computed only when an action asks for it.
  • saveAsTextFile() It writes the elements out, so Spark must compute them: an action.
  • union() It returns a new RDD with the elements of both: a transformation.
Show model answer

collect(), count() and saveAsTextFile(). Transformations (map, filter, flatMap, distinct, union, reduceByKey, groupByKey, sortBy) create a new RDD and are lazy; actions (collect, count, take(n), reduce, saveAsTextFile, first) force the execution and return a result or write it.

The trap is the name: reduceByKey sounds like reduce but is a transformation. Ask of each operation what it returns — another RDD means lazy.

The slides state that "reduceByKey also automatically implements combiners on the map side". Explain what this means and why it matters, and why groupByKey followed by a sum is slower even though it gives the same result. Use the word count as an example.

Show model answer

In the word count, each partition produces pairs (word, 1), and the final count of a word needs all its pairs on one machine, so they must be shuffled across the network by key. reduceByKey(f) applies f inside each partition before the shuffle: a partition that holds "to" a thousand times sends one pair (to, 1000) instead of a thousand pairs — this local pre-aggregation is the combiner of MapReduce. The reducers then merge the partial results with the same f, which is why f must be associative and commutative. groupByKey cannot pre-aggregate, because it must deliver every value of a key to the reducer as a list; the sum happens only after the shuffle. Both give the same counts, but groupByKey moves far more data over the network and can build very large lists on one reducer, so for a reduction reduceByKey is preferred.

A complete answer has four parts: the shuffle as the costly step, the partial aggregation in each partition before it, the condition on the function, and the contrast with groupByKey. A number makes it concrete, as in Fig. 1: with both lines of the slide in one partition, 4 values travel instead of 6.

Check yourself

lines holds the slide's two lines, "to be or" and "not to be". How many elements does lines.map(lambda l: l.split(" ")) have?

  • 2 map is one in, one out: two lines give two lists of three words each.
  • 6 Six is the count of flatMap, which flattens the lists into single words.
  • 4 Four is the number of different words, what reduceByKey would leave.
  • 0, until an action runs Nothing is computed before an action, but the question is what the RDD holds; collect() would return two lists.

A program runs counts = lines.flatMap(...).map(...).reduceByKey(...), then counts.collect(). Which statements are true? Select all that apply.

  • Nothing is computed before collect() runs The three calls only record transformations; the action starts the job.
  • flatMap and map run in the same stage, one task per partition Both are narrow, so the DAG scheduler pipelines them in one pass (p. 1237).
  • reduceByKey needs a shuffle, so it starts a new stage The pairs of one word must meet on one machine: a wide dependency, and the stage boundary.
  • reduceByKey is an action, so the job starts there It returns a new RDD: a transformation. The job starts at collect().

In notebook 4, pizza_df has the keys IG_1–IG_4, IG_7, IG_8 and ingredient_df the keys IG_1–IG_6, each key once. How many rows does the full outer join return?

  • 4 Four is the inner join: only the keys present on both sides.
  • 6 Six is a left or a right join: all the rows of one side.
  • 8 4 matches, plus IG_7 and IG_8 with NULLs on the right, plus IG_5 and IG_6 with NULLs on the left.
  • 36 That would be the Cartesian product, 6 × 6, without a join condition.

On exploded_df (one row per pizza and ingredient), which aggregates per pizza give back each pizza's menu price? Select all that apply.

  • max("Price") All the copies of a pizza's price are equal, so the largest is the price — how notebook 3 gets it back.
  • avg("Price") The average of equal copies is the copy itself (7.949999809265137 for Calzone: the float digits).
  • sum("Price") The price is added once per ingredient: 23.85 for Calzone's three ingredients — the hook's slip.
  • count("Price") That counts the rows, i.e. the ingredients: 3 for Calzone.

With the hospital tables of the lesson, you write the filter F.col("Heat_Power") > 100 before the groupBy instead of filtering the totals after the agg. What comes back?

  • No hospital at all Before the grouping the filter looks at single heating systems, and none has a power above 100; only the totals 110 and 130 do.
  • The same two names The same two names need the totals, which do not exist yet when the filter runs.
  • All three hospitals A filter can only remove rows, and here it removes all of them.
  • An error: a filter cannot come before a groupBy It can — it is the WHERE of SQL. It just answers another question.

You run df.union(new_row) on the seven-pizza menu, then df.count(). What do you get?

  • 7 A DataFrame is immutable: union returns a new DataFrame, which you did not assign. Write df = df.union(new_row).
  • 8 Only the new DataFrame returned by union has 8 rows, and it was thrown away.
  • An error: the schemas differ new_row was built with the same schema; union matches the columns by position.
  • Nothing: count is lazy count() is an action: it runs and returns a number.

Key take-aways

What to watch and read