9.2 Data Sources, SaveModes, and Partitioning

Key Takeaways

  • The DataFrameReader (spark.read) and DataFrameWriter (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 an AnalysisException if 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() and sortBy() 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.

Last updated: October 2026

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.

FormatStorage LayoutSchema HandlingCompressionBest Use Case
CSVRow-oriented textNo embedded schema; requires inferSchema (two scans) or explicit schemaText compression (gzip)Legacy data exchange, external third-party feeds
JSONRow-oriented semi-structuredSelf-describing keys per record; schema inferred or suppliedText compression (gzip)API payloads, web event logs (use multiLine=True for pretty-printed JSON)
ParquetBinary columnarEmbedded schema and statistics in file footerColumn-level compression (Snappy, Zstandard)Spark default format; high-performance analytical queries and BI
ORCBinary columnarEmbedded schema and stripe statisticsColumn-level compression (ZLIB, Snappy)Highly optimized for Apache Hive environments
Delta LakeParquet files + ACID logEmbedded 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:

  1. 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.
  2. 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 maximum amount is 450, Spark skips reading the entire 128 MB row group entirely.
  3. 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 StringJava Enum EquivalentBehavior if Target ExistsBehavior if Target Does Not Exist
"error" / "errorifexists"SaveMode.ErrorIfExistsDefault. Throws an AnalysisException halting executionCreates target directory and writes data
"append"SaveMode.AppendAppends new files alongside existing filesCreates target directory and writes data
"overwrite"SaveMode.OverwriteDeletes existing contents and replaces with new dataCreates target directory and writes data
"ignore"SaveMode.IgnoreSilently does nothing; does not overwrite or write new dataCreates 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:

  1. 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.
  2. Persistent Table Requirement: bucketBy() works only with saveAsTable(). It cannot be used with raw file path writes (.save("path")) because bucketing metadata must be tracked in the Spark metastore/catalog.
Loading diagram...
Parquet Columnar File Layout and Partition Directory Hierarchy
Test Your Knowledge

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')?

A

File metadata footer statistics recording minimum and maximum column values for each row group

B

Tungsten binary row encoding with fixed-width byte offsets

C

Snappy block-level streaming decompression buffers

D

In-memory broadcast join hints stored in the file headers

Test Your Knowledge

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?

A

Spark appends the new files alongside existing files without modifying existing data.

B

Spark raises an AnalysisException because the default SaveMode is ErrorIfExists.

C

Spark deletes the existing directory and writes the new DataFrame files.

D

Spark silently ignores the write operation and exits without an error.

Test Your Knowledge

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?

A

partitionBy('account_id') using .save('path') to generate hash-partitioned subdirectories

B

repartition(numPartitions, 'account_id') saved with SaveMode.Append to cloud object storage

C

bucketBy(numBuckets, 'account_id').sortBy(...) used with .saveAsTable('tableName')

D

coalesce(numPartitions) used with .parquet('path') to force local executor colocation

Sections you finish are checked off in the contents.