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):
| Kind | Operations on the slide | What a call returns |
|---|---|---|
| Transformations: lazy | map(), filter(), flatMap(), distinct(), union(), reduceByKey(), groupByKey(), sortBy() | a new RDD; nothing runs yet |
| Actions: eager | collect(), 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.
Before: groupByKey — every pair travels
After: reduceByKey — combined on the map side
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.
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.
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.
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.
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].
heating_df (H_KW_Cons, Heat_Power as doubles). HS_1 and HS_2 share a consumption on purpose.| Heat_ID | H_KW_Cons | Heat_Power |
|---|---|---|
| HS_1 | 120.0 | 40.0 |
| HS_2 | 120.0 | 70.0 |
| HS_3 | 90.0 | 30.0 |
| HS_4 | 300.0 | 80.0 |
| HS_5 | 150.0 | 50.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:
-
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_ID Name Heat_ID HOSP_1 Milano Care Center HS_1 HOSP_1 Milano Care Center HS_2 HOSP_2 Como General HS_3 HOSP_3 Lecco Care Clinic HS_4 HOSP_3 Lecco Care Clinic HS_5 -
Bring in Heat_Power with a join on the column name — Spark keeps one Heat_ID, placed first:
.join(heating_df, "Heat_ID")Heat_ID Hospital_ID Name H_KW_Cons Heat_Power HS_1 HOSP_1 Milano Care Center 120.0 40.0 HS_2 HOSP_1 Milano Care Center 120.0 70.0 HS_3 HOSP_2 Como General 90.0 30.0 HS_4 HOSP_3 Lecco Care Clinic 300.0 80.0 HS_5 HOSP_3 Lecco Care Clinic 150.0 50.0 -
"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_ID Name Total_Heat_Power HOSP_1 Milano Care Center 110.0 HOSP_2 Como General 30.0 HOSP_3 Lecco Care Clinic 130.0 -
"whose total is greater than 100": a HAVING, so a
filterafter theagg, on the alias:.filter(F.col("Total_Heat_Power") > 100)Hospital_ID Name Total_Heat_Power HOSP_1 Milano Care Center 110.0 HOSP_3 Lecco Care Clinic 130.0 -
"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.
from pyspark.sql import functions as F).| The paper says | You 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.
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?
A program runs counts = lines.flatMap(...).map(...).reduceByKey(...), then counts.collect(). Which statements are true? Select all that apply.
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?
On exploded_df (one row per pizza and ingredient), which aggregates per pizza give back each pizza's menu price? Select all that apply.
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?
You run df.union(new_row) on the seven-pizza menu, then df.count(). What do you get?
Key take-aways
- An RDD comes from
sc.parallelizeorsc.textFile; transformations (map,filter,flatMap,reduceByKey, …) return new RDDs and are lazy, actions (collect,count,take,reduce,saveAsTextFile,first) run the job. - On pair RDDs,
reduceByKeymerges values per key and combines on the map side, so only partial results cross the network;groupByKeyships every value;joinpairs matching values,cogrouplists them; an extra integer sets the number of reduce tasks. - The DAG scheduler cuts a job into stages at the shuffles, pipelines narrow operations, runs tasks near their data, reuses cached partitions and skips the shuffle for an input already partitioned by the key; iterative algorithms gain most from data kept in memory, while the on-disk sort record shows that the engine itself is faster.
- A DataFrame is "a distributed collection of rows organized into named columns" — an RDD with a schema: lazy, immutable, nested (array columns), queried through the DSL that the papers call SparkQL; the 2026 paper forbids SQL strings.
- The exam recipe: explode the list, join on the id name, filter,
groupBy(id, name).agg(F.f(...).alias(...)), filter on the alias for HAVING, select;countDistinctfor "unique"; to add a row, union a one-row DataFrame and re-assign. - The traps: parentheses around every comparison, equality on float columns, two columns with one name after a join, and a parent's value summed after an explode — Calzone's 23.85.
What to watch and read
-
Introducing DataFrames in Apache Spark for Large Scale Data Science
Reynold Xin, Michael Armbrust, Davies Liu — Databricks blog
Why DataFrames arrived in Spark 1.3, as the renamed SchemaRDD of p. 1246, and a chart where Python DataFrame code runs as fast as Scala while Python RDD code lags — the reason behind "DataFrames are preferred today".
-
RDD Programming Guide
Apache Spark documentation
The official tables of transformations and actions, "Working with Key-Value Pairs" and "Shuffle operations" — sections 1 and 2 of this lesson, with the same word count in Python.
-
Quickstart: DataFrame
PySpark documentation
Creating DataFrames from rows, select, filter, groupBy — and the rule behind every lazy-or-eager question: nothing runs until an action such as show() or collect().
-
PySpark Live Notebook: DataFrame
Apache Spark, on Binder
Real PySpark in the browser, nothing to install (it takes a minute or two to start). Rebuild the hospital tables, run the model answers, then try your own variants of the past papers.
-
Functions (pyspark.sql.functions)
PySpark documentation
Every function used in an
aggor afilter— sum, avg, count, count_distinct, collect_set, explode, to_date. Keep it open while you redo the past papers.