05 · Reading Data (CSV, JSON, Parquet)¶
Not executed against a live cluster in this environment
Code and sample outputs below are hand-traced against documented PySpark reader behavior, not run against a live cluster here.
Every spark.read call follows the same pattern: pick a format
(.csv, .json, .parquet, ...), configure options for that format, and
call .load(path) or the format-specific shortcut (.csv(path),
.json(path), .parquet(path)). All of them return a lazily-evaluated
DataFrame — nothing is read from disk until an action is called.
Reading CSV¶
Suppose orders.csv looks like this:
order_id,customer,country,amount,order_date
1,Alice,US,120.50,2026-01-05
2,Bob,IN,45.00,2026-01-06
3,Carla,US,300.25,2026-01-06
4,Deepak,DE,60.00,2026-01-07
df = spark.read.csv(
"orders.csv",
header=True, # first row is column names, not data
inferSchema=True, # scan the data to guess types (int, double, string...)
)
df.printSchema()
# root
# |-- order_id: integer (nullable = true)
# |-- customer: string (nullable = true)
# |-- country: string (nullable = true)
# |-- amount: double (nullable = true)
# |-- order_date: string (nullable = true) <- note: string, not date, unless told otherwise
df.show()
inferSchema=True is convenient but has a real cost: Spark must read the
file (or a sample of it, depending on version/settings) twice — once to
infer types, once to actually load — which matters on large files. It also
can guess wrong (note order_date came back as string, not date,
because CSV has no native date type — inference only sees text). Module 7
covers specifying an explicit schema to avoid both problems.
Useful CSV options:
df = spark.read.csv(
"orders.csv",
header=True,
inferSchema=True,
sep=",", # delimiter (use "\t" for TSV)
nullValue="NA", # treat the literal string "NA" as null
mode="PERMISSIVE", # default: malformed rows become null-filled rows
# instead of failing the whole read
)
mode is worth knowing about early: "PERMISSIVE" (default) keeps
malformed rows with nulls in place of bad fields, "DROPMALFORMED" silently
drops them, and "FAILFAST" throws an error as soon as one is found — pick
"FAILFAST" when you'd rather stop and investigate than silently lose rows.
Reading JSON¶
Spark expects line-delimited JSON by default (one JSON object per line), not a single JSON array spanning the whole file:
{"user_id": 1, "event": "login", "ts": "2026-01-05T10:00:00"}
{"user_id": 2, "event": "click", "ts": "2026-01-05T10:01:15"}
{"user_id": 1, "event": "logout", "ts": "2026-01-05T10:15:00"}
df = spark.read.json("events.json")
df.printSchema()
# root
# |-- event: string (nullable = true)
# |-- ts: string (nullable = true)
# |-- user_id: long (nullable = true)
df.show(truncate=False)
Notice Spark infers schema from JSON automatically (types are explicit in
JSON — numbers, strings, booleans — so this is more reliable than CSV
inference, though nested/array fields can still need attention). If your
file is a single JSON array ([{...}, {...}]) rather than line-delimited,
pass multiLine=True:
Reading Parquet¶
Parquet is a columnar, binary, self-describing file format — it stores its own schema and per-column statistics inside the file, and Spark reads it more efficiently than text formats (CSV/JSON) because it can skip columns you don't need and skip entire row groups based on stored min/max stats matching your filters.
df = spark.read.parquet("events.parquet")
df.printSchema() # schema comes from the file itself — no inference guesswork
df.show()
No header, inferSchema, or sep options needed — Parquet already knows
its own schema and types exactly, which is one of the reasons Module 9
(writing data) and later levels favor Parquet as the default storage format
for anything beyond a raw drop zone.
Reading multiple files / a directory at once¶
All three readers accept a directory path or a glob and will read every matching file as one logical DataFrame:
df = spark.read.parquet("data/orders/") # every file in the folder
df = spark.read.csv("data/logs/2026-01-*.csv", header=True, inferSchema=True)
This is exactly how Spark reads partitioned datasets written by .write
(Module 9) or by other systems — a "table" in the Spark/data-lake world is
usually just a directory of many files that get read together as one
DataFrame.
Worked example: comparing the three for the same data¶
Given the same logical orders data available in all three formats, reading each and checking row count and schema:
csv_df = spark.read.csv("orders.csv", header=True, inferSchema=True)
json_df = spark.read.json("orders.json")
parquet_df = spark.read.parquet("orders.parquet")
for name, d in [("csv", csv_df), ("json", json_df), ("parquet", parquet_df)]:
print(name, d.count()) # all three print the same row count, e.g. 4
parquet_df.printSchema() # most trustworthy schema: stored, not inferred
csv_df.printSchema() # order_date likely comes back as string
The row counts should always agree (same underlying data); the schemas may not, because CSV/JSON schema inference is a best-effort guess while Parquet's schema is exact and stored.
How It Actually Works¶
spark.read.csv(...) with no schema forces a two-pass read when you set
inferSchema=True: Spark first scans the entire file (or a sample) to guess
column types, then re-reads it to actually parse the data — doubling I/O for
large files. Under the hood, this scan is itself a Spark job: it's split
into one task per file split (roughly one per HDFS/S3 block, or per file for
small files), each task independently infers types for its chunk, and the
driver merges the per-task type guesses into one final schema before the
real read job runs. Reading Parquet is fundamentally cheaper because Parquet
files embed their schema and per-column statistics (min/max, null counts) in
a footer, so Spark reads just that footer metadata to build the DataFrame's
schema instantly, and later filters on that data can skip entire row groups
without decompressing them — a technique called predicate pushdown, which
plain CSV cannot support because it has no such metadata to consult upfront.
Exercise¶
You're given a directory sales/ containing daily CSV exports named
sales_2026-01-01.csv, sales_2026-01-02.csv, etc., each with columns
sale_id,product,quantity,price.
- Write the
spark.readcall to load all of them as a single DataFrame, with headers and schema inference enabled. - Suppose two of the daily files have an extra malformed row (missing a
field). Which
modeoption would let the read succeed while still letting you find and inspect those bad rows afterward (rather than silently dropping or crashing on them)? - If you later receive the same dataset as Parquet instead of CSV, what changes about how confident you can be in the inferred schema, and why?
Answer for (2): mode="PERMISSIVE" (the default) — malformed rows are kept
with nulls filling missing fields, rather than being dropped
("DROPMALFORMED") or aborting the whole read ("FAILFAST"), so you can
filter for nulls afterward to find exactly which rows had problems.