Working with Files at Scale
File organisation, compression, partitioning, the small file problem, and format conversion pipelines.
Why Working with Files at Scale Is Its Own Engineering Discipline
A single CSV file is trivial. A directory of 50 million Parquet files representing three years of event data is not. At scale, every file decision has compounding consequences: how files are named determines whether partition pruning works. How files are sized determines whether Spark jobs are fast or slow. How files are compressed determines storage cost. How they are organised determines whether analysts can find data without asking a data engineer.
This module covers the file engineering that sits between “I know Parquet exists” and “I can design a file layer that scales to petabytes and still serves fast queries.” These decisions are made once and lived with for years — getting them right matters.
File Naming — The Foundation of a Usable Data Lake
File naming in a data lake is not cosmetic. The names must encode enough information to identify the file without reading it, sort chronologically without special logic, and survive being listed in any filesystem tool without ambiguity. Poor naming conventions produce lakes where data engineers spend 20 minutes finding the right file for every query.
# ── THE RULES ────────────────────────────────────────────────────────────────
# 1. Timestamps: always ISO 8601, always UTC, always in the name
# 2. Use underscores not spaces (spaces cause shell escaping nightmares)
# 3. Include enough context to identify without opening the file
# 4. Lexicographic sort order = chronological sort order (ISO dates achieve this)
# 5. Include a unique identifier to prevent overwrite collisions
# ── LANDING ZONE: preserve origin context ────────────────────────────────────
# Pattern: {source}_{entity}_{start_ts}_{end_ts}_{batch_id}.{ext}
stripe_payments_20260317T000000Z_20260317T235959Z_f8a3b2c4.json
shopify_orders_20260317T060000Z_20260317T120000Z_9e1d7c3f.csv
freshcart_deliveries_20260317T000000Z_20260317T235959Z_2b4a8d6e.parquet
# What each component gives you:
# stripe → which source system (filter by prefix)
# payments → which entity/table
# 20260317T... → ISO 8601 UTC timestamps (sort = chronological order)
# f8a3b2c4 → unique batch ID (trace back to pipeline run logs)
# ── BRONZE LAYER: add pipeline metadata ──────────────────────────────────────
# Pattern: {entity}/year={YYYY}/month={MM}/day={DD}/{entity}_{ts}_{id}.parquet
# (Hive-style partitioning — covered in Part 03)
s3://freshcart-lake/bronze/payments/year=2026/month=03/day=17/
payments_20260317T000000Z_f8a3b2c4.parquet
payments_20260317T060000Z_9e1d7c3f.parquet
# ── SILVER AND GOLD: clean entity-oriented names ─────────────────────────────
s3://freshcart-lake/silver/orders/date=2026-03-17/part-00001.parquet
s3://freshcart-lake/gold/daily_revenue/date=2026-03-17/part-00001.parquetWhat not to do
# BAD: no timestamp — cannot determine when file was created
orders.csv
# BAD: ambiguous date format — is 03/17/26 March or day 3?
orders_03-17-26.csv
# BAD: spaces in filename — breaks shell commands without quoting
march 17 orders.csv
# BAD: no source or entity context
data_f8a3b2c4.parquet
# BAD: non-sortable timestamp — file 2_11_2026 sorts before 3_1_2026
orders_2_11_2026.csv # Feb 11 sorts AFTER Mar 1? Depends on tool.
# BAD: mutable names — what does "latest" point to next week?
orders_latest.parquet
orders_final.parquet
orders_final_v2.parquet
orders_final_v2_ACTUALLY_FINAL.parquet # this is not a jokeNaming for operational visibility
Production pipelines write many files per day. Good names let you diagnose issues without opening a single one.
import uuid
from datetime import date
def make_output_filename(
entity: str,
run_date: date,
run_id: str,
chunk_idx: int,
fmt: str = 'parquet',
) -> str:
return f"{entity}_{run_date.strftime('%Y%m%d')}_run-{run_id[:8]}_{chunk_idx:04d}.{fmt}"
run_id = str(uuid.uuid4())
print(make_output_filename('orders', date(2026, 3, 17), run_id, 1))orders_20260317_run-f8a3b2c4_0001.parquet
# if a run writes chunks 0001 and 0002 but not 0003, you know from the
# filenames alone that it stopped early — no log file needed to tell you thatPartitioning — The Single Biggest Lever for Query Performance
Partitioning is the practice of organising files into a directory hierarchy based on column values. When a query filters on a partition column, the query engine reads only the directories matching that filter and skips all others. A query for last week’s orders on a dataset partitioned by date reads 7 directories out of 1,000 — 99.3% of files never open. This is called partition pruning and it is the most impactful performance optimisation in a data lake.
Hive-style partitioning — the standard
# Hive-style partitioning uses key=value directory names.
# Query engines (Spark, Athena, Presto, BigQuery external tables)
# understand this structure natively and prune partitions automatically.
s3://freshcart-lake/silver/orders/
date=2026-03-15/
part-00001.parquet (rows where date = 2026-03-15)
part-00002.parquet
date=2026-03-16/
part-00001.parquet (rows where date = 2026-03-16)
date=2026-03-17/
part-00001.parquet (rows where date = 2026-03-17)
part-00002.parquet
part-00003.parquet
# Multi-level partitioning (for finer granularity):
s3://freshcart-lake/silver/orders/
year=2026/month=03/day=17/
store=ST001/part-00001.parquet
store=ST002/part-00001.parquet-- Query: SELECT COUNT(*) FROM orders WHERE date = '2026-03-17'
-- Without partitioning: reads ALL files → 100% I/O
-- With date partitioning: reads ONLY date=2026-03-17/ → ~0.3% I/O (1 of 365 days)
-- Query: WHERE year=2026 AND month=03 AND store='ST001'
-- Reads: ONLY year=2026/month=03/*/store=ST001/ files# PyArrow:
import pyarrow as pa
import pyarrow.parquet as pq
df['date'] = pd.to_datetime(df['created_at']).dt.date.astype(str)
table = pa.Table.from_pandas(df)
pq.write_to_dataset(
table, root_path='s3://freshcart-lake/silver/orders',
partition_cols=['date'], # creates date=YYYY-MM-DD/ dirs
filesystem=s3_filesystem, compression='snappy',
existing_data_behavior='overwrite_or_ignore',
)
# PySpark:
df.write.mode('overwrite').partitionBy('date').parquet('s3://freshcart-lake/silver/orders')Choosing the right partition key — the most important decision
The partition key must match the most common query filter. If analysts almost always filter by date, partition by date. If they filter by store, partition by store. The wrong partition key means partition pruning never fires and you get no benefit from the overhead of managing partitions.
Compression Codecs — Choosing the Right One for Each Situation
Every Parquet and Avro file is compressed. The codec choice affects storage cost, read speed, write speed, and CPU usage during compression/decompression. There is no universal best choice — different workloads have different optimal codecs.
| Codec | Compression ratio | Compress speed | Decompress speed | Splittable? | Best for |
|---|---|---|---|---|---|
| SNAPPY | 2–3× (moderate) | Very fast | Very fast | Yes (Parquet/Avro blocks) | Default for data lake Parquet — best balance of speed and ratio |
| GZIP / DEFLATE | 4–6× (good) | Slow | Moderate | No (as raw .gz file) | Archival storage, landing zone CSVs, when size matters more than speed |
| ZSTD | 3–5× (very good) | Fast (tunable levels) | Very fast | Yes (Parquet/Avro blocks) | Modern default — better ratio than Snappy at similar speed. Parquet 1.5+ default |
| LZ4 | 1.5–2× (low) | Extremely fast | Extremely fast | Yes | Real-time streaming, Kafka messages — CPU cost matters more than ratio |
| BROTLI | 5–7× (excellent) | Very slow | Moderate | Yes | Cold archival storage, rarely read files |
| UNCOMPRESSED | 1× (none) | N/A | N/A | Yes | Development/testing only — never use in production data lake |
Splittability — why it matters for Spark performance
A splittable format allows multiple Spark executors to read different parts of the same file in parallel. A non-splittable format forces a single executor to read the entire file before splitting the data — a bottleneck that eliminates the parallelism that makes Spark fast.
# SPLITTABLE: Parquet with Snappy, ZSTD, or LZ4
# Each row group is compressed independently — Spark assigns one row
# group per task, so all executors work in parallel.
s3://freshcart-lake/silver/orders/date=2026-03-17/part-00001.parquet
# NON-SPLITTABLE: plain .gz CSV
# One executor must decompress the ENTIRE file before splitting; the
# rest sit idle.
s3://freshcart-lake/landing/orders_20260317.csv.gz500 MB Parquet + Snappy, 10 row groups, 10 executors:
→ read time: ~5 seconds (true parallel read)
500 MB .csv.gz, same 10 executors:
→ read time: ~50 seconds (one executor decompresses alone, others idle)Rule of thumb: for data lake storage, always use Parquet (inherently splittable regardless of codec) or Avro. Never store large raw .gz CSV files in the analytical layer.
Codec selection by use case
# DATA LAKE — Parquet files (Bronze, Silver, Gold)
pq.write_table(table, path, compression='zstd') # best all-rounder for 2026
pq.write_table(table, path, compression='snappy') # safe default, widely supported
# Per-column compression (Parquet supports different codecs per column):
pq.write_table(table, path, compression={
'order_id': 'zstd', # numeric ID — compresses well with delta encoding
'order_text': 'snappy', # free text — fast decomp matters for queries
'image_url': 'gzip', # URL strings — ratio more important than speed
})
# KAFKA / STREAMING — LZ4 for lowest latency and minimal CPU overhead
# producer config: compression.type=lz4
# LANDING ZONE — accept whatever the vendor sends as-is, convert at Bronze
# ARCHIVAL (data > 2 years old) — GZIP for CSV, ZSTD level 19 for Parquet
# ZSTD compression level tuning:
pq.write_table(table, path, compression='zstd', compression_level=1) # fastest (default)
# compression_level=9 → high ratio, slower — for archival
# compression_level=19 → max ratio, very slow — cold storage onlyThe Small File Problem — The Silent Performance Killer in Every Data Lake
The small file problem is one of the most common and most impactful performance issues in data lakes. It occurs when a data lake accumulates millions of tiny files — each valid, each correct — but collectively making every operation slow: listing, querying, reading, and writing.
The root cause is almost always streaming or micro-batch pipelines that write many small files over time, or highly partitioned tables where each partition gets very few rows per pipeline run.
What it looks like
# HEALTHY: few large files per partition
s3://freshcart-lake/silver/orders/date=2026-03-17/
part-00001.parquet (480 MB)
part-00002.parquet (520 MB)
part-00003.parquet (495 MB)
# 3 files, ~500 MB each → 3 Spark tasks, each reads one large file efficiently
# SMALL FILE PROBLEM: many tiny files per partition
s3://freshcart-lake/silver/orders/date=2026-03-17/
part-00001.parquet (2.1 KB) ← written by 5-minute micro-batch 00:05
part-00002.parquet (1.8 KB) ← written by 5-minute micro-batch 00:10
... (286 more files) ...
part-00288.parquet (1.9 KB) ← written by 5-minute micro-batch 23:55
# 288 files, ~576 KB total (same data!) → 288 Spark tasks, 288× overheadS3 LIST calls: 3 files <10ms • 288 files ~100ms • 50,000 files 5-10s
Spark task overhead: ~100ms/task × 288 tasks = ~29s of pure scheduling overhead
(vs. ~300ms for the 3-file layout — 97× more overhead)
Parquet footer reads: 288 separate S3 GET requests just for file metadata
Scale of the problem: a pipeline writing every 5 minutes across 10 store
partitions produces 2,880 files/day → 1,051,200 files/year. Most systems
start struggling well before 1M files.Solution 1 — compact existing small files
from pyspark.sql import SparkSession
def compact_partition(spark: SparkSession, path: str, date: str, target_file_size_mb: int = 512) -> None:
"""Read all small files in a partition and rewrite as fewer large files."""
partition_path = f"{path}/date={date}"
df = spark.read.parquet(partition_path)
row_count = df.count()
# Rough estimate: 1M rows ≈ 100 MB compressed Parquet
estimated_mb = row_count / 10_000
target_files = max(1, int(estimated_mb / target_file_size_mb))
df.coalesce(target_files).write.mode('overwrite').parquet(partition_path)
print(f"Compacted date={date}: {row_count:,} rows → {target_files} files")Compacted date=2026-03-17: 48,200,000 rows → 6 files
# 288 tiny files → 6 files of ~500 MB each, same data, same query resultsSolution 2 — prevent small files at write time
# Coalesce the output DataFrame before writing:
def write_compact_parquet(df, output_path: str, partition_col: str = 'date') -> None:
row_count = df.count()
target_mb = 512
rows_per_mb = 10_000
target_files = max(1, int(row_count / (target_mb * rows_per_mb)))
df.repartition(target_files, partition_col) \
.write.mode('overwrite').partitionBy(partition_col).parquet(output_path)
# Delta Lake / Iceberg have built-in compaction — run on a schedule instead:
# OPTIMIZE silver.orders WHERE date = '2026-03-17';
from delta.tables import DeltaTable
delta_table = DeltaTable.forPath(spark, 's3://freshcart-lake/silver/orders')
delta_table.optimize().where("date = '2026-03-17'").executeCompaction()
# OPTIMIZE does not change table content, only file organisation —
# run VACUUM afterward to reclaim space from the old small filesSolution 3 — batch streaming writes into larger intervals
streamDf.writeStream \
.trigger(processingTime='1 hour') \
.option('maxRecordsPerFile', 500_000) \
.partitionBy('date') \
.parquet('s3://freshcart-lake/silver/orders')
# processingTime='1 hour' batches an hour of data before writing:
# → 24 files/day instead of 288 files/day (12× improvement)
# maxRecordsPerFile caps the maximum records per output filecompact_partition’s math on paper for a partition with 2,000 rows and target_file_size_mb=512. What does target_files come out to, and why does the max(1, ...) guard matter for a partition this small?Target File Sizes — What the Numbers Actually Mean
Every modern storage and compute guide says files should be “128 MB to 1 GB.” Where does this range come from? Understanding the reasoning behind it lets you tune for your specific workload rather than applying a rule blindly.
# TOO SMALL (< 32 MB): overhead dominates the actual read
# S3 GET latency: ~10ms
# Parquet footer read: ~5ms
# Spark task scheduling: ~100ms
# For a 1 KB file: overhead (115ms) >> actual read time (<1ms)
# For a 512 MB file: overhead (115ms) << actual read time (~500ms)
# Metadata catalog also grows large — one entry per file
# TOO LARGE (> 2 GB): single-executor bottleneck
# Spark cannot split a single file across tasks by bytes, only by row group
# One 4 GB file → one executor reads all 4 GB serially
# Four 1 GB files → four executors read 1 GB each in parallel
# A failed write of 4 GB also wastes 4 GB of work; 512 MB wastes far less
# THE SWEET SPOT: 256 MB – 1 GB per file (compressed Parquet)
# minimum 128 MB, maximum 1-2 GBCalibrating your own target
import pyarrow.parquet as pq
metadata = pq.read_metadata('s3://freshcart-lake/silver/orders/date=2026-03-17/part-00001.parquet')
file_size_mb = metadata.serialized_size / (1024 * 1024)
row_count = metadata.num_rows
num_row_groups = metadata.num_row_groups
print(f"File size: {file_size_mb:.1f} MB")
print(f"Row count: {row_count:,}")
print(f"Row groups: {num_row_groups}")
print(f"MB per million rows: {file_size_mb / row_count * 1_000_000:.1f}")File size: 487.3 MB
Row count: 3,842,900
Row groups: 8
MB per million rows: 126.8
# use this last number to size target_files in the compaction functions aboveRow groups within a Parquet file also matter: smaller row groups give finer predicate pushdown (each row group carries its own min/max statistics) but more metadata overhead; larger row groups are the opposite trade.
pq.write_table(
table, path,
row_group_size=500_000, # 500k rows per row group
)Bloom filters — accelerating point lookups in Parquet
A bloom filter is a probabilistic structure that answers “is value X definitely NOT in this row group?” If yes, the row group is skipped entirely with zero reads — useful specifically for point lookups on high-cardinality columns, not for date filters that partition pruning already handles.
pq.write_table(
table, 'output.parquet', compression='zstd',
write_bloom_filter=True,
bloom_filter_columns=['payment_id', 'order_id'], # high-cardinality point-lookup columns
bloom_filter_false_positive_rate=0.05, # lower rate → larger filter, better pruning
)
# Snowflake / BigQuery achieve a similar effect via table clustering:
# ALTER TABLE silver.orders CLUSTER BY (date, store_id);Format Conversion Pipelines — CSV/JSON to Parquet in Production
The most common file operation in a data lake’s Bronze layer is format conversion: raw CSV and JSON files from vendors and APIs become typed, compressed, partitioned Parquet files. This looks simple but has real edge cases — encoding, bad rows, schema mismatches — that a production pipeline has to handle explicitly.
Reading the source file defensively
import logging
import chardet
import pandas as pd
import pyarrow as pa
from typing import Iterator
log = logging.getLogger('csv_to_parquet')
# Explicit schema — better than letting Parquet infer types from messy CSV
ORDERS_SCHEMA = pa.schema([
pa.field('order_id', pa.int64(), nullable=False),
pa.field('store_id', pa.string(), nullable=False),
pa.field('customer_id', pa.int64(), nullable=True),
pa.field('amount', pa.decimal128(10, 2), nullable=False),
pa.field('status', pa.string(), nullable=False),
pa.field('created_at', pa.timestamp('us', tz='UTC'), nullable=False),
pa.field('ingested_at', pa.timestamp('us', tz='UTC'), nullable=False),
])
def detect_encoding(filepath: str, sample_bytes: int = 100_000) -> str:
"""Detect file encoding from the first N bytes."""
with open(filepath, 'rb') as f:
raw = f.read(sample_bytes)
result = chardet.detect(raw)
encoding, confidence = result.get('encoding') or 'utf-8', result.get('confidence', 0)
log.info(f'Detected encoding: {encoding} (confidence: {confidence:.0%})')
return encoding if confidence > 0.7 else 'utf-8'
def read_csv_chunked(filepath: str, chunk_size: int = 200_000) -> Iterator[pd.DataFrame]:
"""Read CSV in chunks, with detected encoding and bad-line tolerance."""
encoding = detect_encoding(filepath)
try:
for chunk in pd.read_csv(
filepath, chunksize=chunk_size, encoding=encoding, encoding_errors='replace',
dtype=str, na_values=['', 'NULL', 'null', 'N/A', 'n/a', 'NA', '-'],
on_bad_lines='warn',
):
yield chunk
except Exception as e:
log.error('Failed to read CSV %s: %s', filepath, str(e))
raiseCasting and cleaning each chunk
from datetime import date
def cast_chunk(chunk: pd.DataFrame, source_date: date) -> pd.DataFrame:
"""Apply type casting and add pipeline metadata columns."""
df = chunk.copy()
for col in ['order_id', 'customer_id']:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors='coerce').astype('Int64')
if 'amount' in df.columns:
df['amount'] = pd.to_numeric(df['amount'].str.replace(',', '', regex=False), errors='coerce').round(2)
if 'created_at' in df.columns:
df['created_at'] = pd.to_datetime(df['created_at'], utc=True, errors='coerce')
df['ingested_at'] = pd.Timestamp.now(tz='UTC')
df['source_date'] = source_date.isoformat() # partition column
bad_mask = df['order_id'].isna()
if bad_mask.any():
log.warning('Dropping %d rows with NULL order_id', bad_mask.sum())
df = df[~bad_mask]
return dfAssembling the conversion pipeline
import pyarrow.parquet as pq
import pyarrow.fs as pafs
from pathlib import Path
def convert_csv_to_parquet(input_path: str, output_root: str, source_date: date, s3_bucket: str | None = None) -> dict:
"""Convert a CSV file to partitioned Parquet. Returns rows_written/rows_rejected/files_written."""
stats = {'rows_written': 0, 'rows_rejected': 0, 'files_written': 0}
all_tables = []
for chunk_idx, chunk in enumerate(read_csv_chunked(input_path)):
original_rows = len(chunk)
cast = cast_chunk(chunk, source_date)
dropped = original_rows - len(cast)
if dropped:
stats['rows_rejected'] += dropped
log.warning('Chunk %d: dropped %d invalid rows', chunk_idx, dropped)
if len(cast) > 0:
try:
all_tables.append(pa.Table.from_pandas(cast, schema=ORDERS_SCHEMA, safe=False))
except Exception as e:
log.error('Schema cast failed on chunk %d: %s', chunk_idx, str(e))
raise
stats['rows_written'] += len(cast)
if not all_tables:
log.warning('No valid rows to write')
return stats
full_table = pa.concat_tables(all_tables)
output_path = f"{s3_bucket}/bronze/orders" if s3_bucket else output_root + "/orders"
filesystem = pafs.S3FileSystem(region='ap-south-1') if s3_bucket else pafs.LocalFileSystem()
if not s3_bucket:
Path(output_path).mkdir(parents=True, exist_ok=True)
pq.write_to_dataset(
full_table, root_path=output_path, partition_cols=['source_date'],
filesystem=filesystem, compression='zstd', row_group_size=500_000,
write_statistics=True, existing_data_behavior='overwrite_or_ignore',
)
log.info(f"Conversion complete: {stats['rows_written']:,} rows written, "
f"{stats['rows_rejected']:,} rejected, source_date={source_date.isoformat()}")
return statsINFO Detected encoding: utf-8 (confidence: 99%)
WARNING Chunk 3: dropped 12 invalid rows
INFO Conversion complete: 199,988 rows written, 12 rejected, source_date=2026-03-17JSON-to-Parquet with nested-structure flattening
import json
from typing import Iterator
def read_ndjson(filepath: str) -> Iterator[dict]:
"""Stream records from an NDJSON file one at a time."""
with open(filepath, encoding='utf-8') as f:
for line_num, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError as e:
log.warning('Invalid JSON on line %d: %s', line_num, str(e))
def flatten_order(raw: dict) -> dict:
"""Flatten nested order JSON to a flat dict for Parquet storage."""
return {
'order_id': raw.get('order_id') or raw.get('id'),
'customer_id': raw.get('customer', {}).get('id'),
'customer_city': raw.get('customer', {}).get('address', {}).get('city'),
'restaurant_id': raw.get('restaurant', {}).get('id'),
'order_amount': raw.get('payment', {}).get('amount'),
'payment_method': raw.get('payment', {}).get('method'),
'item_count': len(raw.get('items', [])),
'status': raw.get('status'),
'created_at': raw.get('created_at'),
'_raw_items': json.dumps(raw.get('items', [])), # kept for reference
}import pyarrow.parquet as pq
def json_to_parquet(input_path: str, output_path: str, batch_size: int = 100_000) -> int:
"""Convert NDJSON to Parquet with flattening. Returns total rows written."""
total, batch, writer = 0, [], None
for record in read_ndjson(input_path):
batch.append(flatten_order(record))
if len(batch) >= batch_size:
table = pa.Table.from_pandas(pd.DataFrame(batch))
if writer is None:
writer = pq.ParquetWriter(output_path, table.schema, compression='zstd')
writer.write_table(table)
total += len(batch)
batch = []
if batch:
table = pa.Table.from_pandas(pd.DataFrame(batch))
if writer is None:
writer = pq.ParquetWriter(output_path, table.schema, compression='zstd')
writer.write_table(table)
total += len(batch)
if writer:
writer.close()
log.info(f"Wrote {total:,} rows to {output_path}")
return totalWARNING Invalid JSON on line 40218: Expecting ',' delimiter: line 1 column 812
INFO Wrote 199,999 rows to s3://freshcart-lake/bronze/orders_json/2026-03-17.parquetFile Lifecycle — Retention, Archival, and Cleanup
A data lake without a lifecycle policy is a storage cost that grows indefinitely. Every file written to S3 costs money per GB per month forever, unless explicitly deleted or transitioned to cheaper storage.
S3 lifecycle rules — automated tier transitions
resource "aws_s3_bucket_lifecycle_configuration" "freshcart_lake" {
bucket = "freshcart-data-lake"
# Landing zone — raw files only needed until converted to Bronze
rule {
id = "landing-zone-cleanup"
status = "Enabled"
filter { prefix = "landing/" }
transition { days = 7 storage_class = "STANDARD_IA" }
expiration { days = 30 }
}
# Bronze — keep 1 year in Standard, then archive
rule {
id = "bronze-archive"
status = "Enabled"
filter { prefix = "bronze/" }
transition { days = 90 storage_class = "STANDARD_IA" }
transition { days = 365 storage_class = "GLACIER_IR" }
}
}resource "aws_s3_bucket_lifecycle_configuration" "freshcart_lake_silver" {
bucket = "freshcart-data-lake"
rule {
id = "silver-lifecycle"
status = "Enabled"
filter { prefix = "silver/" }
transition { days = 180 storage_class = "STANDARD_IA" }
transition { days = 730 storage_class = "GLACIER" } # 2 years
}
# Gold layer — usually small, kept hot forever, no rule needed
}| Storage class | $/GB/month | Savings vs Standard | Retrieval |
|---|---|---|---|
| S3 Standard | $0.023 | — | Instant |
| Standard-IA | $0.0125 | 45% cheaper | Instant (30-day min) |
| Glacier Instant | $0.004 | 83% cheaper | Milliseconds |
| Glacier Flexible | $0.0036 | 84% cheaper | 3–5 hours |
| Glacier Deep Archive | $0.00099 | 96% cheaper | 12+ hours |
Delta Lake VACUUM — cleaning up old file versions
Delta Lake writes new Parquet files for every UPDATE, DELETE, and OPTIMIZE. Old files are kept for time travel but cost real money — VACUUM removes files no longer needed for that.
-- Default retention: 7 days (safe minimum for active transactions)
VACUUM silver.orders RETAIN 168 HOURS;
-- Python API, with a dry run first:
from delta.tables import DeltaTable
dt = DeltaTable.forPath(spark, 's3://freshcart-lake/silver/orders')
dt.vacuum(retentionHours=168, dry_run=True) # shows what WOULD be deleted
dt.vacuum(retentionHours=168) # then actually delete
# Schedule weekly on all Silver/Gold tables: 0 3 * * 0 (Sunday 3 AM)Auditing what you actually have
import boto3
from collections import defaultdict
def audit_s3_prefix(bucket: str, prefix: str) -> dict:
"""Count files, total size, and flag small-file-problem symptoms."""
s3 = boto3.client('s3')
paginator = s3.get_paginator('list_objects_v2')
sizes, partition_files = [], defaultdict(list)
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
for obj in page.get('Contents', []):
size_mb = obj['Size'] / (1024 * 1024)
sizes.append(size_mb)
partition = '/'.join(obj['Key'].split('/')[:-1])
partition_files[partition].append(size_mb)
if not sizes:
return {'error': 'No files found'}
small_files = sum(1 for s in sizes if s < 10)
report = {
'total_files': len(sizes), 'total_gb': round(sum(sizes) / 1024, 2),
'avg_mb': round(sum(sizes) / len(sizes), 1),
'small_files': small_files, 'small_pct': round(small_files / len(sizes) * 100, 1),
'partitions': len(partition_files),
}
if small_files / len(sizes) > 0.5:
report['warning'] = 'More than 50% of files are < 10 MB — compaction needed'
return report{
'total_files': 48234, 'total_gb': 142.7, 'avg_mb': 3.0,
'small_files': 45891, 'small_pct': 95.1, 'partitions': 1095,
'warning': 'More than 50% of files are < 10 MB — compaction needed'
}
# avg_mb of 3.0 against a 128-512 MB target, with 95% of files under 10 MB,
# is exactly the small file problem from Part 05 — this audit is what catches itFive Misconceptions About Files at Scale
Diagnosing a Slow Athena Query — and Fixing It with File Engineering
An analyst runs a monthly revenue report every Monday morning. It used to take 90 seconds. This week it took 18 minutes. Nothing changed in the query. You investigate.
# Step 1: Athena console → query execution detail
# Data scanned: 4.2 TB ← the problem signal (expected ~400 GB for 90 days)
# Step 2: check partition count in the catalog
$ aws glue get-partitions --database-name freshcart_silver --table-name events \
--query 'Partitions | length(@)'
# 156,420 partitions — expected ~1,095 for 3 years of daily data
# Step 3: find the culprit partition key
$ aws glue get-partitions --database-name freshcart_silver --table-name events \
--query 'Partitions[0:5].StorageDescriptor.Location'
# s3://freshcart-lake/silver/events/date=2026-03-17/hour=20/minute=14/
# s3://freshcart-lake/silver/events/date=2026-03-17/hour=20/minute=15/
# → the pipeline was accidentally partitioning by MINUTE
$ aws s3 ls s3://freshcart-lake/silver/events/ --recursive | awk '{print $4}' | wc -l
# 4,847,293 files — each ~1 KB (one minute of events)from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
df = spark.read.parquet('s3://freshcart-lake/silver/events/')
df.write.mode('overwrite').partitionBy('date').parquet('s3://freshcart-lake/silver/events_v2/')
spark.sql('MSCK REPAIR TABLE freshcart_silver.events_v2')Before → After
Partitions: 156,420 → 1,095
Files: ~4.8M → ~3,285
Avg file size: ~1 KB → ~130 MB
Query time: 18 min → 94 sec (11× improvement)
Data scanned: 4.2 TB → 412 GB (10× reduction)The root cause was a single line in the Spark write configuration that added minute as a partition column alongside date. This turned 1,095 daily partitions into 1.5 million minute partitions — all valid, all correct data, but completely unusable for analytics. Two hours of investigation and a Spark rewrite job fixed it permanently.
5 Interview Questions — With Complete Answers
Mistakes Beginners Make Constantly
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓File naming must encode source, entity, date (ISO 8601 UTC), and a unique run identifier. ISO 8601 dates (YYYYMMDD) sort lexicographically in chronological order. Include the pipeline run ID in filenames to trace any file back to the run that created it. Never use mutable names like "latest" or "final."
- ✓Hive-style partitioning (date=2026-03-17/ directories) enables partition pruning — query engines skip entire directories that cannot contain matching rows. A date-filtered query on a date-partitioned table reads 0.3% of data instead of 100%. Partition pruning is the single biggest performance lever in a data lake.
- ✓Choose partition columns with low-to-medium cardinality (date: 365/year, store_id: 10–1,000). Never partition by high-cardinality columns like customer_id — 10 million customers creates 10 million directories, making every operation slower. Each partition should hold at least 100 MB of data.
- ✓For data lake Parquet files, choose ZSTD (better ratio than Snappy at similar speed, modern default) or Snappy (widely supported, fast). Use GZIP only for archival and landing zone files where storage cost matters more than read speed. Never store uncompressed files in production.
- ✓Splittability matters for Spark parallelism. Parquet files are splittable at the row group level regardless of codec. Plain .gz CSV files are not splittable — one executor reads the whole file. Always use Parquet (not gzip CSV) in the analytical layer.
- ✓The small file problem occurs when streaming or micro-batch pipelines create millions of tiny files. Performance impact: S3 LIST API overhead, Spark task scheduling waste, and Parquet footer read overhead dominate actual read time. Target 256 MB to 1 GB per Parquet file.
- ✓Fix small files with compaction: Delta Lake OPTIMIZE, Spark coalesce and overwrite, or PyArrow dataset rewrite. Schedule compaction after every batch write or daily. Prevent small files by batching micro-batches (write hourly not every 5 minutes) and using coalesce before writing.
- ✓Bloom filters on high-cardinality string columns (payment_id, order_id) enable fast point lookups in Parquet by allowing the query engine to skip row groups that definitely do not contain a specific value. Add bloom filters to UUID and external ID columns used in WHERE column = value queries.
- ✓File lifecycle management prevents unbounded storage cost growth. Landing zone files delete after 30 days. Bronze and Silver files transition to Infrequent Access after 90 days and Glacier after 2 years. Delta Lake VACUUM removes old file versions after the time travel retention period (7 days default).
- ✓When an Athena or Spark query is suddenly slow: check data scanned (should match partition size), count files per partition (thousands of tiny files = small file problem), verify format is Parquet not CSV/JSON, confirm partition pruning is firing (no functions on partition columns in WHERE), and check if MSCK REPAIR TABLE needs to be run to register new partitions.
What comes next
Module 20 introduces the data pipeline — its anatomy from ingestion to serving, the design principles that separate maintainable pipelines from ones that break every week, and the anti-patterns that look reasonable until production.
Module 20 → What is a Data Pipeline?Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.