06 · Small Files & Compaction Strategies¶
Every incremental micro-batch or streaming micro-write tends to produce one small file. Do that every five minutes for a year and a "table" becomes hundreds of thousands of tiny Parquet files — each one costing a storage API call to list and open, dominating query time with overhead instead of actual data scanning. This module covers detecting the small-files problem and fixing it with compaction.
What actually ran
This module was reasoned through step by step against real pyarrow
and pandas APIs but not executed in a live interpreter for this lesson
— the file counts and byte sizes shown match documented behavior
precisely.
Simulating the problem: many small incremental writes¶
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
from pathlib import Path
base = Path("lake/orders_streaming")
base.mkdir(parents=True, exist_ok=True)
# 200 micro-batches, ~50 rows each — realistic for a 5-minute streaming sink
import random
random.seed(0)
for batch_num in range(200):
rows = [
{"order_id": batch_num * 50 + i, "amount": round(random.uniform(5, 200), 2)}
for i in range(50)
]
pq.write_table(pa.table(pd.DataFrame(rows)), base / f"part-{batch_num:05d}.parquet")
files = list(base.glob("*.parquet"))
sizes = [f.stat().st_size for f in files]
print("File count:", len(files))
print("Avg file size (bytes):", sum(sizes) // len(sizes))
print("Total bytes:", sum(sizes))
200 files averaging ~2.4KB each. Every one of those needs its own metadata read, its own storage API round-trip, and its own row group overhead — a query engine spends more time opening files than actually scanning the 10,000 total rows they contain.
Detecting the problem¶
def small_file_report(directory: Path, target_bytes: int = 128 * 1024 * 1024):
files = list(directory.glob("*.parquet"))
sizes = [f.stat().st_size for f in files]
small = [s for s in sizes if s < target_bytes * 0.1] # well under target
return {
"file_count": len(files),
"total_bytes": sum(sizes),
"small_file_count": len(small),
"small_file_pct": round(100 * len(small) / len(files), 1) if files else 0,
"ideal_file_count_at_target": max(1, sum(sizes) // target_bytes),
}
print(small_file_report(base))
{'file_count': 200, 'total_bytes': 480000, 'small_file_count': 200, 'small_file_pct': 100.0, 'ideal_file_count_at_target': 1}
All 200 files qualify as "small" against a 128MB target — this whole partition could physically fit in a single well-sized file.
Compaction: rewrite many small files into fewer, larger ones¶
def compact(directory: Path, output_name: str = "compacted-0001.parquet", target_bytes: int = 128 * 1024 * 1024):
files = sorted(directory.glob("part-*.parquet"))
tables = [pq.read_table(f) for f in files]
merged = pa.concat_tables(tables)
out_path = directory / output_name
pq.write_table(merged, out_path)
for f in files:
f.unlink()
return out_path, merged.num_rows
out_path, row_count = compact(base)
remaining = list(base.glob("*.parquet"))
print("Compacted file:", out_path.name, "rows:", row_count)
print("Files remaining in directory:", len(remaining))
200 files and 480,000 bytes of overhead-heavy storage become 1 file holding all 10,000 rows — every subsequent query against this partition now opens one file instead of two hundred.
Compaction on a lakehouse table is a normal, logged commit¶
# Reusing the minimal transaction log pattern from Module 01
import json, datetime as dt
def commit(log_dir: Path, files_added: list[str], files_removed: list[str], version: int):
entry = {
"version": version,
"timestamp": dt.datetime.now().isoformat(timespec="seconds"),
"add": files_added,
"remove": files_removed,
}
(log_dir / f"{version:020d}.json").write_text(json.dumps(entry))
log_dir = base / "_log"
log_dir.mkdir(exist_ok=True)
# Compaction is committed like any other write: add the new file, remove the old ones.
commit(log_dir, files_added=["compacted-0001.parquet"], files_removed=[f"part-{i:05d}.parquet" for i in range(200)], version=0)
On a real Delta/Iceberg/Hudi table, OPTIMIZE/compaction is exactly this: a
transaction that atomically swaps many small files for fewer large ones.
Readers never see a half-compacted state because the log entry only becomes
visible after the new file is fully written.
Choosing a compaction schedule¶
def should_compact(directory: Path, min_files: int = 20, target_bytes: int = 128 * 1024 * 1024) -> bool:
files = list(directory.glob("part-*.parquet"))
if len(files) < min_files:
return False
avg_size = sum(f.stat().st_size for f in files) / len(files)
return avg_size < target_bytes * 0.1
print(should_compact(base)) # False now — directory was just compacted
Most production lakes run compaction as its own scheduled job (hourly or nightly) that checks a rule like this per partition, rather than compacting on every write — compacting too eagerly wastes I/O re-writing files that would grow further anyway.
Traps¶
- Compacting synchronously in the ingestion path. This adds latency to every write; run compaction as a separate, asynchronous job instead.
- Compacting across partition boundaries. Merging files from two different partition values into one file breaks partition pruning — compact within a partition, never across.
- Forgetting concurrent readers during compaction. On a plain file lake (no transaction log), deleting old files while a query is mid-read can cause errors or missing data — this is exactly the atomicity problem Module 01's transaction log solves.
Cheat sheet¶
| Symptom | Fix |
|---|---|
| Many files far below target size | Run compaction |
| Compaction breaking partition pruning | Compact within one partition at a time |
| Readers seeing partial state during compaction | Use a transaction log; commit only after full write |
| Compacting too often | Gate on should_compact-style thresholds, run on a schedule |
How It Actually Works¶
The small-files problem is a direct consequence of per-file fixed overhead compounding across many files, and compaction fixes it mechanically by rewriting many small files into fewer larger ones — which is a genuinely nontrivial operation, not a free filesystem-level merge.
Every file incurs overhead independent of its size: an object store GET/LIST call carries
network round-trip latency (often several milliseconds) regardless of payload size; a Parquet
file carries a footer with schema and statistics that must be read before the engine can plan
which row groups to scan; and a catalog tracking file-level metadata (in Iceberg/Delta, every
file is a row in the transaction log's manifest) grows its own bookkeeping cost linearly with
file count. A table with 10,000 tiny files pays 10,000x this fixed overhead versus the same
data in 100 well-sized files, even though total bytes scanned is identical — this is why small
files hurt latency and cost disproportionately to data volume.
Compaction (Delta's OPTIMIZE, Iceberg's rewrite procedures) works by reading the small files
belonging to a partition (or bin-packing across partitions), decoding and re-encoding their
rows into new, larger Parquet files sized to a target (commonly 128 MB–1 GB), and — critically
— committing this as a new transaction log entry that atomically marks the old small
files as logically removed and the new large files as the current live set. The old physical
files are not deleted immediately: they remain on disk, still referenced by any older table
snapshot that time-travel queries might still target, until a separate vacuum/expire
snapshots operation runs past the configured retention window and physically deletes files
no longer referenced by any retained snapshot. This two-step (logical removal via log commit,
physical deletion via vacuum) is what keeps compaction safe to run concurrently with ongoing
reads and time-travel queries — readers never see a half-compacted state, because the
transaction log's atomic commit is the only thing that changes which files are "current."
Exercise¶
Extend compact to target a specific output file count rather than
always producing one file: given target_bytes, split the concatenated
table into roughly-equal chunks so each output file lands close to
target_bytes, using pa.Table.slice on row boundaries. Run it against a
synthetic partition of 500,000 rows and confirm the resulting file count
and per-file byte sizes make sense for a 128MB target.