Spark SQL, Catalyst and the columnar file formats

After this lesson you will follow a DataFrame query through Catalyst's four phases, say which bytes of a Parquet file it reads and why, and explain where Spark SQL's speed comes from.

In the paper behind the chart of p. 1287, two Spark programs compute the same thing: the average of one column for every value of another, over a billion pairs of numbers, on the same five worker machines. Both are written in Python. One hands its work to map and reduceByKey as lambdas; the other is a single line, df.groupBy("a").avg("b"). On the chart the lambdas need about 180 seconds and the one-liner about 15: twelve times less.

Same language, same data, same cluster. What does Spark know about the one-liner that it cannot know about the lambdas? By the end of the lesson you will have three answers, and one of them is not in Spark at all but in the file on disk.

Spark SQL is about more than SQL

The title slide says it in five words: Spark SQL "is about more than SQL" (p. 1262). Page 1263 gives the reasons. Big-data work must "perform ETL to and from various (semi- or unstructured) data sources" and "perform advanced analytics (e.g. machine learning, graph processing) that are hard to express in relational systems". The answer: "a DataFrame API that can perform relational operations on both external data sources and Spark's built-in RDDs", plus "a highly extensible optimizer, Catalyst".

Page 1264 turns this into three promises of declarative big-data processing: write less code, read less data, let the optimizer do the hard work. You state what you want; Spark decides how. This lesson follows the data: your code, the optimizer, the file.

Write less code: one average, four dialects

Page 1265 computes an average in about thirty lines of Hadoop Java and five of Spark; p. 1266 writes the average age per name in four dialects: RDDs, SQL, Pig and the DataFrame. Take people(name, age): Ann 30, Bob 20, Ann 40, Cid 10. (The slides' sqlCtx is today's session object spark.)

from pyspark.sql import functions as F
# one row per name: Ann 35.0, Bob 20.0, Cid 10.0
spark.table("people").groupBy("name").agg(F.avg("age")).show()

The RDD version of p. 1266 carries the average's ingredients by hand:

# the same averages with RDDs: carry (sum, count) pairs yourself
(sc.textFile("people.tsv")
   .map(lambda line: line.split("\t"))
   .map(lambda x: (x[0], [int(x[1]), 1]))
   .reduceByKey(lambda x, y: [x[0] + y[0], x[1] + y[1]])
   .map(lambda x: [x[0], x[1][0] / x[1][1]])
   .collect())

Why [int(x[1]), 1]? reduceByKey combines partial results in any order, on any machine, like the combiner of lesson 30. Averages cannot be combined; (sum, count) pairs can, and the last map divides:

\[ \begin{aligned} &(s_1, c_1) \oplus (s_2, c_2) \\ &\quad = (s_1 + s_2,\ c_1 + c_2) \end{aligned} \]

