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
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 1GBJSON (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 recordParquet - 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 scanORC (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 ParquetAvro - 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")
)Format Comparison Matrix
| Format | Storage | Read Speed | Write Speed | Compression | Best Use Case |
|---|---|---|---|---|---|
| CSV | Poor (10/10) | Slow | Fast | None | Data exchange, small files |
| JSON | Poor (8/10) | Slow | Fast | Fair with gzip | APIs, semi-structured, logs |
| Parquet | Excellent (1-2/10) | Very Fast | Moderate | Excellent | Analytics, data lakes |
| ORC | Excellent (1/10) | Very Fast | Moderate | Best | Hive, ACID transactions |
| Avro | Good (4/10) | Moderate | Fast | Good | Streaming, 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 partitions3. 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.