If you operate Apache Iceberg tables in production, you already know maintenance is not optional. What most teams underestimate is how much it actually costs — and how much of that spend is avoidable.
Iceberg gives you the primitives: rewrite_data_files, expire_snapshots, remove_orphan_files, rewrite_manifests. It does not give you an operational layer that runs them efficiently, in the right order, at the right frequency, on the tables that actually need work. That gap between primitives and practice is where maintenance costs compound silently — across storage, compute, and engineering time.
This article breaks down exactly where Iceberg maintenance dollars go, how each cost category interacts with the others, and what you can do to cut total maintenance spend without sacrificing table health.
Four cost categories that compound
Iceberg maintenance costs are not one line item. They come from four distinct sources that interact with and amplify each other. Fixing one in isolation delivers partial results.
1. Storage waste
Every Iceberg snapshot persists until you explicitly expire it. Each snapshot pins references to data files from that point in time, preventing garbage collection of superseded bytes. Failed writes and aborted Spark jobs leave orphan files — storage objects not referenced in any metadata — that accumulate indefinitely.
The cost math is straightforward. S3 Standard storage costs $0.023/GB/month ($23.55/TB/month) in us-east-1. On mature production lakes with streaming ingestion, unreferenced data from retained snapshots and orphan files routinely represents 25–40% of billable object-storage spend. A table with 30-day snapshot retention and 10-minute commit intervals accumulates over 4,000 snapshots per month. Each holds references to data files the current table state no longer needs.
The numbers from real deployments are concrete. One production scan found approximately 200 TB of orphan data across 324 tables — roughly 1.8 million unreferenced files from aborted transactions, failed Spark jobs, and stale tables. At S3 Standard rates, that was ~$4,700/month in storage for data no query would ever read. Another team found 120 TB removable from expired snapshots alone — approximately $34,000/year in pure waste. Amazon Ads reported a 74% reduction in orphan data and 32% lower storage costs after optimizing their Iceberg file layout — confirming that orphan accumulation is a systemic problem, not an edge case.
2. Query compute waste
This is the hidden maintenance cost most teams miss entirely. Unmaintained tables are expensive to query, not just to store.
Every Parquet file requires at least one S3 GET request to read its footer. S3 GET requests cost $0.0004 per 1,000 — seemingly cheap until you multiply by file count and query frequency. A table with 47,000 small files forces 47,000 metadata round trips before reading a single row. After compacting that table to 280 files, query time dropped from 52 seconds to 5.8 seconds — a 9× improvement that translates directly to 9× less CPU time per execution.
Sort order has an equally large impact. When data is physically sorted by the columns queries filter on, engines use min/max statistics in Parquet row group metadata to skip irrelevant data without reading it. On scan-priced engines like Athena ($5/TB scanned), the difference is stark: a well-sorted 1 TB table queried with a selective filter might cost $0.25 per query. The same query on an unsorted, fragmented copy costs $5.00 — a 20× difference. For a daily reporting pipeline scanning 5 TB across 20 tables, that is $25/run on Athena ($750/month), versus $3–5/run on a right-sized Trino cluster ($90–150/month).
Delete files from merge-on-read operations add another layer. Every query must reconcile data files against position delete files at read time. One production table had 23,433 delete files covering 551 million rows of deleted data — every query paid the full cost of reading and filtering that entire history.
The takeaway: the cost of not maintaining your tables shows up as inflated query compute bills, not as a maintenance line item. It is the largest single cost most teams fail to attribute to table health.
3. Compaction compute
Maintenance itself consumes compute. The dominant pattern — Spark-based compaction on scheduled Airflow DAGs — works, but costs significantly more than it needs to.
Compaction is a narrow, I/O-bound read-merge-write operation. Running it on Spark means paying for JVM startup, garbage collection overhead, executor provisioning, and idle cluster time between runs. EMR Serverless charges $0.052624/vCPU-hour — which adds up fast when compaction runs are over-provisioned (as they usually are, because under-provisioning causes OOM failures, and failures mean 2 AM pages).
The benchmark comparison on a 200 GB table (~1 TB uncompressed): Spark-based binpack takes 1,612 seconds at ~$1.54 in compute. AWS S3 Tables' built-in compaction takes 6,300 seconds. A purpose-built Rust engine on Apache DataFusion completes the same job in 221 seconds at ~$0.21 — roughly one-tenth the compute cost. Independent benchmarks from RisingWave confirm the magnitude: their Rust/DataFusion engine completed the same workload 5.5× faster at 82% lower cost, and succeeded on delete-heavy workloads where Spark failed with OOM errors.
Directional monthly cost comparison for a mid-size estate (~80 TB, 200+ tables, daily compaction):
| Approach | Est. monthly compaction cost | Operational overhead |
|---|---|---|
| Spark on EMR | ~$15,000 | High (clusters, DAGs, on-call) |
| Spark on Glue | ~$12,000 | Medium (job configs, scheduling) |
| Athena OPTIMIZE | ~$8,000 | Low (SQL only, no sort support) |
| Purpose-built Rust engine | ~$1,500 | Minimal (policies, autonomous) |
Estimates based on $50/TB compaction cost for Spark and $5/TB for purpose-built engines, with daily runs across 200+ tables. Actual costs vary by table size, compaction frequency, and instance pricing.
The gap is structural, not incremental. JVM-based engines carry startup, GC, and idle-cluster overhead that compounds across hundreds of daily compaction runs. Native engines built for the specific workload of reading, sorting, and writing Parquet files operate at a fundamentally different cost point.
4. Engineering time
Someone built those Airflow DAGs. Someone maintains them, monitors them, debugs them when compaction conflicts with a streaming writer at 2 AM, and investigates when snapshot expiration fails silently.
At 50 tables, this is manageable side work — as Alex Merced's table maintenance economics analysis notes, a small estate (40 tables, 3 TB) needs one DAG and a week of initial setup. At 500 tables, maintenance scripts become a dedicated role. At medium scale (600 tables, 80 TB), the work concentrates on the ~30 streaming tables that generate most maintenance needs, but table discovery, per-table policy, and failure handling stop being a side project. Merced estimates the ongoing cost at roughly a quarter of an engineer indefinitely, rising with table count.
Fully loaded — salary, on-call burden, opportunity cost — this vector frequently exceeds infrastructure spend on lakes with 300+ tables. It never appears on a cloud bill, which is exactly why it is routinely underestimated.
How the costs interact
These four categories do not operate independently. Each amplifies the others:
- Small files inflate both query and compaction costs. Every query pays per-file overhead. Compaction has more file handles to manage and more footers to read.
- Skipped snapshot expiration inflates storage and compaction costs. Compacting before expiring snapshots rewrites data files that are about to become unreferenced anyway — wasted work.
- Orphan cleanup before expiration is a no-op. The files are still referenced by unexpired snapshots. Running operations out of order wastes compute and leaves storage unreclaimable.
- Manifest fragmentation and small files compound. Tables with many small files tend to have many manifests. Every query planner reads every manifest before deciding which files to scan. At 200+ manifests, planning overhead often exceeds the actual scan.
The implication: sequencing matters as much as execution. Running each operation independently on a fixed schedule yields partial results. Running them as a coordinated loop — in the order Iceberg's architecture requires — is where the full cost reduction materializes.
Learn more:
The correct maintenance sequence
The cost-optimal order is not arbitrary. It follows from Iceberg's metadata dependencies:
1. Expire snapshots → releases references to superseded data files
2. Remove orphan files → deletes unreferenced storage (now including newly dereferenced files)
3. Compact data files → merges remaining small files into optimal sizes
4. Rewrite manifests → consolidates metadata against the new layout
5. Refresh statistics → generates Puffin files for aggressive engine-level pruning
Running this out of order inflates every cost category. You pay compute to rewrite files that expiration would have freed. You miss reclaimable storage because orphan cleanup runs before expiration dereferences anything. You burn metadata I/O rebuilding manifests that compaction is about to invalidate.
Most Spark-based maintenance setups run each operation as an independent cron job with no awareness of the others. The operations execute on their own schedules, in whatever order the DAG happens to trigger them. This is where a control plane like LakeOps becomes relevant — it sequences maintenance operations in dependency order per table, triggered by actual table health signals rather than fixed schedules, so cleanup always precedes compaction and metadata optimization always follows it.
Here's a real data scnapshot of an enterprise lake with 7k tables runing LakeOps:
Measuring your actual maintenance cost
Before optimizing, measure. Most teams cannot answer "what does Iceberg maintenance cost us?" because the spend is fragmented across cloud line items.
Storage audit
Compare logical table size (total-data-files-size-in-bytes from Iceberg metadata) to actual billable storage for each table prefix. A gap exceeding 20% typically indicates orphans, unexpired snapshot references, or incomplete compaction rewrites. Multiply the gap in TB by $23.55 (S3 Standard monthly rate per TB) for a rough dollar estimate.
-- Check snapshot count and age per table (via Spark)
SELECT count(*) as snapshot_count,
min(committed_at) as oldest_snapshot,
max(committed_at) as newest_snapshot,
datediff(current_date(), min(committed_at)) as oldest_snapshot_age_days
FROM catalog.db.my_table.snapshots;
-- Estimate orphan file waste: dry-run before deleting (via Spark)
CALL catalog.system.remove_orphan_files(
table => 'db.my_table',
dry_run => true
);
Tables with unbounded snapshot growth — streaming tables can accumulate 4,000+ snapshots per month — are your highest-priority storage cost targets.
Compute audit
Track two metrics:
- Maintenance compute as percentage of query compute. If your compaction clusters cost more than 30% of your query compute, the maintenance strategy needs a faster engine or signal-driven triggers. Alex Merced's table maintenance economics analysis puts the healthy range at 10–25% of query compute for write-heavy tables. Teams that have never measured this are usually surprised: either it is zero (no maintenance running, query costs inflated) or above 40% (naive schedule, wasted runs).
- Cost per TB compacted. This is the unit-economics metric. Spark-based compaction typically runs ~$50/TB on EMR. Purpose-built Rust engines run ~$5/TB. Compare your actual cost across engines on identical tables — synthetic benchmarks understate the JVM overhead at production scale.
Query waste audit
The most underrated measurement: how much query compute is wasted on poorly maintained tables?
-- Check file count and average size per partition (via Spark)
SELECT partition, count(*) as file_count,
avg(file_size_in_bytes) / (1024*1024) as avg_file_size_mb,
sum(file_size_in_bytes) / (1024*1024*1024) as total_size_gb
FROM catalog.db.my_table.files
GROUP BY partition
HAVING count(*) > 100 OR avg(file_size_in_bytes) < 33554432 -- < 32 MB
ORDER BY file_count DESC;
-- Check delete file overhead (via Spark)
SELECT count(*) as delete_file_count,
sum(record_count) as total_delete_records
FROM catalog.db.my_table.delete_files;
Tables where >30% of files fall below 32 MB are costing you measurably more on every query. On Athena, you can calculate the exact dollar waste: (bytes scanned on fragmented table - bytes scanned on compacted equivalent) × $5/TB × daily query count. Tables with delete-file-to-data-file ratios above 1:10 are candidates for immediate compaction.
Reducing compaction compute cost
Compaction is typically the largest controllable maintenance cost. Three strategies reduce it:
Replace the engine
The structural mismatch between Spark and compaction is the single biggest cost lever. Compaction is a narrow I/O operation. Spark is a general-purpose distributed computation framework with JVM overhead, GC pauses, and executor provisioning designed for arbitrary DAG workloads.
Purpose-built compaction engines built on Rust and Apache DataFusion eliminate that overhead structurally. LakeOps's Rust compaction engine processes the same 200 GB benchmark in 221 seconds versus 1,612 seconds for Spark — 7.3× faster, at roughly one-tenth the compute cost ($0.21 vs. $1.54). A 1.2 TB table that caused Spark to OOM completed in 11 minutes on the same hardware.
The cost gap compounds: Spark costs scale linearly with data volume at ~$50/TB. Self-improving engines that skip tables not needing work grow sublinearly — one team cut their per-table compaction cost from $8,400/year to $750.
Compact only what needs work
Fixed-schedule compaction (every 4 hours, every night) wastes compute on tables that have not degraded since the last run. Signal-driven triggers — firing when file count, average file size, or delete-file ratio crosses a threshold — skip healthy tables entirely.
A lake with 600 tables where 30 receive continuous streaming writes generates almost all maintenance work from those 30 tables. The other 570 need snapshot expiration and little else. Cron-based compaction treats all 600 equally.
Sort during compaction
Sort compaction costs more than binpack per run, but the downstream savings dwarf the upfront investment. Sorted data enables min/max pruning on every subsequent query — unsorted tables scan roughly 50% more data than sorted equivalents in production benchmarks. The ROI calculation: sort compaction cost is paid once per sort-order cycle; the scan reduction applies to every query until the next compaction.
The challenge is choosing the right sort key. The wrong choice accelerates one query pattern while degrading another. LakeOps addresses this by validating proposed sort orders against actual query patterns before committing to a full rewrite — ensuring the compute investment produces a net-positive return on query performance.
For a detailed comparison of compaction strategies and tooling options, the LakeOps blog has a practical guide on Iceberg compaction strategies.
Reducing storage waste cost
Storage cost reduction is the highest-ROI starting point because it requires no sort-order decisions or parameter tuning. You scan, identify waste, and remove it.
Snapshot retention policies
Most tables do not need 30 days of snapshots. Define retention based on actual requirements:
- Time-travel window: How far back do queries actually look? Usually 7–14 days.
- Rollback safety window: How long before you would detect a bad write? Usually 24–72 hours.
- ML reproducibility: Use named tags for specific training snapshots instead of retaining all history.
A streaming table producing 288 snapshots/day with 30-day retention holds 8,640 snapshots. Each pins references to data files. Reducing retention to 7 days and a minimum of 5 retained snapshots cuts snapshot count to ~2,000 — and releases the storage those 6,600 expired snapshots were preventing from being garbage-collected. On a table where expired snapshots pin 10 TB of superseded data, that is ~$235/month in recoverable S3 Standard storage.
-- Expire snapshots older than 7 days, keeping at least 5
CALL catalog.system.expire_snapshots(
table => 'db.my_table',
older_than => TIMESTAMP '2026-09-09 00:00:00',
retain_last => 5
);
Orphan file cleanup
Orphan files accumulate from failed writes, aborted compaction, and dropped tables. They are invisible to query engines but billable on your storage invoice. OOMed compaction jobs are the most prolific source — a run writing 500 new files that fails before the atomic commit leaves 500 orphans per failure.
-- First: dry-run to see what would be removed
CALL catalog.system.remove_orphan_files(
table => 'db.my_table',
older_than => TIMESTAMP '2026-09-09 00:00:00',
dry_run => true
);
-- Then: remove orphan files older than 7 days
CALL catalog.system.remove_orphan_files(
table => 'db.my_table',
older_than => TIMESTAMP '2026-09-09 00:00:00'
);
Always run orphan cleanup after snapshot expiration. Expiration dereferences files that then become orphans — if cleanup runs first, it misses them. The older_than threshold of 3–7 days is critical: it protects files from in-flight writes that have not yet committed.
At lake scale, the impact is significant. One cleanup pass across 324 tables removed approximately 200 TB of orphan data — ~$56,000/year in S3 Standard storage savings from a single operation.
Metadata bloat
Iceberg's metadata.json stores all historical schemas and partition specs. Tables with frequent schema evolution accumulate hundreds of schema versions — a table with thousands of columns that has undergone 200+ schema changes stores every complete schema version in full. Production reports show metadata.json files reaching 10 MB compressed (250 MB uncompressed), consuming approximately 4 GB of heap on load in engines like Trino. Each snapshot entry also adds 200–500 bytes; at 288 commits/day, that is 56–140 KB/day of snapshot metadata alone.
Mitigations: set write.metadata.delete-after-commit.enabled to true and write.metadata.previous-versions-max to a reasonable limit (e.g., 10–20). Keep snapshot expiration aggressive to reduce the snapshot list in metadata.json. For a deeper analysis, see the LakeOps blog on Iceberg metadata lifecycle maintenance.
Reducing query compute through better table health
The most impactful long-term cost reduction comes not from cheaper maintenance, but from maintaining tables well enough that every query costs less.
Three levers:
File sizing. Compact small files to 128–512 MB targets. This alone reduces per-file API call overhead by orders of magnitude. A table going from 47,000 files to 280 files saves ~46,700 S3 GET requests per query — and at $0.0004 per 1,000 GETs, that is ~$0.019 saved per query just in API calls, before accounting for the far larger CPU and scan savings.
Sort order. Physical sort by high-selectivity filter columns enables row-group skipping. Engines use min/max statistics in Parquet row group metadata to skip irrelevant file groups entirely. Sorted data also compresses better (163 GB vs. 178 GB for 1 TB TPC-H — a 9% reduction), lowering storage costs as a side effect.
Delete file cleanup. Physically apply position deletes through compaction so queries stop paying reconciliation cost at read time. Tables with delete-file-to-data-file ratios above 1:10 are candidates for immediate compaction.
The compound effect: a table that is compacted to the right file size, sorted by the right columns, with delete files cleaned up and manifests consolidated, delivers dramatically faster queries than the same table in a degraded state. On Athena at $5/TB scanned, the cost difference is direct and measurable.
From scripts to policies: cutting engineering time
The engineering time cost of Iceberg maintenance scales linearly with table count when you manage it through scripts. Every table needs a compaction DAG, an expiration schedule, an orphan cleanup job, monitoring, alerting, and on-call coverage.
The alternative is policy-based maintenance: define rules at the catalog, namespace, or table level and let the system enforce them.
A practical policy set for a mid-size estate:
-
All tables: Expire snapshots daily (7-day window, minimum 5 retained). Remove orphan files weekly (7-day
older_thanthreshold). - Streaming tables (>10 commits/hour): Compact when average file size drops below 64 MB or small-file ratio exceeds 30%. Rewrite manifests after every compaction cycle.
- Batch tables: Compact when file count exceeds 500 per partition. Rewrite manifests monthly.
- CDC tables: Compact when delete-file count exceeds threshold. Priority: position-delete rewrite before data-file compaction.
The LakeOps platform implements this as declarative policies scoped by catalog, namespace, or individual table — with a specificity hierarchy where table-level overrides take precedence. New tables inherit policies automatically. Every execution is logged with duration, impact, and status for governance and chargeback.
The engineering time savings at scale are substantial. Instead of maintaining hundreds of Airflow DAGs and Spark scripts, you define policies once and the system handles sequencing, scheduling, conflict avoidance, and adaptation. The on-call burden drops from "compaction failed on table X at 2 AM" to reviewing an event log.
What a control plane changes
The underlying problem is that Iceberg gives you maintenance primitives but not an operational system. Each procedure runs in isolation. Nothing sequences them, nothing triggers them from table health signals, nothing coordinates across tables, and nothing tells you which tables are actually costing you money.
A lakehouse control plane like LakeOps connects to your catalogs (Glue, REST/Polaris, Nessie, S3 Tables) and query engines (Trino, Spark, Snowflake, Athena, DuckDB) and targets each cost vector through specific mechanisms:
- Storage waste → lake-wide observability and automated cleanup. Every table assessed for structural health — file fragmentation, snapshot age, manifest bloat, write velocity — with the most degraded tables surfaced first. Snapshot expiration and orphan cleanup run in dependency order automatically — so the orphan cleanup that reclaimed 200 TB at one production lake does not require a manual audit to trigger.
- Compaction compute → Rust engine replacing Spark. Compaction on a purpose-built Rust/DataFusion engine at ~$5/TB instead of Spark at ~$50/TB. No JVM startup, bounded memory, no OOM failures. The engine skips tables that have not degraded since the last run, so costs grow sublinearly with table count.
- Query compute → query-aware sort optimization. Sort decisions driven by cross-engine query telemetry — which columns your Trino, Spark, and Athena queries actually filter on — not guesswork at table-creation time. Layout changes are tested on Iceberg branches before touching production.
- Engineering time → policy-driven automation replacing DAGs. Maintenance rules defined at the namespace level replace per-table Airflow configurations. New tables are covered from the moment they appear in the catalog. Every execution is logged with duration, impact, and status.
- Cross-engine savings → multi-engine routing. Each query routed to the cheapest engine meeting its latency target. A nightly batch pipeline scanning 5 TB drops from $25/run on Athena to $3–5/run on Trino — without changing application code.
The LakeOps platform integrates with your existing infrastructure through standard catalog and engine APIs — setup is measured in minutes, not sprints.
For a comprehensive breakdown of all four cost vectors with production numbers, the Apache Iceberg cost optimization guide covers each in detail. For a broader view of the tooling landscape — cloud-native optimizers, engine-integrated maintenance, and dedicated control planes — the State of Iceberg FinOps survey compares approaches across the ecosystem.
The highest-ROI sequence
For most production lakes, the highest-ROI sequence is:
-
Audit storage waste (week 1). Compare logical table sizes to billable storage. Run
remove_orphan_fileswithdry_run => trueon your top 20 tables by object count. Multiply the gap in TB by $23.55 for your monthly waste estimate. - Run snapshot expiration and orphan cleanup (week 1–2). These release storage at near-zero compute cost. No sort-order decisions required. One pass can reclaim tens of thousands of dollars in annual storage spend.
- Benchmark compaction cost per TB (week 2–3). Compare your current engine against alternatives on identical tables. The engine choice is a first-order cost decision — $50/TB vs. $5/TB is a 10× difference that compounds across every table and every run.
- Compact and sort your highest-traffic tables (week 3–4). Start with the tables that generate the most latency complaints — they are typically your highest-cost tables from a compute perspective.
- Replace cron with health-driven policies (month 2). Move from fixed schedules to triggers based on file count, average file size, and delete-file ratio. This is where engineering time savings materialize.
The total reduction from addressing all four cost categories as a coordinated system — not as isolated optimizations — is where production teams consistently report the largest improvements. Storage waste disappears. Query compute drops because every engine scans less. Maintenance compute drops because the engine is cheaper and skips healthy tables. Engineering time drops because policies replace scripts.
Iceberg's table format is production-ready. The maintenance layer should be too — and it should cost a fraction of what cron-based Spark jobs spend to keep your lake healthy. LakeOps is one way to get there.
Thanks for reading :)









Top comments (0)