The DataFrame just says avg, and Spark supplies this machinery. (The slide's sc.textFile(...).split("\t") would fail: an RDD has no split, so each line is split inside a map.)

One engine, two front ends

Spark SQL is "part of the core distribution since Spark 1.0" and "runs SQL / HiveQL queries, optionally alongside or replacing existing Hive deployments" (p. 1270). Page 1272 shows where it sits.

JDBC Console User programs Java, Scala, Python Spark SQL DataFrame API Catalyst optimizer Spark Resilient Distributed Datasets
Fig. 1. The programming interface of p. 1272. JDBC clients, the console and user programs send their queries (orange) to Spark SQL, where SQL strings and DataFrame calls reach the same Catalyst optimizer; what comes out runs on Spark's RDDs. The dashed arrow: a program may still call RDDs directly.

The slide presents Spark SQL as "Originally called Shark" with three limitations (p. 1271): it "can only be used to query external data in Hive catalog → limited data sources", "can only be invoked via SQL string from Spark → error prone", and its "Hive optimizer tailored for MapReduce → difficult to extend". Read backwards, they are Spark SQL's design: any source and native RDDs, a DataFrame API, a new optimizer.

"Spark SQL can only query Hive tables." That was Shark's limitation. Strictly, Shark was the predecessor project, Hive running on Spark, stopped in July 2014 in favour of Spark SQL (background).

What a program has that an SQL string has not

Page 1273 lists the "Advantages over Relational Query Languages": "Holistic optimization across functions composed in different languages", "Control structures (e.g. if, for)" and "Logical plan analyzed eagerly → identify code errors associated with data schema issues on the fly". Execution is lazy, analysis is eager: on a table sales(region, amount) the line sales.select("col") fails at once, before any data is read, with ... `col` cannot be resolved. Did you mean one of the following? [`amount`, `region`] (Spark 4.2, executed).

Spark SQL also queries your own objects (p. 1274): it can "infer column names and types directly from data objects (via reflection in Java and Scala and data sampling in Python, which is dynamically typed)", reads them "in-place", and lets you "combine RDDs with external structured data", with "hot columns cached in memory".

Inside a full programming language a user-defined function (UDF) needs no packaging: Spark SQL "allows inline registration of UDFs", whereas Pig "requires the UDF to be written in a Java package", and a registered UDF is "available to other interfaces" (p. 1275) — the slide registers a trained model as predict and calls SELECT predict(age, weight) FROM users. Page 1269 adds composition: add_demographics takes a DataFrame and returns one. Runnable, with the caller of p. 1281:

from pyspark.sql import functions as F
from pyspark.sql.types import StringType

zipToCity = F.udf(lambda z: "Melbourne" if z.startswith("3")
                            else "Other", StringType())

def add_demographics(events):
    u = spark.table("users")
    return (events.join(u, events.user_id == u.user_id)
                  .withColumn("city", zipToCity(u.zip)))

# the timestamps of the events of users living in Melbourne
events = add_demographics(spark.read.parquet("/data/events"))
training_data = (events.where(events.city == "Melbourne")
                       .select(events.timestamp)
                       .collect())

Spark SQL is one structured API and one optimizer behind both SQL strings and DataFrame code, executed as RDD operations.

Let the optimizer do the hard work: Catalyst

A lambda is a black box: Spark can call it but cannot look inside. A DataFrame query is kept as a tree of node objects. The expression x + (1 + 2) is Add(Attribute(x), Add(Literal(1), Literal(2))), and a rule is a function that finds a pattern in the tree and replaces it (p. 1276):

tree.transform {
  case Add(Literal(c1), Literal(c2)) => Literal(c1+c2)
}
Add Attribute(x) Add Literal(1) Literal(2) x + (1 + 2)

Before: the rule matches the highlighted subtree

Add Attribute(x) Literal(3) folded once x + 3

After: one literal, computed before any row is read

Fig. 2. Constant folding as a Catalyst rule (p. 1276). case Add(Literal(c1), Literal(c2)) matches the highlighted subtree and replaces it with Literal(3); the branch with Attribute(x) does not match and is left alone.

Rules (p. 1277) are "pattern matching functions that transform subtrees into specific structures", and partial functions: they "skip over subtrees that do not match → no need to modify existing rules when adding new types of operators" — which is why Catalyst is extensible. One transform may hold several patterns; rules come in batches and "may take multiple batches to reach a fixed point", where no rule changes the tree any more (one rewrite can enable the next); and "transform can contain arbitrary Scala code".

Four phases, from query to RDDs

Page 1278 is the map of the optimizer, and its caption is the fact to remember: "DataFrames and SQL share the same optimization/execution pipeline".

SQL AST DataFrame one pipeline for both Unresolved logical plan Catalog Analysis names → columns, types Logical plan Logical optimization rules · 800LOC Optimized logical plan Physical planning one or more plans Physical plans Cost model the only cost-based step Selected physical plan Code generation quasiquotes → bytecode RDDs
Fig. 3. The pipeline of p. 1278, drawn top to bottom. SQL strings and DataFrame calls enter the same way; only physical planning consults a cost model, the other phases apply rules.

Unresolved attribute — an attribute whose "type is not known or it's not matched to an input table" (p. 1279).

Analysis (p. 1279) turns the unresolved logical plan into a logical plan. For SELECT col FROM sales it must "look up relations by name from the catalog", "map named attributes to the input provided given operator's children", give a "UID for references to the same value" and "propagate and coerce types through expressions (e.g. 1 + col)". Spark lets you watch: for SELECT amount + 1 FROM sales the parsed plan holds 'amount and 'UnresolvedRelation [sales], the analysed plan amount#32 + cast(1 as double) — a column with a unique id, and a literal cast to its type.

Logical optimization (p. 1280) "applies standard rule-based optimization (constant folding, predicate-pushdown, projection pruning, null propagation, boolean expression simplification, etc)", all in "800LOC". Predicate pushdown moves a filter as close to the data as possible; projection pruning keeps only the columns used further up. Page 1282 spells out one such rule: "Find filters on top of projections", "Check that the filter can be evaluated without the result of the project", "If so, switch the operators".

Project name Filter id = 1 Project id, name People 1 row 1,000,000 1,000,000 filters late

Original plan

Project name Project id, name Filter id = 1 People 1 row 1 row 1,000,000 filters first

Filter push-down

Fig. 4. The example transformation of p. 1282, read from the bottom up. The filter needs only id, which People already has, so it may swap places with the projection. Row counts are illustrative (a People table of 1,000,000 rows, one of them with id = 1): after the swap the projection handles 1 row instead of 1,000,000.

Spark 4.2 goes further: its optimised plan drops the inner projection and turns the filter into isnotnull(id) AND (id = 1) (executed).

Page 1281 runs the slide's example through three plans (the widget replays them): the caller's where sits on top of the join written inside add_demographics, and Catalyst moves it down anyway — the "holistic optimization across functions" of p. 1273.

Catalyst at work: four plans of one query

The query of p. 1281 ("after the join, on users.city") with one change: users stores the city itself, so no UDF is needed (with the slide's Python UDF, Spark moves the filter below the join but cannot push it into the scan). Both tables are Parquet files: events(user_id, timestamp, kind) and users(user_id, zip, city). Choose how the filter is written, then step from the unresolved plan to the physical plan. The plans are simplified from what Spark 4.2 prints for this query. Illustrative sizes for the row readout: 1,000,000 events, 10,000 users; the 400 users in Melbourne have 40,000 events; 250,000 events are clicks.

  1. Keep "after the join" and step to the analysed plan. Before you press Next again: where will the Filter end up, and how many rows will the join produce?
    What you should see

    Below the join, directly above the users relation (with a new Project above it that keeps only user_id): city is a users column, so Catalyst tests it before joining. The join now builds 40,000 rows instead of 1,000,000. This is the insight of the widget: the optimizer moves the filter towards the data, whatever order you wrote it in.

  2. Switch to "before the join" and compare its optimised and physical plans with those of "after the join".
    What you should see

    They are identical, node for node. The two queries still differ in the unresolved and analysed plans; from the optimised plan on, Catalyst has made them the same. Write the filter wherever it reads best.

  3. Choose "after the join, on events.kind" and step to the physical plan. Which side receives the filter, and how many columns of users are read?
    What you should see

    The events side: kind is an events column. The users scan reads only user_id, 1 of 3 columns, because nothing above the join needs zip or city. Projection pruning works per table and per query.

  4. In the physical plan the filter has been pushed into the Parquet scan, yet a Filter node is still drawn above the scan. Why both?
    What you should see

    The scan uses the pushed filter to skip whole row groups whose statistics rule it out (next section); the rows of the row groups it does read are then checked one by one. Pushed filters are a help to the source, not a promise.

Physical planning turns the optimised plan into one or more physical plans of real operators, and a cost model selects one (p. 1278). In the Spark SQL paper it chose only the join algorithm — a broadcast join for a small table, as in the widget (background). The scans belong to data sources: "Spark's Data Source API allows optimizations like column pruning and filter pushdown into custom data sources" (p. 1268) — p. 1280's projection pruning and predicate pushdown. A source implements buildScan(requiredColumns: Array[String], filters: Array[Filter]): RDD[Row] (p. 1267): its two parameters are the two pushdowns. And since "DataFrame execution is done with Spark RDDs", custom code can drop to them: queryResult.rdd.mapPartitions { … }.

Code generation (p. 1283) "relies on Scala's quasiquotes": Catalyst "transforms a SQL tree into an abstract syntax tree (AST) for Scala code". The slide's generator is one pattern match:

def compile(node: Node): AST = node match {
  case Literal(value) => q"$value"
  case Attribute(name) => q"row.get($name)"
  case Add(left, right) =>
    q"${compile(left)} + ${compile(right)}"
}

Each q"…" is a code template. The tree of Fig. 2 becomes the line row.get("x") + 3, compiled once instead of walked for every row: for x+x+x a billion times, about 36 s interpreted against about 9 s generated, close to hand-written code (read off the chart), from a generator of "700LOC".

You say what; Catalyst decides how — it rewrites a tree with rules, chooses operators by cost and compiles the result.

Files that know their own shape: Avro and Parquet

Catalyst hands a scan requiredColumns and filters; what the scan does with them depends on the file. Page 1242 lists the "Available File Formats": text and CSV, JSON, SequenceFile ("binary key/value pair format"), Avro, Parquet, ORC ("optimized row columnar format") and "Data Frames". Three families hide in it: text (readable, slow to parse, no schema), binary rows (SequenceFile, Avro) and binary columns (Parquet, ORC).

Avro: rows, with the schema inside

Avro (p. 1243) is a "language neutral data serialization system": "write a file in python and read it in C". The schema is "language independent", "usually written in JSON", the data "encoded in binary format". It "supports schema evolution" — "producers and consumers at different versions of schema" — and "supports compression and are splittable". The tweets schema of p. 1244, repaired:

{
  "type": "record",
  "name": "tweets",
  "doc": "schema for storing tweets",
  "fields": [
    { "name": "username",  "type": "string" },
    { "name": "tweet",     "type": "string" },
    { "name": "timestamp", "type": "long" }
  ]
}

An Avro file (p. 1244) is a header — metadata holding the JSON schema, and a sync marker — then blocks: auxiliary data (record count and size), the serialized objects, and the same marker. That marker makes the file splittable: a task that starts mid-file, at an HDFS split, skips to the next marker and is aligned on a block. The schema is written once, so records carry no field names.

Parquet: columns inside row groups

"Apache Parquet is a columnar storage format available to any project in the Hadoop/Spark ecosystem, regardless of the choice of data processing framework, data model or programming language" (p. 1250). Page 1254 draws the difference on a table X, Y, Z: a row format stores x1 y1 z1 x2 y2 z2 …, a columnar format stores x1 … x5, then y1 … y5, then z1 … z5, each column as one "encoded chunk" — the column store of lesson 20, as a file.

header metadata: schema (JSON) sync marker block 1 aux. data r1 r2 r3 … sync block 2 aux. data r4 r5 r6 … sync split: skip to sync … block N r = (username, tweet, timestamp)

Avro: whole records, one after another

PAR1 row group 0 username tweet timestamp row group 1 footer schema per row group, per column chunk: offset, size, encodings, codec, min / max statistics footer length PAR1

Parquet: column chunks inside row groups

Fig. 5. Two files for the tweets of p. 1244. Left, the Avro layout of p. 1244: whole records in blocks, each block closed by the sync marker that a reader starting mid-file (orange) skips to. Right, the Parquet layout of pp. 1245 and 1252: row groups of column chunks, each chunk cut into pages (thin lines), and a footer that points (violet) at every chunk.

The units nest (pp. 1245, 1251). A row group is "a group of rows in columnar format": the "max size buffered in memory while writing", "one (or more) per split while reading", "roughly: 50MB < row group < 1 GB". A column chunk is "the data for one column in a row group" and "can be read independently". A page is the "unit of access in a column chunk", "big enough for compression to be efficient", "roughly: 8KB < page < 1MB". The footer (p. 1252) "contains column chunks offset and schema": per column chunk its type, encodings, codec, number of values, offsets and sizes. It comes last, so the writer can stream row groups out and append the index at the end; a reader reads it first and jumps to the chunks it needs.

"Configurable parquet parameters" (p. 1245), as printed. They are the defaults of the Hadoop Parquet library; Spark itself writes Parquet with Snappy compression unless told otherwise (background).
Property nameDefault valueDescription
parquet.block.size128 MBThe size in bytes of a block (row group).
parquet.page.size1MBThe size in bytes of a page.
parquet.dictionary.page.size1MBThe maximum allowed size in bytes of a dictionary before falling back to plain encoding for a page.
parquet.enable.dictionarytrueWhether to use dictionary encoding.
parquet.compressionUNCOMPRESSEDThe type of compression: UNCOMPRESSED, SNAPPY, GZIP & LZO

Mind the word block: p. 1245 calls a row group a block; p. 1260 means the HDFS block of lesson 31. No row group straddles two HDFS blocks — the slide packs two into one and pads the rest — so a task reads its row group from one machine.

"Columnar means the whole file is one column after another." Not in Parquet: the file is a sequence of row groups, and only inside a row group are the columns contiguous, so every record can be rebuilt from one row group.

Avro writes whole records one after another with the schema in its header; Parquet writes each column of a row group together and indexes the file in its footer.

Read less data: inside a Parquet file

Page 1257 lists three ways to "Read Less Data". Columnar organization: "Encoding: make the data smaller" and "Column projection: read only the columns you need". Row group filtering: "Use footer stats to eliminate row groups" and "Use dictionary pages to eliminate row groups". Page filtering: "Use page stats to eliminate pages". Page 1255 calls the first moves projection push down (read only the requested columns) and predicate push down (use statistics, such as each chunk's minimum and maximum, to skip what cannot match): together you "read only the data you need". The layout also "minimizes CPU cache misses": a column's values lie side by side, so the processor fetches only useful ones.

The dictionary of a column chunk (p. 1261) "is a compact list of all the values": "Search term missing? Skip the row group" — "like a bloom filter without false positives". Minimum and maximum rule a value out only when it lies outside their range; inside it they say "maybe". A dictionary answers exactly.

One file written twice: what a query reads

Twelve tweets with the schema of p. 1244, in time order. Above, an Avro file: four records per block, each closed by a sync marker (violet). Below, a Parquet file: three row groups of four rows, each made of three column chunks; the narrow violet cell is the dictionary page of the username chunk. Sizes in the picture: a username 8 bytes, a tweet 120, a timestamp 8 — 1,632 bytes in all. Headers, markers, dictionaries and the footer are left out of the counts.

  1. Keep the first query with row-group filtering off. Predict the share of the 1,632 bytes that Avro reads and the share that Parquet reads. Then switch on the footer statistics.
    What you should see

    Avro reads 100 %: to find the timestamps it must read every record whole. Parquet reads 192 bytes (11.8 %), the username and timestamp chunks of every row group; the big tweet column is never touched. With statistics, 64 bytes (3.9 %): row group 1 ends at 09:14 and row group 3 starts at 09:47, so neither is opened. The two savings multiply: 11.8 % of the bytes of each row group, times 1 of 3 row groups, is 3.9 %. That is the insight of the widget: Parquet skips whole column chunks and whole row groups, Avro can skip neither.

  2. Choose "text of the tweets by eva" with the footer statistics on. Predict which row groups min/max can skip. Then add the dictionary pages.
    What you should see

    None: in every row group the usernames run from ada to ugo or to eva, and eva lies inside each range, so min/max can only answer "maybe" — 1,536 bytes. The dictionaries are exact: row groups 1 and 3 contain only ada, bob and ugo, so both are skipped and 512 bytes remain. "Like a bloom filter without false positives" (p. 1261).

  3. Choose "every field of every tweet". Compare the two files.
    What you should see

    Both read all 1,632 bytes: with no filter and every column needed, the columnar layout saves nothing, and Parquet must even stitch each record back together from three chunks. A row format suits writing and reading whole records; a columnar format suits analytics over a few columns.

Encodings: make the data smaller

Before any compressor runs, Parquet encodes each column with a trick that fits its content (pp. 1256, 1258; the examples are ours).

The encodings of pp. 1256 and 1258.
EncodingStoresGood for (slide)Example
Deltathe first value, then the differencestimestamps: subsequent values are close1000, 1003, 1004, 1009 → 1000, +3, +1, +5
Prefixthe length of the prefix shared with the previous string, then the rest"delta encoding for strings"Milano, Milazzo → Milano, (4, zzo)
Dictionaryeach distinct value once, then small integer keys"small set of values, e.g post code, ip addresses"Como, Lecco, Como, Como → [Como, Lecco] + keys 0 1 0 0
Run length (RLE)a value and the length of its run"repeating data"; definition levels of sparse columns0 0 0 0 0 0 1 → (0, 6) (1, 1)
Bit packingsmall integers in the fewest bits"repetition level, definition levels and dictionary keys"keys 0–3 in 2 bits each instead of 32

Dictionary encoding is "useful for columns with few ( < 50,000 ) distinct values" and "when applicable, compresses better and faster than heavyweight algorithms". Bit packing stores small integers "in the minimum bits required": a value whose maximum is \(m\) needs \(\lceil \log_2(m+1) \rceil\) bits, so 16 bits cover any key below 65,536. Parquet 2.0 (p. 1259) adds "more encodings: compact storage without heavyweight compression" (delta encodings "for integers, strings and sorted dictionaries"), "statistics: to be used by query planners and predicate pushdown" and a "new page format: to facilitate skipping ahead at a more granular level".

"Dictionary encoding always pays." Only for few distinct values: beyond 1 MB of dictionary (parquet.dictionary.page.size, p. 1245) the writer falls back to plain encoding, and the chunk has no complete dictionary left to skip with.

Nested records in flat columns

Parquet's "key strength is to store nested data in truly columnar format using definition and repetition levels" (p. 1254). The technique comes from Google's Dremel; p. 1253 applies it to a schema with an optional group and two repeated fields.

Document required optional DocId Links repeated repeated Backward Forward max R 0 · D 0 20 R0 D0 max R 1 · D 2 10 R0 D2 30 R1 D2 max R 1 · D 2 80 R0 D2 one column per leaf
Fig. 6. The schema and the record of p. 1253 in one picture. Every leaf (blue) becomes a column; under it, its maximum levels and the record's values with their repetition level R and definition level D. Optional and repeated fields count towards D, only repeated ones towards R.

Next to each stored value Parquet writes two small integers. The definition level D says how many of the optional or repeated fields on the value's path are actually present; the repetition level R says at which repeated field the value continues a list, 0 meaning that a new record starts. The slide's record, shredded step by step:

  1. Read the columns off the schema, one per leaf: DocId, Links.Backward, Links.Forward. The groups Document and Links store nothing themselves.

  2. Count, on each path, the fields that may be missing:

    \[ D_{\max} = \#\ \text{optional or repeated fields} \]

    DocId is required: 0. Links is optional and Backward repeated: 2; Forward too. A value must say how deep its path is defined.

  3. Count only the repeated fields:

    \[ R_{\max} = \#\ \text{repeated fields} \]

    0 for DocId, 1 for each list: with one repeated field on the path, R is 0 or 1.

  4. Shred DocId: 20: a required value at the start of a record gives (20, R 0, D 0).

  5. Shred Backward: 10, 30. The first value starts the record's list (R 0) with Links and Backward present (D 2): (10, 0, 2). The second continues the list at Backward, the first repeated field: (30, 1, 2).

  6. Shred Forward: 80, the record's first Forward: (80, 0, 2). These are the four rows of p. 1253; with maxima 1 and 2, bit packing stores R in 1 bit and D in 2.

  7. Not on the slide, same rules: a record without Links stores NULL in both Links columns with R 0, D 0 — the path stops before Links. R = 0 tells the assembler a new record begins, so it rebuilds the record exactly.

Parquet skips at three levels — column chunks, row groups, pages — and turns nesting into two tiny integers per value.

Beyond queries: extensions, JSON, pipelines, speed

Two "Extensions" complete the design (p. 1284). A new data source "must implement a createRelation function that takes a set of key-value params and returns a BaseRelation object" — "E.g. CSV, Avro, Parquet, JDBC". A user-defined type (UDT) maps "user-defined types to structures composed of Catalyst's built-in types": PointUDT stores a Point as a struct of two doubles, x and y, with serialize and deserialize. Catalyst never needs to understand a Point, only the struct.

Schema inference for JSON

JSON carries no schema, yet Spark SQL "automatically infers schema from a set of records, in one pass or sample" (p. 1285). Each record yields "a tree of STRUCT types"; each field gets "the most appropriate type … based on all data observed in that column"; the per-record schemas are merged "in one reduce operation" — possible because merging schemas is associative, like the (sum, count) pairs. The slide's three tweets:

The three records of p. 1285 and the schema the slide prints for them.
FieldRecord 1Record 2Record 3Inferred (p. 1285)
texta stringa stringa stringSTRING NOT NULL
tags["#Spark"][]["#tweet", "#location"]ARRAY<STRING NOT NULL> NOT NULL
loc.lat45.139no locFLOAT NOT NULL inside loc
loc.long9088.5no locFLOAT NOT NULL inside loc
locpresentpresentmissingSTRUCT<lat …, long …>, nullable

An integer in one record and a fraction in another make FLOAT, the most specific type holding both; loc is missing from record 3, so it gets no NOT NULL. Spark 4.2 reads the same records as struct<lat: double, long: double>, every field nullable (executed): the slide's reasoning, newer type names. "Same trick for Python typing" is the data sampling of p. 1274, and all of it is the schema-on-read of lesson 4.

ML pipelines speak DataFrame

Page 1286, repeated on p. 1288, chains three stages of Spark's machine-learning library, MLlib (quotes straightened):

from pyspark.ml import Pipeline
from pyspark.ml.feature import Tokenizer, HashingTF
from pyspark.ml.classification import LogisticRegression

tokenizer = Tokenizer(inputCol="text", outputCol="words")
hashingTF = HashingTF(inputCol="words", outputCol="features")
lr = LogisticRegression(maxIter=10, regParam=0.01)
pipeline = Pipeline(stages=[tokenizer, hashingTF, lr])
# df: a "text" and a "label" column; returns a PipelineModel
model = pipeline.fit(df)
Pipeline Model ds0 text, label tokenizer ds1 + words hashingTF ds2 + features lr fit produces lr.model ds3 + prediction
Fig. 7. The pipeline of p. 1286, drawn top to bottom. Every stage returns its input DataFrame with one more column (left; executed, ds3 actually gains rawPrediction, probability and prediction). The estimator lr is fitted on ds2 and produces lr.model; the fitted Pipeline Model (dotted) holds only transformers.

tokenizer and hashingTF only transform; lr is an estimator, whose fit on ds2 produces the transformer lr.model. That is why lr sits outside the dotted "Pipeline Model" box and lr.model inside (executed). If you have used scikit-learn pipelines, fit and transform mean the same here.

The twelve times, explained

Page 1287 measures the promises (Fig. 8).

An aggregation (left chart), seconds Python API Scala API DataFrame ≈ 180 ≈ 28 ≈ 15 12 times less 0 50 100 150 200 Filter, then word count (right chart), seconds SQL + Spark DataFrame filter ≈ 565 count ≈ 325 ≈ 405 2 times less 0 200 400 600 800 1000
Fig. 8. The two charts of p. 1287 (Figures 9 and 10 of the Spark SQL paper), redrawn. The slide prints no numbers: every value is read off its bars. Teal: hand-written RDD code, or an SQL query followed by a separate Spark job (solid: filter, dashed: word count); magenta: the DataFrame.

Back to the opening puzzle. Lambdas are black boxes that Spark can only call, row by row, in a Python process; the one-liner is a tree it can read, and that gives three answers. Catalyst can rewrite it. Only the plan is built in Python and the work runs as JVM bytecode: the paper credits this for the 12×, and code generation, which "avoids expensive allocation of key-value pairs", for the 2× over Scala. And the plan names its columns and filters, so the data source can be asked for less: with Parquet, only the needed column chunks and row groups leave the disk — the answer in the file. The right chart adds that one DataFrame job beats an SQL query plus a separate Spark job by about 2×: nothing is saved to HDFS in between (paper).

The DataFrame wins because Spark sees the whole plan: it can rewrite it, compile it and ask the file for less.

Exam-style questions

Catalyst, the design of Spark SQL and the file formats are theory: open, multiple-choice and true-or-false questions in 10–15 minutes, where wrong answers may carry a penalty. The practical part's SparkQL exercise wants PySpark DataFrame code ("you are not allowed to use SQL syntax", paper of 30 January 2026); the last question is one in the wording of the past papers, and lesson 39 solves the papers' own.

Describe the phases of query optimization in Spark SQL's Catalyst, from a query written as SQL or with the DataFrame API to the RDDs that execute it. Which phase uses a cost model, and for what?

Show model answer

An SQL string (parsed into an AST) and a DataFrame both produce an unresolved logical plan: the same pipeline serves both. Analysis resolves it with the catalog — it looks up relations by name, maps named attributes to the inputs of each operator, gives each attribute a unique id and propagates and coerces types — and yields a logical plan. Logical optimization applies rule-based rewrites such as constant folding, predicate pushdown, projection pruning, null propagation and boolean expression simplification, producing an optimized logical plan. Physical planning generates one or more physical plans made of Spark operators, and a cost model selects one (for example the join algorithm: a broadcast join for a small table). Code generation finally compiles the selected plan, using Scala quasiquotes, into code that runs as RDD operations. Only physical planning is cost-based; the other phases apply rules.

A complete answer gives the four phases in order, what goes in and comes out of each, the catalog in analysis and the cost model only in physical planning. Name a few rules of logical optimization too: they are the easiest part to leave out.

What is an unresolved attribute in Spark SQL? Using the query SELECT col FROM sales, explain what the analyzer does, and what happens when sales has no column col.

Show model answer

An attribute is unresolved if its type is not known or it is not matched to an input table. After parsing, col is only a name and sales an unresolved relation. The analyzer looks up sales by name in the catalog, matches col to one of its columns, gives it a unique id so that every reference to the same value is recognised, and propagates and coerces types through expressions (in 1 + col the literal is cast to the type of col). If sales has no column col, analysis fails at once with an error, before any data is read: the logical plan is analyzed eagerly, even though the query is executed lazily.

The core is the definition in the slide's words and the catalog lookup. "Eager analysis, lazy execution" shows that you understand why the error comes early.

Which of the following are rule-based optimizations of Catalyst's logical optimization phase, as listed in the lecture? Select all that apply.

  • Constant folding Listed on p. 1280; the rule of p. 1276 turns x + (1 + 2) into x + 3.
  • Predicate pushdown Listed on p. 1280: a filter moves towards the data, below projections and joins.
  • Projection pruning Listed on p. 1280: only the columns used above are kept.
  • Choosing a broadcast join because a table is small That choice belongs to physical planning, where the cost model selects among physical plans.
  • Null propagation Listed on p. 1280 among the standard rules.
  • Generating Java bytecode with quasiquotes That is the last phase, code generation (p. 1283), not logical optimization.
Show model answer

Constant folding, predicate pushdown, projection pruning and null propagation (with boolean expression simplification, the list of p. 1280). The join algorithm is chosen in physical planning by the cost model; bytecode is produced by code generation.

The distractors are real Catalyst work, done in another phase. Wrong answers may carry a penalty, so the phase of each optimization is what to know for sure.

True or false: because DataFrames are evaluated lazily, a reference to a column that does not exist is reported only when an action such as show() or collect() runs.

  • True Execution is lazy, but the logical plan is analyzed eagerly (p. 1273): a wrong column name fails when the line is written.
  • False "Logical plan analyzed eagerly → identify code errors associated with data schema issues on the fly" (p. 1273).
Show model answer

False. Spark SQL analyzes the logical plan eagerly: column names and types are checked against the catalog as soon as the DataFrame operation is defined, while the data is processed only when an action runs.

The trap is to stretch "lazy" from execution to analysis. Analysis needs only metadata, so it is cheap to do at once.

Describe the internal structure of a Parquet file and explain how it lets a query that needs two columns and a filter read less data than a row-oriented format such as Avro.

Show model answer

A Parquet file is a sequence of row groups followed by a footer, with the magic number PAR1 at both ends. A row group holds a set of rows in columnar format (parquet.block.size, 128 MB by default) and never straddles two HDFS blocks; inside it each column is stored contiguously as a column chunk, and each column chunk is divided into pages, the unit of access, encoding and compression (1 MB by default). The footer stores the schema and, for every row group and column chunk, its offset, size, encodings, codec and statistics such as min and max. A reader reads the footer first. With projection pushdown it reads only the column chunks of the two requested columns. With predicate pushdown it uses the footer statistics and the dictionary pages to skip row groups that cannot contain matching rows, and page statistics to skip pages. Avro stores whole records one after another, so the same query must read every byte of every record.

A complete answer has the hierarchy (row group, column chunk, page), the footer's content and both kinds of pushdown, each tied to the structure that makes it possible.

Consider the Parquet schema message Document { required int64 DocId; optional group Links { repeated int64 Backward; repeated int64 Forward; } }.

(a) Give the maximum repetition and definition levels of each column. (b) Shred the record DocId: 10, Links { Forward: 20, Forward: 40, Forward: 60 }, which has no Backward value, into (value, R, D) triples.

Show model answer

(a) DocId: max R 0, max D 0 (required, nothing optional or repeated on its path). Links.Backward and Links.Forward: max R 1 (one repeated field on the path), max D 2 (Links is optional, the field itself repeated).

(b) DocId: (10, 0, 0). Links.Backward: (NULL, 0, 1) — Links is present but Backward is not, so one of the two fields of the path is defined; R 0 because the value belongs to a new record. Links.Forward: (20, 0, 2), (40, 1, 2), (60, 1, 2) — the first value starts the record's list, the next two repeat at level 1, and both Links and Forward are defined.

The slide's record (p. 1253) has a Backward list; this one does not, and the NULL with D = 1 is the point of the exercise. Max D counts optional and repeated fields, max R only repeated ones.

Consider the following ER scenario: a city app tracks its Users (User_ID, Name, Age, City) and the Events they generate (Event_ID, Timestamp, Kind). Each user generates zero or more events (0,N); each event is generated by exactly one user (1,1). Consider the following Users and Events tables.

User_IDNameAgeCity
U1Ada31Melbourne
U2Bob24Sydney
…………
Event_IDUser_IDTimestampKind
E1U12026-03-01 09:12click
E2U22026-03-01 09:15view
…………

(a) Write a SparkQL query to compute the average age of the users of each city.

(b) Write a SparkQL query to return the timestamps of the "click" events generated by users living in Melbourne.

(c) Write a SparkQL query to compute the total number of events for each city, returning only the cities whose total is greater than 2.

Note that you are not allowed to use SQL syntax. Use PySpark instead.

Show model answer
from pyspark.sql import SparkSession
from pyspark.sql import functions as F

spark = SparkSession.builder.appName("SMBUD").getOrCreate()
# users and events: DataFrames with the columns of the two tables

# (a) average age per city
users.groupBy("City").agg(F.avg("Age").alias("Avg_Age")).show()

# (b) timestamps of the clicks of users living in Melbourne
(events.join(users, "User_ID")
       .filter((F.col("City") == "Melbourne")
               & (F.col("Kind") == "click"))
       .select("Timestamp")
       .show())

# (c) events per city, only cities above 2 (the HAVING)
(events.join(users, "User_ID")
       .groupBy("City")
       .agg(F.count("Event_ID").alias("N_Events"))
       .filter(F.col("N_Events") > 2)
       .show())

Executed on four users (U1 Ada 31 and U3 Eva 27 in Melbourne, U2 Bob 24 in Sydney, U4 Ugo 45 in Perth) and seven events (E1 U1 click 2026-03-01 09:12, E2 U2 view, E3 U1 purchase, E4 U3 click 2026-03-02 18:05, E5 U2 click, E6 U3 view, E7 U4 click): (a) Melbourne 29.0, Perth 45.0, Sydney 24.0; (b) 2026-03-01 09:12 and 2026-03-02 18:05; (c) Melbourne 4.

What to get right: the join on the key column's name, join(users, "User_ID"), which keeps one User_ID column; each comparison in parentheses combined with &, groupBy with agg, and the HAVING as a filter on the aggregate's alias. In (b) it does not matter whether you filter before or after the join: Catalyst pushes both conditions below it, as the widget of this lesson shows.

Check yourself

You move .where(users.city == "Melbourne") from after the join to before it. What happens to the optimised plan?

  • Nothing: it is the same plan Catalyst pushes the filter below the join either way; the widget showed identical optimised and physical plans.
  • It gets faster, because the filter now runs first It runs first in both cases: predicate pushdown already moved it there.
  • It gets slower, because the join loses rows it needs The filter removes only rows that the query would drop anyway; the result is the same.
  • It fails, because users has no timestamp The timestamp comes from events; filtering users first is perfectly valid.

A Parquet file has 10 equally large columns in 8 row groups. A query needs 2 columns, and the footer statistics prove that only 2 row groups can hold matching rows. Roughly what share of the file is read?

  • 5 % The two skips multiply: 2/10 of the columns times 2/8 of the row groups is 1/20.
  • 20 % That counts only the column projection and forgets the skipped row groups.
  • 25 % That counts only the row groups and forgets that 8 of the 10 columns are not read.
  • 45 % The two savings do not add up; they multiply.

Why can Spark not move the filter of rdd.map(f).filter(g) below the map, as Catalyst does with a DataFrame?

  • f and g are opaque functions: Spark cannot see which fields they read or write Pushdown needs to know that the filter does not depend on what the map computes; a lambda does not tell.
  • Because RDDs are immutable DataFrames are immutable too, and Catalyst rewrites their plans all the time.
  • Because RDD operations are evaluated eagerly RDD transformations are lazy as well; laziness is not the obstacle.
  • Because Catalyst works only on Scala code Catalyst optimizes Python DataFrame code too; only the plan is built in Python.

Which statements about the footer of a Parquet file are true?

  • It is written after all the row groups The writer streams the row groups out and appends the footer last, in one pass.
  • It holds the schema and, for each column chunk, its offset and statistics "A footer contains column chunks offset and schema" (p. 1252); the statistics are the "footer stats" of p. 1257.
  • A reader reads it before any column chunk It needs the offsets to jump to the chunks, and the statistics to skip row groups.
  • It keeps a copy of the first row group for quick previews The footer holds metadata only, no data.
  • It sits at the end because HDFS cannot read files from the start HDFS reads from any offset; the reason is single-pass writing.

Same schema as p. 1253. A record has DocId: 7 and no Links at all. What does the column Links.Backward store for it?

  • NULL with R = 0 and D = 0 Nothing on the path is defined, so D = 0; the value belongs to a new record, so R = 0.
  • Nothing at all Then a reader could not tell where one record ends in this column: every record leaves a trace in every column.
  • NULL with R = 0 and D = 1 D = 1 would mean that Links is present and only Backward is missing.
  • NULL with R = 1 and D = 2 D = 2 means a real Backward value, and R = 1 a continued list: neither is the case.

A column chunk of city has the dictionary {Como, Lecco, Milano}; its statistics are min Como, max Milano. Which filter lets the reader skip the row group thanks to the dictionary, although min/max alone would not?

  • city == "Lodi" Lodi lies between Como and Milano, so min/max says "maybe"; the dictionary says it is not there.
  • city == "Monza" Monza comes after Milano: min/max alone already skips the row group.
  • city == "Lecco" Lecco is in the dictionary: the row group must be read.
  • city > "Bari" Every value of the chunk is after Bari: nothing can be skipped.

In the MLlib pipeline of p. 1286, which objects end up inside the fitted PipelineModel?

  • tokenizer A transformer: it is used as it is.
  • hashingTF A transformer too.
  • lr.model The transformer that lr.fit produced on ds2.
  • lr The estimator is used once, by fit; that is why the slide draws it outside the dotted box.
  • ds0 The input data is not a stage of the model.

Key take-aways

What to watch and read