Big Data File Formats

Choosing the right file format for massive datasets

Why File Format Matters at Scale

When dealing with terabytes or petabytes of data, your choice of file format can mean the difference between queries that take seconds versus hours, and storage costs that are manageable versus astronomical. The wrong format can waste 90% of your storage and make simple queries impossibly slow. This lesson covers the file formats that power modern big data systems and when to use each one.

Format Choice = 10x-100x Performance Difference

Switching from CSV to Parquet can reduce storage by 80% and speed up queries by 100x. Choose wisely.

Row-Based vs Column-Based Storage

The fundamental divide in big data file formats: row-based formats store records together, column-based formats store columns together. This choice has massive implications for performance.

Row-Based Format

Stores entire rows together on disk.

Data on disk:
Row 1: [id=1, name="Alice", age=25, city="NYC"]
Row 2: [id=2, name="Bob", age=30, city="LA"]
Row 3: [id=3, name="Carol", age=35, city="SF"]

All fields of a record stored together

Best for:

  • Reading entire records
  • INSERT/UPDATE operations
  • OLTP (transactional) workloads
  • Small-to-medium datasets

Examples:

CSV, JSON, Avro

Column-Based Format

Stores entire columns together on disk.

Data on disk:
Column id:   [1, 2, 3]
Column name: ["Alice", "Bob", "Carol"]
Column age:  [25, 30, 35]
Column city: ["NYC", "LA", "SF"]

All values of a column stored together

Best for:

  • Reading few columns from wide tables
  • Aggregations (SUM, AVG, COUNT)
  • OLAP (analytical) workloads
  • Large datasets (TB-PB scale)

Examples:

Parquet, ORC, Arrow

Performance example: Query "SELECT AVG(age) FROM users" on 1TB of data with 100 columns. Row format reads entire 1TB. Column format reads only age column (10GB). 100x faster!

CSV (Comma-Separated Values)

The universal data exchange format. Simple, human-readable, but terrible for big data.

Characteristics

# example.csv
id,name,age,city,salary
1,Alice,25,NYC,75000
2,Bob,30,LA,85000
3,Carol,35,SF,95000

Format:
- Plain text, comma-separated
- First row usually header
- No schema enforcement
- No compression (typically)
- Row-based storage

Pros:

  • Universal compatibility
  • Human-readable
  • Simple to parse
  • No special tools needed

Cons:

  • No compression (huge files)
  • No schema/types (everything is text)
  • Slow to parse
  • Not splittable when compressed
  • No metadata
# Working with CSV in PySpark

from pyspark.sql import SparkSession

spark = SparkSession.builder.appName("CSV Example").getOrCreate()

# Read CSV (slow, must infer schema)
df = spark.read.csv(
    "data/users.csv",
    header=True,
    inferSchema=True  # Scans data twice to infer types
)

# Specify schema for better performance
from pyspark.sql.types import StructType, StructField, StringType, IntegerType

schema = StructType([
    StructField("id", IntegerType(), nullable=False),
    StructField("name", StringType(), nullable=False),
    StructField("age", IntegerType(), nullable=True),
    StructField("city", StringType(), nullable=True),
    StructField("salary", IntegerType(), nullable=True)
])

df = spark.read.csv(
    "data/users.csv",
    header=True,
    schema=schema  # Much faster, no inference needed
)

# Write CSV
df.write.csv("output/users_csv", mode="overwrite", header=True)

# Performance comparison
# 1GB CSV file:
# - Read time: ~45 seconds
# - Storage: 1GB (no compression)
# - Query (SELECT name, age): Must read entire 1GB
When to use CSV: Data exchange with external systems, small datasets (< 1GB), one-time data loads, when human readability matters. Never for production big data storage.

JSON (JavaScript Object Notation)

Flexible, self-describing format. Great for semi-structured data, but inefficient at scale.

Characteristics

// example.json - Line-delimited JSON (JSONL/NDJSON)
{"id": 1, "name": "Alice", "age": 25, "city": "NYC", "tags": ["python", "spark"]}
{"id": 2, "name": "Bob", "age": 30, "city": "LA", "tags": ["java", "kafka"]}
{"id": 3, "name": "Carol", "age": 35, "city": "SF", "tags": ["scala", "spark"]}

