Skip to content
>GLB_
Go back

Small Files Problem in Delta Lake: Detection and Compaction Strategy

>GLB_ // tools · Delta Lake

A Delta table that ingests every five minutes looks fine on day one. By day ninety, a SELECT count(*) that used to take 4 seconds takes 90, and nothing in the query changed. The data volume barely grew. The file count did.

This post covers how to measure the problem from the transaction log, why it hurts, and a compaction strategy that doesn’t fight your streaming jobs.

Why small files hurt

Every Parquet file carries fixed costs that don’t scale with its size:

The usual causes:

  1. Streaming / micro-batch ingestion with short triggers.
  2. High-cardinality partitioning (one file per partition per batch).
  3. MERGE / UPDATE / DELETE rewriting only the touched rows into new small files.
  4. Too many shuffle partitions on small writes (spark.sql.shuffle.partitions = 200 writing 50 MB).

Step 1: Detect it

Quick check

DESCRIBE DETAIL my_catalog.sales.events;

numFiles and sizeInBytes give you the average file size. Anything averaging below ~64–128 MB on a table larger than a few GB is a candidate.

Real distribution from the transaction log

The average lies. A table with 20 files of 1 GB and 5,000 files of 1 MB averages ~5 MB, but the problem is concentrated. Get the distribution from the active snapshot instead of listing storage (storage also contains files already removed from the table but not yet vacuumed). With delta-rs you can read the add actions of the current snapshot without Spark and without scanning data:

from deltalake import DeltaTable
import pyarrow.compute as pc

dt = DeltaTable("s3://lake/sales/events")
adds = dt.get_add_actions(flatten=True)  # one row per active file

sizes_mb = pc.divide(adds["size_bytes"], 1024 * 1024)
print("files:", len(adds))
print("p50 MB:", pc.quantile(sizes_mb, 0.5)[0])
print("p90 MB:", pc.quantile(sizes_mb, 0.9)[0])
print("files < 32 MB:", pc.sum(pc.less(sizes_mb, 32)).as_py())

If the table is partitioned, group by the partition columns (partition.<col> in the flattened output) to find which partitions are fragmented. Usually it’s the recent ones still receiving writes.

Signals to track over time

MetricHealthy-ishInvestigate
p50 file size> 128 MB< 32 MB
Files per partitiontensthousands
_delta_log JSON commits since last checkpoint≤ checkpoint intervalgrowing without checkpoints

Thresholds depend on the engine and workload; treat these as starting points, not rules.

Step 2: Compact with OPTIMIZE

OPTIMIZE bin-packs small files into larger ones and commits the change as a new table version:

-- Whole table
OPTIMIZE my_catalog.sales.events;

-- Only recent partitions (cheaper, and avoids rewriting cold data)
OPTIMIZE my_catalog.sales.events
WHERE event_date >= current_date() - INTERVAL 7 DAYS;

Python API (Delta Lake OSS):

from delta.tables import DeltaTable

path = "s3://lake/sales/events"
(DeltaTable.forPath(spark, path)
    .optimize()
    .where("event_date >= '2026-10-01'")
    .executeCompaction())

Key properties:

With delta-rs, outside Spark:

DeltaTable(path).optimize.compact(target_size=512 * 1024 * 1024)

Step 3: Prevent it at write time

Compaction is the cleanup. These reduce how much cleanup you need:

ALTER TABLE my_catalog.sales.events SET TBLPROPERTIES (
  'delta.autoOptimize.optimizeWrite' = 'true',
  'delta.autoOptimize.autoCompact'  = 'true'
);

Availability of these properties depends on your Delta version and platform. Check the docs for your runtime before relying on them.

Other levers:

A compaction strategy that works

  1. Write side: optimized writes on; streaming triggers as long as the SLA allows.
  2. Hot partitions: scheduled OPTIMIZE ... WHERE over the last N days, e.g. hourly or daily depending on ingestion rate.
  3. Cold data: compact once after the partition stops receiving writes. Don’t keep re-optimizing it.
  4. Storage: VACUUM on a schedule, respecting the retention window required by time travel and your longest-running readers.
  5. Measure: log p50/p90 file size per table after each run. If p50 keeps dropping between runs, the write side is the problem, not the schedule.

On newer Delta versions, liquid clustering replaces partitioning + Z-order for many tables and makes OPTIMIZE incremental. Worth evaluating before designing a new partition scheme.

Concurrency note

OPTIMIZE uses optimistic concurrency. It can conflict with concurrent UPDATE/DELETE/MERGE that touch the same files. Append-only writers don’t conflict with it. If you run frequent MERGEs, schedule compaction in windows where they don’t overlap the same partitions.

References


Share this post:

Related Posts


Previous Post
Delta Lake Schema Evolution Explained with Real Examples
Next Post
Comparing Parquet Compression Codecs: Snappy vs ZSTD Benchmark