Skip to main content
Articles/#apache-spark/spark-reading-writing-data-parquet-delta-json
Series: Data Engineering & Spark Mastery•Part 10 of 19
Scenario Article

Reading and Writing Data in Spark: Parquet, Delta, JSON, and JDBC

The Team That Turned a 500 GB Daily Scan into an 8 GB Query

Abstract Algorithms•Apr 19, 2026
💡 Executive Summary

Parquet's columnar layout with row-group statistics enables predicate pushdown that can reduce a 500 GB scan to 8 GB.

🎯 System Scenario & Requirements

System Goal

Parquet's columnar layout with row-group statistics enables predicate pushdown that can reduce a 500 GB scan to 8 GB.

Actors & Components

  • Application Workers
  • Volatile Memory Tier
  • Persistent Disk Tier
  • Worker Process
  • In-Memory Cache
  • Primary Database

Functional Requirements

  • TLDR: Parquet's columnar layout with row-group statistics enables predicate pushdown that can reduce a 500 GB scan to 8 GB.
  • Delta Lake wraps Parquet with a JSON transaction log to add ACID semantics and time travel.
  • JSON and CSV read every byte because they have no statistics.

Constraints & Assumptions

  • The Small File Problem in Streaming Writes Structured Streaming writes to Parquet produce one or more files per micro-batch per partition.
  • At a 60-second batch interval, 500 output partitions, and 10 micro-batches per hour, a single streaming job generates 300,000 small files per hour.
  • Lesson 1: Every JSON query is a full table scan, always.
  • The most common mistake in new Spark pipelines is leaving raw event data in JSON because "it's easier to debug." Easier to debug, yes — but it costs full-table I/O on every query.

⚡ Execution Stages & Decision Trace

1

Stage 1 — The DataFrameReader and DataFrameWriter API and What You Can Control

Action / Decision:

Every Spark I/O operation passes through two symmetric APIs: DataFrameReader for reads and DataFrameWriter for writes.

Component Behavior:

Understanding the options available at this surface is the prerequisite for understanding what the underlying format can and cannot do.

Observable Output: The DataFrameReader Pattern The reader is accessed via spark.read and accepts a format identifier, optional key-value options, and a path.
Read Full Stage in Deep Dive ↓
📖

Deep Dive

Detailed Architectural Analysis

Scenario context

Parquet's columnar layout with row-group statistics enables predicate pushdown that can reduce a 500 GB scan to 8 GB. Delta Lake wraps Parquet with a JSON transaction log to add ACID semantics and time travel. JSON and CSV read every byte because they have no statistics. JDBC reads scale only with explicit numPartitions + partitionColumn. The format you choose is not cosmetic — it determines the entire I/O path…

This walkthrough focuses on The Team That Turned a 500 GB Daily Scan into an 8 GB Query.

Before making a design choice, clarify:

  • A platform analytics team at a mid-size SaaS company had a daily pipeline that read the previous seven days of raw user-event data from S3 and computed a set of funnel metrics.
  • The data was stored as gzipped JSON — one file per hour per region — totalling approximately 500 GB.
  • The pipeline ran for ninety minutes every morning, used forty executors, and cost the team about $180 per run on their cloud cluster.

Concepts in play: spark-data-engineering, caching, data-warehousing-lakehouse, transaction-isolation-distributed-acid

Stage 1 — The DataFrameReader and DataFrameWriter API and What You Can Control

  • Every Spark I/O operation passes through two symmetric APIs: DataFrameReader for reads and DataFrameWriter for writes.
  • Understanding the options available at this surface is the prerequisite for understanding what the underlying format can and cannot do.
  • The DataFrameReader Pattern The reader is accessed via spark.read and accepts a format identifier, optional key-value options, and a path.
  • The supported built-in formats are: The reader chain works as: specify format → apply options → load a path or paths.
output/
  date=2026-01-15/
    region=us-east/
      part-00000-abc123.parquet
      part-00001-def456.parquet
    region=eu-west/
      part-00002-ghi789.parquet
  date=2026-01-16/
    region=us-east/
      part-00003-jkl012.parquet

Observable outcome: The supported built-in formats are: The reader chain works as: specify format → apply options → load a path or paths.

Stage 2 — Parquet Internals, the Delta Transaction Log, and How Partition Pruning Works

  • Parquet's Columnar Layout: Row Groups, Column Chunks, and Statistics A Parquet file is divided horizontally into row groups — each row group is a horizontal slice of the data, typically 128 MB.
  • Within a row group, data for each column is stored contiguously as a column chunk .
  • Column chunks are further divided into pages of approximately 1 MB each, which are the unit of compression and encoding.
  • Every column chunk stores column statistics in the Parquet footer: the minimum and maximum values of that column across all rows in the row group.
