General

The "Small File" Disaster: Why Streaming Data Lakes Silently Bankrupt S3 and Paralyze Query Engines

Oct 17, 2026/11 min read

calricon / field note

Data engineering teams deploy real-time Kafka and Flink streaming ingestion to Amazon S3, celebrating near-instant analytical availability. In production, accumulating millions of 200KB Parquet files turns data lakes into operational swamps: query engines spend 90% of their runtime negotiating S3 metadata, and API request fees explode by 1,000x. Here is how elite cloud architects implement automated Apache Iceberg bin-packing and micro-batch buffering to tame the small file disaster.

Oct 17, 2026/General
179
Advertisement

The Core Premise: Why Real-Time Streaming Ingestion Destroys Columnar Storage

Writing streaming data directly to cloud object storage in sub-minute micro-batches is the fastest way to turn an enterprise data lake into an un-queryable, multimillion-dollar operational swamp. As organizations modernize their data architectures with Apache Kafka, AWS Kinesis, and Apache Flink, business stakeholders demand real-time visibility into clickstreams, financial transactions, and IoT telemetry. To deliver low data latency, engineering teams configure streaming sinks to flush data to Amazon S3 or Google Cloud Storage every 10 to 30 seconds. In production, this architecture generates hundreds of thousands of tiny, 100KB to 500KB Parquet or JSON files every single day. While columnar formats like Apache Parquet and table formats like Apache Iceberg or Delta Lake are engineered to deliver blistering analytics by reading massive 128MB to 512MB contiguous row groups, scattering data across millions of fragmented objects shatters the underlying storage engine. Distributed query engines (Amazon Athena, Trino, Snowflake, Databricks Spark) spend 90% of their execution time listing object prefixes, parsing metadata footers, and opening TLS connections to S3 rather than reading columnar data. Worse yet, because cloud providers bill per API request ($0.005 per 1,000 LIST requests and $0.0004 per 1,000 GET requests), running a single analytical query across an uncompacted table triggers millions of billable S3 operations, inflating cloud storage invoices by 1,000x while analytics dashboards freeze in perpetual timeout.

"A data lake with 10 million 100KB files is not an analytical storage tier; it is an unindexed file system trapped behind a meter that charges you money every time your query engine breathes. You pay 10x more for the S3 GET requests than you pay to store the actual data."

The Anatomy of Query Paralysis: The Physics of S3 Object Latency

To understand why small files destroy data lake performance, examine what happens when an analytical engine executes a SQL query (e.g., SELECT sum(amount) FROM transactions WHERE date = '2026-10-15') across two identical 100GB datasets:

  • Scenario A (Optimized Storage): The 100GB dataset is compacted into 200 properly sized Parquet files of 500MB each.
    • Athena issues 200 HTTP GET requests to S3.
    • Query workers read Parquet file footers, evaluate column statistics (min/max pruning), skip unneeded columns entirely, and stream data in bulk.
    • Query Execution Time: 3.2 seconds. S3 API cost: $0.00008.
  • Scenario B (Streaming Fragmentation): The identical 100GB dataset is stored as 1,000,000 small files of 100KB each (generated by a 15-second Kafka streaming flush).
    • Athena must issue 1,000 S3 LIST requests simply to discover the files.
    • Athena worker nodes must establish 1,000,000 individual HTTPS connections to S3. Each file requires roughly 150ms to 200ms of network overhead just to negotiate TLS, read the HTTP headers, and parse the tiny Parquet metadata footer.
    • Columnar compression ratios completely collapse because tiny files lack sufficient statistical sample depth to build effective dictionary encoding.
    • Query Execution Time: 18 minutes (or fails with Query Timeout). S3 API cost: $0.405 per query. Running this query 300 times a day costs $3,645/month in pure S3 API request fees for a single table.

The 4 Hidden Disasters of Small File Ingestion

1. S3 Request Cost Multipliers Outpacing Storage Fees

Cloud FinOps teams scrutinize S3 capacity storage rates ($0.023 per GB-month for S3 Standard), assuming data volume is their primary expense:

  • The 1,000x Inversion: Reading 1 TB of data stored in 1,000 files incurs 1,000 GET requests ($0.0004). Reading that same 1 TB distributed across 10,000,000 small files incurs 10,000,000 GET requests ($4.00). If downstream dashboards, ETL dbt jobs, and ML training pipelines query that table multiple times daily, API request charges exceed base storage costs by over 400%.
  • The S3 Standard-IA Penalty: If organizations attempt to save money by lifecycle-moving older data to S3 Standard-Infrequent Access (Standard-IA), the small file problem becomes catastrophic. S3 Standard-IA enforces a 128 KB minimum billable object size. A 10 KB streaming event file is billed as if it were 128 KB, instantly multiplying storage invoices by 12x.

2. Apache Iceberg and Delta Lake Metadata Tree Bloat

Modern open table formats (Apache Iceberg, Delta Lake) replace legacy Hive metastores with immutable metadata snapshot trees:

  • Manifest File Explosion: In Apache Iceberg, every committed write creates a snapshot containing manifest files listing every individual data file. When streaming writers commit every 30 seconds, an Iceberg table accumulates 2,880 snapshots and tens of thousands of manifest files every 24 hours.
  • Driver Memory Exhaustion (OOM): When a Trino or Spark query planner attempts to plan a query, it must load the entire manifest list into the driver's memory. The query planner spends minutes walking deep metadata trees, exhausting JVM heap memory and crashing coordinator nodes with java.lang.OutOfMemoryError before a single byte of data is read.