Format:
- Self-describing (field names included)
- Supports nested structures
- Supports arrays and objects
- Row-based storage
- Text-based (verbose)

Pros:

  • Flexible schema (nested, arrays)
  • Human-readable
  • Self-describing
  • Wide tool support
  • Good for semi-structured data

Cons:

  • Very verbose (field names repeated)
  • Slow to parse
  • Large file sizes
  • No built-in compression
  • Row-based (not optimized for analytics)
# Working with JSON in PySpark

# Read JSON (handles nested structures automatically)
df = spark.read.json("data/users.json")

# JSON automatically infers schema from data
df.printSchema()
# root
#  |-- id: long (nullable = true)
#  |-- name: string (nullable = true)
#  |-- age: long (nullable = true)
#  |-- city: string (nullable = true)
#  |-- tags: array (nullable = true)
#  |    |-- element: string (containsNull = true)

# Access nested fields
df.select("name", df.tags[0]).show()

# Write JSON
df.write.json("output/users_json", mode="overwrite")

# Compressed JSON (much better)
df.write.json(
    "output/users_json_compressed",
    mode="overwrite",
    compression="gzip"  # or "snappy", "lz4"
)

# Performance comparison (1GB of data)
# - Read time: ~60 seconds
# - Storage: 1GB uncompressed, ~250MB with gzip
# - Very verbose: field names repeated in every record
When to use JSON: API responses, log files, semi-structured data with varying schemas, nested data structures. Use JSONL (line-delimited) for big data, not JSON arrays.

Parquet - The Big Data Standard

Column-oriented format designed for big data analytics. The de facto standard for data lakes and analytical workloads.

Why Parquet Dominates Big Data

Storage Efficiency
  • Columnar compression (80-90% space savings)
  • Dictionary encoding for repeated values
  • Run-length encoding for sequential data
  • Efficient encoding per column type
Query Performance
  • Column pruning (read only needed columns)
  • Predicate pushdown (skip irrelevant data)
  • Row group statistics (min/max/count)
  • Splittable for parallel processing
Parquet file structure:

File:
├─ Row Group 1 (128MB)
│  ├─ Column Chunk: id
│  │  ├─ Metadata (min/max/count)
│  │  └─ Data Pages (compressed)
│  ├─ Column Chunk: name
│  │  ├─ Metadata
│  │  └─ Data Pages (dictionary encoded)
│  └─ Column Chunk: age
│     ├─ Metadata
│     └─ Data Pages (compressed)
├─ Row Group 2
├─ Row Group N
└─ Footer (schema, metadata, row group locations)
# Working with Parquet in PySpark

# Read Parquet (very fast, schema included)
df = spark.read.parquet("data/users.parquet")

# Schema is stored in file (no inference needed)
df.printSchema()

# Write Parquet
df.write.parquet(
    "output/users_parquet",
    mode="overwrite",
    compression="snappy"  # Default, good balance
)

# Compression options
df.write.parquet("output/users_gzip", compression="gzip")      # Smaller, slower
df.write.parquet("output/users_snappy", compression="snappy")  # Balanced (default)
df.write.parquet("output/users_lz4", compression="lz4")        # Faster, larger

# Partitioning for massive datasets
df.write.parquet(
    "output/users_partitioned",
    mode="overwrite",
    partitionBy=["country", "year"]  # Physical directory structure
)
# Creates: output/users_partitioned/country=US/year=2024/*.parquet

# Column pruning (only reads needed columns)
# Query: SELECT name, age FROM users
df.select("name", "age").show()
# Parquet: Reads ONLY name and age columns
# CSV/JSON: Reads ENTIRE file

# Predicate pushdown (skips row groups)
# Query: SELECT * FROM users WHERE age > 30
df.filter("age > 30").show()
# Parquet: Checks row group metadata, skips groups where max(age) <= 30

# Performance comparison (1GB CSV → Parquet)
# - Storage: 1GB → 150MB (85% reduction)
# - Read time: 45s → 2s (22x faster)
# - Query (2 columns): 45s → 0.3s (150x faster)

# Advanced: Custom row group size
df.write.option("parquet.block.size", 256 * 1024 * 1024) \
    .parquet("output/users_large_rowgroups")  # 256MB row groups


# Reading with predicate pushdown
from pyspark.sql.functions import col