_delta_log/
  00000000000000000000.json   ← initial commit (table creation)
  00000000000000000001.json   ← first write
  00000000000000000002.json   ← second write (e.g., DELETE)
  00000000000000000010.json   ← tenth write
  00000000000000000010.checkpoint.parquet  ← checkpoint at commit 10
  _last_checkpoint              ← pointer to latest checkpoint
{"commitInfo":{"timestamp":1713398400000,"operation":"WRITE","operationParameters":{"mode":"Append","partitionBy":"[\"date\"]"}}}
{"add":{"path":"date=2026-01-15/part-00000-abc123-c000.snappy.parquet","partitionValues":{"date":"2026-01-15"},"size":134217728,"modificationTime":1713398400000,"dataChange":true}}
{"add":{"path":"date=2026-01-15/part-00001-def456-c000.snappy.parquet","partitionValues":{"date":"2026-01-15"},"size":127926944,"modificationTime":1713398400000,"dataChange":true}}

Observable outcome: Every column chunk stores column statistics in the Parquet footer: the minimum and maximum values of that column across all rows in the row group.

Stage 3 — Deep Dive: How Spark Plans and Executes a Parquet Read Internally

  • The Internals of Parquet Reads in Spark via DataSourceV2 Since Spark 2.3, the Parquet data source (and most others) is implemented via the DataSourceV2 API .
  • This API introduces a clean separation between scan planning (what to read) and scan execution (how to read it), enabling more sophisticated optimizations than the original DataSource V1 API allowed.
  • When spark.read.parquet("s3://bucket/events") is called, the execution sequence is: Step 1 — File scan planning.
  • Spark calls the data source's Table.newScanBuilder() method.
graph TD
    A[SparkSession.read.parquet - path]
    B[DataSourceV2 scan builder]
    C[FileIndex - list partition directories]
    D[PartitionPruning - evaluate partition filters]
    E[Footer metadata read - row group statistics per file]
    F[Row group filtering - skip groups where predicate cannot match]
    G[Column projection - fetch only requested column chunks]
    H[Vectorized decode - 4096 rows per columnar batch]
    I[DataFrame - InternalRow columnar batches]

    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    F --> G
    G --> H
    H --> I

Observable outcome: Spark calls the data source's Table.newScanBuilder() method.

Stage 4 — Tracing the Parquet Read Path: From Storage to DataFrame

  • The following diagram shows both the planning path and the execution path for a partitioned Parquet table read with predicate pushdown.
  • The left column shows planning-time decisions (no data read); the right column shows execution-time I/O.
  • The point where control transfers from planning to execution is the FileScan plan node — once that node exists, Spark knows exactly which files and row groups to read, and no planning-time decision can be revisited with…
  • The planning phase (top half of the diagram) operates entirely on metadata: directory listings, footer statistics, and schema information.
graph TD
    QP[Query with WHERE predicates]
    CA[Catalyst Analyzer]
    CO[Catalyst Optimizer]
    PP[Partition pruning at FileIndex]
    FP[Filter pushdown to DataSourceV2]
    CP[Column pruning - ReadSchema]
    FS[FileScan plan node - pruned file list]
    DA[DAGScheduler creates FileScanRDD tasks]
    T1[Task 1 - reads file1 footer]
    T2[Task 2 - reads file2 footer]
    TN[Task N - reads fileN footer]
    RG[Row group filter - skip non-matching row groups]
    CD[Column data fetch - only projected columns]
    VD[Vectorized decode - columnar batches]
    DF[DataFrame result]

    QP --> CA
    CA --> CO
    CO --> PP
    PP --> FP
    FP --> CP
    CP --> FS
    FS --> DA
    DA --> T1
    DA --> T2
    DA --> TN
    T1 --> RG
    T2 --> RG
    TN --> RG
    RG --> CD
    CD --> VD
    VD --> DF

Observable outcome: The planning phase (top half of the diagram) operates entirely on metadata: directory listings, footer statistics, and schema information.

Stage 5 — Delta Lake Merges, JDBC Parallel Reads, and Dynamic Partition Overwrites

  • Delta Lake MERGE: The Upsert Pattern for Mutable Data Lakes The most common mutation pattern in data lake workloads is the upsert (insert or update): for each incoming record, update the existing row if a matching key e…
  • Relational databases handle this natively with SQL MERGE or INSERT ...
  • For Parquet-based lakes, implementing upsert without Delta requires reading all existing data, merging the changes in memory, and rewriting entire partitions — an O(table size) operation regardless of change volume.
  • Delta Lake implements this as a first-class MERGE INTO operation backed by the transaction log: Internally, Delta executes a MERGE as two phases: first, it identifies which data files contain rows matching the ON condit…
