9.2 Data Sources, SaveModes, and Partitioning
Key Takeaways
The
DataFrameReader(spark.read) andDataFrameWriter(df.write) interfaces provide a unified, pluggable API supporting formats including CSV, JSON, Parquet, ORC, and Delta Lake.Parquet is Spark's default storage format, featuring columnar layout, binary dictionary encoding, Snappy compression, and a metadata footer that enables predicate pushdown and column pruning.
DataFrameWriter.mode()defaults to"error"(or"errorifexists"), throwing anAnalysisExceptionif destination files or tables already exist.Directory partitioning via
partitionBy()creates subdirectories (col=value/) that allow downstream queries to skip irrelevant data (partition pruning), but high-cardinality keys must be avoided to prevent the small file problem.Bucketing via
bucketBy()andsortBy()hashes and sorts records into a fixed bucket assignment when saving a supported catalog table with saveAsTable(); compatible bucket metadata can allow a join exchange to be avoided.
Data ingestion and data persistence represent the boundaries of any Apache Spark application. Spark provides a unified, format-agnostic abstraction for reading data from external storage into DataFrames via the DataFrameReader interface, and writing DataFrames back to storage via the DataFrameWriter interface. This section covers the read/write APIs, storage format trade-offs, write modes, and physical layout optimization techniques including directory partitioning and table bucketing.
1. The Unified Data Source API
Apache Spark standardizes all data ingestion and persistence through a consistent, fluent builder pattern:
spark.read.format(...).option(...).load(...) --> DataFrameReader
df.write.format(...).mode(...).option(...).save(...) --> DataFrameWriter
The DataFrameReader Interface
# Generic DataFrameReader syntax
df = spark.read \
.format("csv") \
.option("header", "true") \
.option("inferSchema", "true") \
.option("sep", ",") \
.load("dbfs:/data/raw_transactions.csv")
# Equivalent format-specific shortcut syntax
df = spark.read.csv(
"dbfs:/data/raw_transactions.csv",
header=True,
inferSchema=True,
sep=","
)
The DataFrameWriter Interface
# Generic DataFrameWriter syntax
df.write \
.format("parquet") \
.mode("overwrite") \
.option("compression", "snappy") \
.save("dbfs:/data/processed_transactions.parquet")
# Equivalent format-specific shortcut syntax
df.write.mode("overwrite").parquet("dbfs:/data/processed_transactions.parquet")
2. Storage Formats: Row-Oriented, Columnar, and Delta Lake
Choosing the appropriate file format fundamentally dictates storage footprint, read latency, and cluster compute costs.
| Format | Storage Layout | Schema Handling | Compression | Best Use Case |
|---|---|---|---|---|
| CSV | Row-oriented text | No embedded schema; requires inferSchema (two scans) or explicit schema | Text compression (gzip) | Legacy data exchange, external third-party feeds |
| JSON | Row-oriented semi-structured | Self-describing keys per record; schema inferred or supplied | Text compression (gzip) | API payloads, web event logs (use multiLine=True for pretty-printed JSON) |
| Parquet | Binary columnar | Embedded schema and statistics in file footer | Column-level compression (Snappy, Zstandard) | Spark default format; high-performance analytical queries and BI |
| ORC | Binary columnar | Embedded schema and stripe statistics | Column-level compression (ZLIB, Snappy) | Highly optimized for Apache Hive environments |
| Delta Lake | Parquet files + ACID log | Embedded transaction log (_delta_log/) | Snappy (inherited from Parquet) | Production lakehouses requiring ACID transactions, time travel, and schema evolution |
Why Parquet is the Analytical Gold Standard
Parquet is Apache Spark's default storage format (spark.sql.sources.default=parquet). Unlike CSV or JSON, which store data row-by-row as plain text, Parquet stores data in binary columnar format:
- Column Pruning: When a query executes
df.select("user_id", "amount"), Spark's Parquet reader reads only the byte ranges corresponding to those two columns from disk or cloud storage. All other columns are completely bypassed, drastically reducing network and disk I/O. - Predicate Pushdown: Parquet files organize records into horizontal blocks called Row Groups (typically 128 MB each). The Parquet file footer stores metadata statistics—specifically minimum and maximum values—for every column chunk within each row group. When a query contains a filter like
.filter("amount > 1000"), Spark inspects the footer metadata first. If a row group's maximumamountis 450, Spark skips reading the entire 128 MB row group entirely. - Efficient Encodings: Because columns contain homogeneous data types, Parquet applies dictionary encoding, bit packing, and run-length encoding, often reducing size relative to text; actual ratios depend on data, encoding and codec.
3. SaveModes: Controlling Destination Write Behavior
When writing a DataFrame to a destination path or table that already exists, the SaveMode controls how Spark handles the conflict. Specify the mode via df.write.mode(mode_string):
| SaveMode String | Java Enum Equivalent | Behavior if Target Exists | Behavior if Target Does Not Exist |
|---|---|---|---|
"error" / "errorifexists" | SaveMode.ErrorIfExists | Default. Throws an AnalysisException halting execution | Creates target directory and writes data |
"append" | SaveMode.Append | Appends new files alongside existing files | Creates target directory and writes data |
"overwrite" | SaveMode.Overwrite | Deletes existing contents and replaces with new data | Creates target directory and writes data |
"ignore" | SaveMode.Ignore | Silently does nothing; does not overwrite or write new data | Creates target directory and writes data |
# Example of setting SaveMode
df.write.mode("append").parquet("dbfs:/mnt/datalake/events")
Caution
Non-Atomic Cloud Overwrites: On standard cloud object stores (Amazon S3, Azure Blob Storage), a standard Parquet overwrite operation is not atomic. Spark deletes files in the bucket and uploads new ones; if a failure occurs halfway through, the dataset is left corrupted or empty. In contrast, Delta Lake writes new files first and commits atomically via its transaction log, guaranteeing ACID durability.
4. Physical Storage Layout: Partitioning vs. Bucketing
Organizing physical files on disk to match common query filter and join patterns is the most effective way to eliminate unnecessary I/O.
Directory Partitioning (partitionBy)
df.write.partitionBy(*cols) divides output data into physical subdirectories based on the distinct values of the specified partitioning columns:
# Partitioning transactions by year and month
df.write \
.partitionBy("year", "month") \
.parquet("dbfs:/data/transactions")
This creates a nested directory structure on the filesystem:
dbfs:/data/transactions/
├── year=2026/
│ ├── month=09/
│ │ ├── part-00000.snappy.parquet
│ │ └── part-00001.snappy.parquet
│ └── month=10/
│ ├── part-00000.snappy.parquet
│ └── part-00001.snappy.parquet
When downstream queries run df.filter("year = 2026 AND month = 10"), Spark performs partition pruning, reading only files within the year=2026/month=10/ directory and ignoring all other folders.
Important
The Small File Problem (Anti-Pattern): Never partition by a high-cardinality column (such as timestamp, user_id, or order_id). Partitioning across millions of unique keys creates millions of tiny subdirectories containing files of only a few kilobytes. This exhausts cloud storage API rate limits, degrades listing performance, and causes massive driver memory overhead.
Table Bucketing (bucketBy and sortBy)
For high-cardinality columns where directory partitioning is unsuitable, use Bucketing:
# Bucketing data by customer_id into 16 hash buckets
df.write \
.bucketBy(16, "customer_id") \
.sortBy("transaction_date") \
.saveAsTable("bucketed_transactions")
Key rules and benefits of bucketing:
- Potential Join Exchange Reuse: When two tables are bucketed on the same join key with the same number of buckets (or multiples thereof), Spark may avoid a join shuffle when compatible bucketing is recognized and enabled; sort requirements and provider support still apply. Bucket count need not equal physical file count. It can perform a SortMergeJoin with fewer exchanges. Each bucket from Table A joins directly with the corresponding bucket from Table B locally.
- Persistent Table Requirement:
bucketBy()works only withsaveAsTable(). It cannot be used with raw file path writes (.save("path")) because bucketing metadata must be tracked in the Spark metastore/catalog.
Which of the following architectural features of Apache Parquet allows Spark to skip reading entire 128 MB row groups when evaluating a filter condition like .filter('transaction_amount > 5000')?
File metadata footer statistics recording minimum and maximum column values for each row group
Tungsten binary row encoding with fixed-width byte offsets
Snappy block-level streaming decompression buffers
In-memory broadcast join hints stored in the file headers
A data engineer runs df.write.parquet('s3://lakehouse/orders'). If the destination path s3://lakehouse/orders already contains data files from a prior run, what is the default behavior?
Spark appends the new files alongside existing files without modifying existing data.
Spark raises an AnalysisException because the default SaveMode is ErrorIfExists.
Spark deletes the existing directory and writes the new DataFrame files.
Spark silently ignores the write operation and exits without an error.
For supported Spark catalog Parquet tables, which writer API records fixed hash buckets and local sorting metadata that the optimizer may use to avoid a later compatible join exchange?
partitionBy('account_id') using .save('path') to generate hash-partitioned subdirectories
repartition(numPartitions, 'account_id') saved with SaveMode.Append to cloud object storage
bucketBy(numBuckets, 'account_id').sortBy(...) used with .saveAsTable('tableName')
coalesce(numPartitions) used with .parquet('path') to force local executor colocation
Sections you finish are checked off in the contents.