# This is extremely efficient
df = spark.read.parquet("data/users_partitioned")
filtered = df.filter(
    (col("country") == "US") &  # Partition pruning
    (col("age") > 30)           # Row group pruning
).select("name", "email")       # Column pruning

# Spark only reads relevant data:
# 1. Skips non-US partitions (partition pruning)
# 2. Skips row groups where max(age) <= 30 (predicate pushdown)
# 3. Only reads name and email columns (column pruning)
# Result: 100x+ faster than full scan
When to use Parquet: ALWAYS for big data analytics. Data lakes, data warehouses, Spark/Hive/Presto workloads, any analytical use case. Industry standard for columnar storage.

ORC (Optimized Row Columnar)

Hadoop-native columnar format. Similar to Parquet but optimized for Hive and Hadoop ecosystem.

ORC vs Parquet

ORC Advantages
  • Slightly better compression than Parquet
  • Built-in indexes (bloom filters)
  • Better for Hive/Hadoop workloads
  • ACID transaction support in Hive
Parquet Advantages
  • Better ecosystem support
  • Works with more tools (Spark, Presto, etc.)
  • More widely adopted
  • Better nested data support
# Working with ORC in PySpark

# Read ORC
df = spark.read.orc("data/users.orc")

# Write ORC
df.write.orc(
    "output/users_orc",
    mode="overwrite",
    compression="zlib"  # or "snappy", "lz4"
)

# ORC bloom filters for faster lookups
df.write.option("orc.bloom.filter.columns", "email,user_id") \
    .orc("output/users_orc_bloom", mode="overwrite")  # Fast IN/= queries

# Performance (similar to Parquet)
# - Storage: ~10% better compression than Parquet
# - Read speed: Similar to Parquet
# - Compatibility: Less universal than Parquet
When to use ORC: Hive-centric data warehouses, when you need ACID transactions in Hive, when 5-10% better compression matters. Otherwise, use Parquet for better ecosystem compatibility.

Avro - Row-Based with Schema Evolution

Row-oriented binary format with rich schema evolution support. Perfect for streaming and data interchange.

Avro's Superpower: Schema Evolution

Avro stores schema with data and supports forward/backward compatibility. Critical for streaming pipelines where producers and consumers evolve independently.

// Avro Schema (JSON)
{
  "type": "record",
  "name": "User",
  "namespace": "com.example",
  "fields": [
    {"name": "id", "type": "long"},
    {"name": "name", "type": "string"},
    {"name": "email", "type": "string"},
    {"name": "age", "type": ["null", "int"], "default": null}  // Nullable
  ]
}

Schema Evolution Examples:

1. Add new field with default:
   {"name": "country", "type": "string", "default": "US"}
   → Old readers ignore new field
   → New readers use default for old data

2. Remove field (backward compatible):
   → Old readers see the field
   → New readers ignore it

3. Change field type (careful!):
   int → long: ✓ Compatible
   string → int: ✗ Incompatible
# Working with Avro in PySpark

# Read Avro (requires spark-avro package)
df = spark.read.format("avro").load("data/users.avro")

# Write Avro
df.write.format("avro").save("output/users_avro")

# Schema evolution example
# Original schema: id, name, email
df_v1 = spark.read.format("avro").load("data/users_v1.avro")

# New schema: id, name, email, country (added field)
df_v2 = spark.read.format("avro").load("data/users_v2.avro")

# Both work seamlessly!
# v1 data: country is null
# v2 data: country has value

# Avro with Kafka (common pattern)
from pyspark.sql.avro.functions import from_avro, to_avro

# Deserialize Avro from Kafka
schema = open("user_schema.avsc").read()

kafka_df = spark.readStream \
    .format("kafka") \
    .option("kafka.bootstrap.servers", "localhost:9092") \
    .option("subscribe", "users") \
    .load()

# Parse Avro-encoded values
users_df = kafka_df.select(
    from_avro("value", schema).alias("user")
).select("user.*")

# Serialize to Avro for writing to Kafka
output_df = users_df.select(
    to_avro("user_data").alias("value")
)
When to use Avro: Kafka streaming pipelines, data interchange between systems, when schema evolution is critical, row-oriented write-heavy workloads. Not for analytical queries (use Parquet instead).

Format Comparison Matrix