MERGE INTO target_table AS target
USING source_data AS source
ON target.user_id = source.user_id
WHEN MATCHED THEN UPDATE SET
    target.last_seen = source.event_ts,
    target.session_count = target.session_count + 1
WHEN NOT MATCHED THEN INSERT *
Query 1: WHERE order_id >= 1         AND order_id < 1000001
Query 2: WHERE order_id >= 1000001   AND order_id < 2000001
...
Query N: WHERE order_id >= (N-1)*1M  AND order_id <= 4000000

Observable outcome: Delta Lake implements this as a first-class MERGE INTO operation backed by the transaction log: Internally, Delta executes a MERGE as two phases: first, it identifies which data files contain rows matching the ON condit…

Stage 6 — Reading the Evidence: Explain Output and a Delta Transaction Log Walk-Through

  • This section demonstrates two diagnostic skills: reading a Spark explain() output to understand what predicate pushdown is doing, and reading a Delta transaction log to understand what a write actually committed.
  • Example 1 — Interpreting a Parquet Scan's explain() Output Consider a query selecting purchase events for a single date from a partitioned Parquet table with 47 schema columns.
  • The explain(true) physical plan output contains the following FileScan node: Reading this output section by section tells the full I/O story: Location shows a single path after partition pruning.
  • The original table has 365 date partitions but only date=2026-01-15 survived the PartitionFilters evaluation — Spark's file index listed 365 directories and included exactly one.
== Physical Plan ==
*(1) ColumnarToRow
+- FileScan parquet events[event_type#5, user_id#6, revenue#7]
   Batched: true,
   DataFilters: [isnotnull(event_type#5), (event_type#5 = purchase)],
   Format: Parquet,
   Location: InMemoryFileIndex(1 paths)[s3://data-lake/events/date=2026-01-15],
   PartitionFilters: [isnotnull(date#0), (date#0 = 2026-01-15)],
   PushedFilters: [IsNotNull(event_type), EqualTo(event_type,purchase)],
   ReadSchema: struct<event_type:string,user_id:string,revenue:double>
{"commitInfo":{"timestamp":1713484800000,"operation":"MERGE","operationParameters":{"predicate":"(target.user_id = source.user_id)","matchedPredicates":"[{\"actionType\":\"update\"}]","notMatchedPredicates":"[{\"actionType\":\"insert\"}]"},"readVersion":41,"isolationLevel":"Serializable","isBlindAppend":false}}
{"remove":{"path":"date=2026-01-14/part-00003-old-abc.snappy.parquet","deletionTimestamp":1713484800000,"dataChange":true}}
{"remove":{"path":"date=2026-01-15/part-00007-old-def.snappy.parquet","deletionTimestamp":1713484800000,"dataChange":true}}
{"add":{"path":"date=2026-01-14/part-00003-new-xyz.snappy.parquet","partitionValues":{"date":"2026-01-14"},"size":131072000,"modificationTime":1713484800000,"dataChange":true}}
{"add":{"path":"date=2026-01-15/part-00007-new-uvw.snappy.parquet","partitionValues":{"date":"2026-01-15"},"size":127926944,"modificationTime":1713484800000,"dataChange":true}}

Observable outcome: The original table has 365 date partitions but only date=2026-01-15 survived the PartitionFilters evaluation — Spark's file index listed 365 directories and included exactly one.

End-to-end outcome

A platform analytics team at a mid-size SaaS company had a daily pipeline that read the previous seven days of raw user-event data from S3 and computed a set of funnel metrics.

Alternatives considered

Choosing the Right Format: A Decision Guide for Spark I/O

Use this table when choosing a data format for a new Spark pipeline or migrating an existing one.

Failure and degraded modes

The Small File Problem in Streaming Writes

  • Detection: Structured Streaming writes to Parquet produce one or more files per micro-batch per partition.
  • User or operator impact: Structured Streaming writes to Parquet produce one or more files per micro-batch per partition.

Parquet vs Delta: When the Transaction Log Cost Matters

  • Detection: Delta Lake is strictly more capable than plain Parquet — it adds ACID, time travel, schema enforcement, and MERGE support.
  • User or operator impact: Delta Lake is strictly more capable than plain Parquet — it adds ACID, time travel, schema enforcement, and MERGE support.

JSON and CSV for Production Analytical Workloads

  • Detection: JSON and CSV exist in production Spark pipelines primarily as landing zone formats — the format in which raw data arrives from external sources.
  • User or operator impact: JSON and CSV exist in production Spark pipelines primarily as landing zone formats — the format in which raw data arrives from external sources.

Lessons Learned from Production Spark I/O Failures

  • Detection: Lesson 1: Every JSON query is a full table scan, always.
  • User or operator impact: Lesson 1: Every JSON query is a full table scan, always.

Share this case study

Discuss architecture trade-offs and lessons with your team.

Share:XLinkedInReddit