3. Columnar Compression Ratio Collapse

Parquet achieves its industry-standard 75% to 85% compression ratios through sophisticated dictionary encoding, run-length encoding (RLE), and Snappy/Zstd compression across large columnar blocks:

  • Dictionary Starvation: When a Parquet file contains only 1,000 rows (100KB), the compression dictionary overhead accounts for 30% of the total file size. The columnar format loses its compression advantage, requiring 3x more disk storage to hold the exact same raw data compared to a 256MB row group.

4. Compaction Job Deadlocks and Thrashing

When engineering teams realize they have a small file problem, their standard reaction is to schedule a background Apache Spark compaction job (e.g., Iceberg's rewrite_data_files):

  • Compaction Lags Ingestion: Ingestion pipelines continue dumping 50,000 new small files into S3 every hour. The compaction job takes 3 hours to scan, download, merge, and re-upload the files into 128MB chunks.
  • Snapshot Conflict Retries: Because the streaming writer commits every 30 seconds while the compaction job takes hours, the compaction commit constantly encounters optimistic concurrency conflicts (CommitFailedException) and must retry repeatedly, burning thousands of dollars in EMR or Databricks compute without ever catching up.

Architectural Comparison: Naive Streaming Lake vs. Calricon Adaptive Lakehouse

Dimension Naive Micro-Batch Streaming Production Failure Mode Calricon Adaptive Lakehouse Architecture
Ingress Target File Size Unconstrained (50KB–500KB flushes) Query engine metadata thrashing; 1,000x S3 GET costs Target 256MB–512MB contiguous Parquet files
Ingestion Buffering Time-based flush to S3 every 15–30s Generates 2,800+ commits and tiny files daily Two-tier buffer (Memory/NVMe micro-batching before S3)
Compaction Strategy Occasional ad-hoc full-table rewrites Compaction fails due to OOM; cannot keep up Continuous automated bin-packing (Iceberg partial compaction)
Query Planning Speed Slow (minutes spent walking S3 object trees) Query timeouts; driver node crashes Sub-second plan generation with min/max manifest pruning

The Calricon Production Playbook: Taming the Small File Problem

1. Enforce Two-Tier Ingestion: Buffer in Memory Before Hitting S3

Never permit streaming workers (Flink, Kafka Connect, or Vector) to write directly to your primary analytical S3 prefix every 15 seconds. Implement an intermediate buffering layer:

  • Configure streaming sinks to buffer records in worker memory or high-speed NVMe scratch storage until either 128MB of uncompressed data is accumulated OR 15 minutes have elapsed.
  • Flushing at 128MB thresholds instantly reduces the number of generated S3 objects by 99.8%, ensuring every single written Parquet file possesses deep dictionary compression and optimized row group sizing.

2. Implement Continuous Asynchronous Bin-Packing Compaction

If near-real-time ingestion necessitates writing temporary small files into a "staging" or "bronze" layer, decouple write operations from long-term storage using automated bin-packing compaction:

-- Apache Iceberg: Automated Partial Bin-Packing Compaction
CALL system.rewrite_data_files(
    table => 'analytics.clickstream_events',
    strategy => 'binpack',
    options => map(
        'target-file-size-bytes', '536870912', -- Target 512MB Parquet files
        'min-file-size-bytes', '67108864',     -- Consider anything under 64MB a small file
        'max-file-group-size-bytes', '10737418240', -- Limit compaction groups to 10GB to avoid OOM
        'partial-progress.enabled', 'true',    -- Commit batches incrementally!
        'partial-progress.max-commits', '10'
    )
);
  

Enabling partial-progress.enabled allows the compaction engine to commit compacted batches incrementally every few minutes, preventing long-running transactions from deadlocking against streaming writers.

3. Prune Table Snapshots and Orphaned Metadata Aggressively

Compacting data files is only half the battle; you must clean up historical metadata snapshots that bloat query planning:

  • Schedule an automated daily job executing expire_snapshots with a 3-day retention policy:
    CALL system.expire_snapshots(
        table => 'analytics.clickstream_events',
        older_than => TIMESTAMP '2026-10-14 00:00:00.000',
        retain_last => 24
    );
          
  • This purges millions of dead manifest records from S3, ensuring query coordinators parse table metadata in under 200 milliseconds.

4. Alert on S3 Object Count vs. Byte Growth Ratios

Monitor your data lake health using CloudWatch metrics. Create a custom alert calculating the Average Object Size Ratio: TotalBytesStored / NumberOfObjects. If average object size across your production analytical prefixes drops below 64MB, trigger high-priority alerts to catch streaming pipeline misconfigurations before they manifest as query timeouts and budget blowouts.

The Strategic Takeaway: Big Data Requires Big Files

Distributed analytics engines are marvels of modern engineering, capable of scanning petabytes of data in seconds. But their performance relies on a non-negotiable physical contract: sequential, contiguous reads over large data blocks.

By enforcing two-tier buffering at ingestion, automating continuous Iceberg bin-packing, and maintaining rigorous metadata snapshot hygiene, cloud data leaders can permanently eliminate the small file disaster—accelerating query performance by 10x while saving thousands of dollars in wasted S3 request fees every month.

Advertisement
Talk to Calricon