FormatStorageRead SpeedWrite SpeedCompressionBest Use Case
CSVPoor (10/10)SlowFastNoneData exchange, small files
JSONPoor (8/10)SlowFastFair with gzipAPIs, semi-structured, logs
ParquetExcellent (1-2/10)Very FastModerateExcellentAnalytics, data lakes
ORCExcellent (1/10)Very FastModerateBestHive, ACID transactions
AvroGood (4/10)ModerateFastGoodStreaming, schema evolution
Real-World Performance: 1TB Dataset
Scenario: 1TB of user data, 100 columns, 1 billion rows

Storage Size:
CSV:       1.0 TB (baseline, no compression)
JSON:      2.5 TB (verbose field names)
JSON.gz:   400 GB (with gzip compression)
Avro:      300 GB (binary + compression)
Parquet:   150 GB (columnar compression)    ← 85% reduction
ORC:       120 GB (best compression)        ← 88% reduction

Query: "SELECT name, age FROM users WHERE country='US'"

CSV:       120 seconds (read entire 1TB)
JSON:      150 seconds (read entire 2.5TB)
JSON.gz:   90 seconds  (decompression overhead)
Avro:      45 seconds  (row-based, must read all columns)
Parquet:   1.2 seconds (column pruning + predicate pushdown)   ← 100x faster
ORC:       1.0 seconds (similar to Parquet)

Aggregation: "SELECT AVG(salary) FROM users"

CSV:       130 seconds
JSON:      160 seconds
Avro:      50 seconds
Parquet:   0.8 seconds (only reads salary column)   ← 160x faster
ORC:       0.7 seconds

Cost Impact (S3 storage):
CSV:       1TB × $0.023/GB = $23.00/month
Parquet:   150GB × $0.023/GB = $3.45/month    ← Save $20/month per TB

Best Practices for File Format Selection

1. Default to Parquet for Analytics

Unless you have a specific reason not to, use Parquet for all analytical workloads. It's the industry standard for good reasons: excellent compression, fast queries, wide tool support.

2. Partition Large Datasets

Partition by commonly filtered columns (date, country, category) to enable partition pruning. Aim for 128MB-1GB partitions.

# Good partitioning
df.write.parquet(
    "data/events",
    partitionBy=["year", "month", "day"]
)
# Creates: data/events/year=2024/month=01/day=15/*.parquet

# Query with partition pruning
df = spark.read.parquet("data/events")
df.filter("year = 2024 AND month = 1").count()
# Only reads January 2024 partitions
3. Choose the Right Compression
  • Snappy (default): Balanced speed and compression, good for most cases
  • Gzip: Better compression, slower read/write, good for cold storage
  • LZ4: Faster than Snappy, slightly worse compression
  • Zstd: Modern, great balance (use if available)
4. Optimize File Sizes

Target 128MB-1GB per file. Too small = metadata overhead. Too large = poor parallelism.

# Control file sizes with coalesce/repartition
df.coalesce(100).write.parquet("output")  # ~100 files

# Or repartition for even sizes
df.repartition(100).write.parquet("output")
5. Use Avro for Streaming, Parquet for Batch

Common pattern: Ingest with Avro (fast writes, schema evolution) → Batch convert to Parquet (efficient analytics). Get the best of both worlds.

6. Avoid CSV/JSON for Production Storage

Use CSV/JSON only for data exchange or temporary staging. Convert to Parquet/ORC as soon as possible. The storage and performance costs of text formats are unsustainable at scale.

Key Takeaways

  • Columnar formats dominate analytics, Parquet and ORC offer 10-100x better performance than row formats
  • Parquet is the default choice, Best compression, fastest queries, widest ecosystem support
  • ORC for Hive-centric workloads, Slightly better compression, ACID support, less portable
  • Avro for streaming pipelines, Schema evolution, fast writes, Kafka integration
  • CSV/JSON for exchange only, Never for production storage, convert to Parquet immediately
  • Partition large datasets, Enable partition pruning for 10-100x speedup on filtered queries
  • Right compression matters, Snappy for speed, Gzip for storage, Zstd for balance
  • File size optimization, Target 128MB-1GB files for best parallelism
  • Cost savings are massive, Parquet reduces storage by 80-90% compared to CSV
  • Test with your data, Benchmark formats with representative queries before choosing

Rule of thumb: Use Parquet for everything unless you have a specific reason not to. It will save you money, speed up queries, and make your data engineers happy.