Back to blog

Why Your Iceberg Queries Are Slow (And How to Fix Them)

Slow Iceberg queries almost always trace back to five structural problems: small files, wrong sort order, manifest bloat, stale snapshots, or partition misalignment. This diagnostic guide shows you how to find each one, confirm it with SQL, and fix it — manually or with autonomous optimization.

Rob M

Rob M

17 min read

Your dashboards are lagging. Analysts are complaining. Queries that used to finish in seconds now take minutes. You have not added significantly more data, your compute cluster looks healthy, and nothing in the application layer changed. The problem is not your query engine — it is the physical structure of your Iceberg tables.

Slow Iceberg queries almost always trace back to five structural problems hiding in your table's metadata and file layout: small files, wrong sort order, manifest bloat, stale snapshots, and partition misalignment. Left unchecked, they compound — small files breed manifest bloat, stale snapshots pin dead data, and misaligned partitions defeat file skipping. A table suffering from two or three of these issues simultaneously can be 10–50x slower than the same data stored correctly.

This guide walks through each root cause with diagnostic SQL for Spark and Trino, then presents two fix strategies. Path 1 uses LakeOps — an autonomous Iceberg optimization platform that detects and resolves these problems continuously by analyzing query patterns, compacting files, rewriting manifests, and adapting sort orders without manual intervention. Path 2 covers the manual approach: SQL procedures, table properties, and scheduled maintenance you build yourself.

LakeOps dashboard showing lake-wide health KPIs, storage, and optimization activity
LakeOps dashboard: lake-wide health KPIs, storage, and CPU — the first place to look when queries slow down.

How Iceberg query planning works (and where it breaks)

Before diagnosing specific problems, it helps to understand the performance-critical path. When a query engine — Spark, Trino, Flink, or any Iceberg-compatible engine — receives a query against an Iceberg table, it does not immediately scan data files. Instead, it walks a metadata tree.

First, the engine reads the current metadata.json to locate the active snapshot. From the snapshot, it reads the manifest list — an index of all manifest files in the table. Each manifest list entry contains partition value ranges, allowing the planner to skip entire manifests whose partitions cannot match the query predicate. This is Level 1 pruning.

For the surviving manifests, the engine reads per-file metadata: file path, partition values, column-level min/max statistics, null counts, and row counts. It evaluates query predicates against these statistics to skip files that cannot contain matching rows. This is Level 2 pruning — and it is where sort order determines everything.

The planning phase finishes when the engine has a list of data files to scan. Only then does it issue GET requests to object storage to read actual data. Every structural problem in this guide degrades either the planning phase (too many manifests, too many files to evaluate) or the pruning efficiency (wrong sort order, overlapping min/max ranges, too-broad partitions).

Operations monitoring showing table coverage, readiness, and maintenance timeline
Operations monitoring in LakeOps: which tables are maintained, which are degrading, and the execution history of optimization operations.

Root cause 1: Small file explosion

Symptoms: Query planning takes 5–30 seconds before any data is scanned. High S3 GET request counts. Cloud storage bills climbing without proportional data growth.

Why it happens: Streaming jobs with short checkpoint intervals, high-cardinality partitioning, frequent MERGE INTO operations, and concurrent writers all produce undersized files. A Flink job checkpointing every 60 seconds across 50 partitions creates 72,000 files per day — most under 5 MB. The query planner must evaluate every one of them, regardless of how selective the query is.

Why it causes slowness: Each data file gets a manifest entry. More files mean more manifest entries, more manifests, and more S3 GETs during planning. A table with 200,000 small files can take 15–30 seconds just to plan, compared to under a second with 2,000 properly sized files. The small files problem also defeats column statistics — more files with fewer rows produce wider, overlapping min/max ranges that reduce pruning effectiveness.

Diagnose it

sql
1-- Spark: file size distribution2SELECT3  COUNT(*) AS total_files,4  ROUND(AVG(file_size_in_bytes) / 1048576, 1) AS avg_file_mb,5  ROUND(PERCENTILE(file_size_in_bytes, 0.5) / 1048576, 1) AS median_file_mb,6  ROUND(MIN(file_size_in_bytes) / 1048576, 1) AS min_file_mb,7  SUM(CASE WHEN file_size_in_bytes < 33554432 THEN 1 ELSE 0 END) AS files_under_32mb8FROM catalog.db.my_table.files;
sql
1-- Trino: file size distribution2SELECT3  COUNT(*) AS total_files,4  ROUND(AVG(file_size_in_bytes) / 1048576.0, 1) AS avg_file_mb,5  ROUND(MIN(file_size_in_bytes) / 1048576.0, 1) AS min_file_mb,6  COUNT_IF(file_size_in_bytes < 33554432) AS files_under_32mb7FROM "catalog"."db"."my_table$files";

If avg_file_mb is below 64 or files_under_32mb is more than 30% of total files, you have a small file problem.

Partition distribution showing file counts and sizes per partition
Partition-level file distribution in LakeOps — immediately shows which partitions have small-file concentration.

Root cause 2: Wrong or missing sort order

Symptoms: Queries with selective WHERE clauses still scan most of the table. High data scan volumes relative to result set size. File skipping metrics show low pruning rates.

Why it happens: Tables are created without a sort order, or with a sort order that does not match how the table is actually queried. A table sorted by created_date is useless for queries filtering on customer_id. Without effective sort order, every file's min/max range for the filtered column spans the entire value domain — no files can be pruned at Level 2.

Why it causes slowness: Sort order is the single most impactful lever for Iceberg query performance. Sorted tables scan up to 51% less data per query than unsorted ones. With query-aware sort orders aligned to production filter patterns, file skipping can eliminate 80–95% of files during planning, producing up to 12x faster queries.

Diagnose it

Check the current sort order:

sql
1-- Spark: check table sort order2DESCRIBE EXTENDED catalog.db.my_table;3-- Look for 'Sort Columns' in the output

Check whether min/max ranges actually enable pruning. Wide, overlapping ranges indicate poor or missing sort order:

sql
1-- Spark: check column value ranges across files2SELECT3  COUNT(*) AS total_files,4  MIN(lower_bounds['customer_id']) AS global_min,5  MAX(upper_bounds['customer_id']) AS global_max,6  COUNT(DISTINCT lower_bounds['customer_id']) AS distinct_lower_bounds7FROM catalog.db.my_table.files;

If distinct_lower_bounds is close to 1 (all files have the same lower bound), your data is unsorted on that column and pruning cannot work.

Layout simulations showing query-aware sort strategies and expected improvements
LakeOps layout simulations: preview how different sort strategies affect file skipping before rewriting any data.

Root cause 3: Manifest bloat

Symptoms: Query planning is slow even on small result sets. The planning phase dominates total query time. Cold-start queries are disproportionately slow compared to repeated queries.

Why it happens: Every commit — every streaming checkpoint, batch append, compaction, or MERGE operation — creates at least one new manifest file. A streaming table with 10-minute checkpoints accumulates over 4,300 manifests per month. Each manifest may only track a handful of files, creating a fragmented metadata layer.

Why it causes slowness: During query planning, the engine reads every manifest referenced by the current snapshot. Hundreds of small manifests mean hundreds of S3 GETs just to build the file list. Manifest caching helps on repeated queries, but cold-start planning — the first query after a cluster restart, or against a table you have not queried recently — pays the full I/O cost. Tables with 500+ manifests routinely see planning times of 5–15 seconds.

Diagnose it

sql
1-- Spark: manifest count and fragmentation2SELECT3  COUNT(*) AS manifest_count,4  ROUND(AVG(added_data_files_count), 1) AS avg_files_per_manifest,5  SUM(CASE WHEN added_data_files_count <= 2 THEN 1 ELSE 0 END) AS tiny_manifests,6  ROUND(AVG(length) / 1024, 1) AS avg_manifest_size_kb7FROM catalog.db.my_table.manifests;
sql
1-- Trino: manifest count2SELECT3  COUNT(*) AS manifest_count,4  ROUND(AVG(added_data_files_count), 1) AS avg_files_per_manifest5FROM "catalog"."db"."my_table$manifests";

If manifest_count exceeds 100 or avg_files_per_manifest is below 10, manifest rewriting will improve planning time. If more than 50% of manifests are tiny_manifests (tracking 1–2 files), the improvement will be dramatic.

Insights showing table health issues classified by severity
LakeOps insights: file fragmentation, manifest bloat, and scan amplification surfaced by severity — root causes of slow queries identified automatically.

Root cause 4: Stale snapshots pinning dead data

Symptoms: Storage volume grows faster than ingestion rate. Old data files that should have been deleted are still on disk. S3 LIST operations are slow. Metadata files keep growing.

Why it happens: Every write, update, and delete in Iceberg creates a new snapshot. Snapshots reference manifest lists, which reference manifests, which reference data files. Until a snapshot is explicitly expired, every data file it references is retained — even files that have been logically replaced by compaction or rewritten by MERGE operations. Without running expire_snapshots, the table accumulates an unbounded history of dead data files and metadata.

Why it causes slowness: Stale snapshots do not directly slow query execution (queries only read the current snapshot), but they create indirect pressure. Dead data files inflate S3 LIST response sizes, slowing maintenance operations. The growing metadata.json file takes longer to parse. In extreme cases, the volume of dead files causes S3 request throttling (503 SlowDown errors), which affects reads on the same prefix. Metadata that should fit in coordinator memory spills to disk, slowing planning.

Diagnose it

sql
1-- Spark: snapshot accumulation2SELECT3  COUNT(*) AS total_snapshots,4  MIN(committed_at) AS oldest_snapshot,5  MAX(committed_at) AS newest_snapshot,6  DATEDIFF(MAX(committed_at), MIN(committed_at)) AS snapshot_span_days7FROM catalog.db.my_table.snapshots;
sql
1-- Trino: snapshot accumulation2SELECT3  COUNT(*) AS total_snapshots,4  MIN(committed_at) AS oldest_snapshot,5  MAX(committed_at) AS newest_snapshot6FROM "catalog"."db"."my_table$snapshots";

If total_snapshots exceeds 1,000 or snapshot_span_days exceeds 30, you are retaining too much history. For most production tables, 5–7 days of snapshot retention is sufficient for time-travel needs.

Table metrics showing record distribution and file activity over time
Table metrics in LakeOps: record distribution and file activity over time — correlate query slowdowns with snapshot and metadata growth.

Root cause 5: Partition misalignment

Symptoms: Queries scan far more partitions than expected. Partition pruning is ineffective. Some partitions have millions of rows while others have dozens. Adding new filter predicates does not reduce scan time.

Why it happens: The table was partitioned by the wrong column, at the wrong granularity, or the data distribution has changed since the table was created. Common mistakes: partitioning by day on a table mostly queried by region, using hourly partitions on a low-volume table (producing thousands of tiny partitions), or using identity partitioning on a high-cardinality column. Iceberg's partition evolution solves this without rewriting data, but many teams do not know it exists or are unsure how to apply it.

Why it causes slowness: Partition pruning is Level 1 filtering — the coarsest and cheapest form of data skipping. If queries filter on columns that are not part of the partition spec, the engine must read all manifests and evaluate all files. If partitions are too granular, each partition contains too few rows and too many small files. If partitions are too broad, each partition contains too much irrelevant data that gets scanned.

Diagnose it

sql
1-- Spark: partition distribution2SELECT3  partition,4  COUNT(*) AS file_count,5  SUM(record_count) AS total_records,6  ROUND(SUM(file_size_in_bytes) / 1073741824, 2) AS partition_gb,7  ROUND(AVG(file_size_in_bytes) / 1048576, 1) AS avg_file_mb8FROM catalog.db.my_table.files9GROUP BY partition10ORDER BY file_count DESC11LIMIT 20;

Look for partitions with very high file counts (thousands of files per partition) and partitions with tiny partition_gb values (under 100 MB). Both indicate misalignment. Also check if the partition column matches your most common query predicates — if you always filter by region but the table is partitioned by event_date, partition pruning helps only date-range queries.

Table health list showing status classification for every table
Table health overview in LakeOps: every table classified by status — Critical tables with partition skew and structural issues are surfaced immediately.

The full diagnostic checklist

Run these queries to build a complete health profile of any slow table:

sql
1-- Spark: comprehensive table health check2 3-- 1. File statistics4SELECT5  COUNT(*) AS total_files,6  ROUND(AVG(file_size_in_bytes) / 1048576, 1) AS avg_file_mb,7  SUM(CASE WHEN file_size_in_bytes < 33554432 THEN 1 ELSE 0 END) AS small_files,8  ROUND(SUM(file_size_in_bytes) / 1073741824, 2) AS total_gb9FROM catalog.db.my_table.files;10 11-- 2. Manifest health12SELECT13  COUNT(*) AS manifest_count,14  ROUND(AVG(added_data_files_count), 1) AS avg_files_per_manifest15FROM catalog.db.my_table.manifests;16 17-- 3. Snapshot accumulation18SELECT19  COUNT(*) AS total_snapshots,20  MIN(committed_at) AS oldest,21  MAX(committed_at) AS newest22FROM catalog.db.my_table.snapshots;23 24-- 4. Partition skew25SELECT26  partition,27  COUNT(*) AS file_count,28  ROUND(AVG(file_size_in_bytes) / 1048576, 1) AS avg_file_mb29FROM catalog.db.my_table.files30GROUP BY partition31ORDER BY file_count DESC32LIMIT 10;

This gives you a health scorecard: file sizes tell you about compaction need, manifest counts tell you about planning overhead, snapshot counts tell you about metadata bloat, and partition distribution tells you about layout alignment. For a deeper look at how these metrics interact across the optimization stack, see the Iceberg lakehouse performance guide.

Once you have diagnosed the problems, you have two paths forward.

Events audit trail showing maintenance operations and results
Events audit trail in LakeOps: every optimization operation logged with timing, scope, and outcome — full visibility into what was fixed and when.

Path 1: Intelligent continuous optimization with LakeOps

LakeOps is an autonomous control plane for Apache Iceberg that treats all five root causes as facets of a single problem: table health. Instead of building separate cron jobs for compaction, manifest rewriting, snapshot expiry, and sort order management, LakeOps monitors every table continuously and applies the right optimization at the right time based on actual health signals.

Query-aware sort order. LakeOps collects query telemetry from every engine hitting your tables — Spark, Trino, Flink, Athena, Snowflake, DuckDB — and computes the optimal sort order for each table based on real filter patterns. The layout simulation feature lets you preview how different sort strategies affect file skipping before rewriting any data. Production results show up to 12x faster queries from sort order optimization alone.

Rust-based compaction engine. Rather than running Spark jobs to compact files, LakeOps uses a purpose-built Rust engine that merges small files into 256–512 MB targets with 95% less compute than Spark-based compaction. The engine applies the query-aware sort order during compaction, so files are consolidated and optimally sorted in a single pass.

Adaptive Maintenance. Instead of fixed cron schedules, LakeOps triggers maintenance based on table health signals: file size distribution, manifest count, snapshot age, and partition skew. Tables that are healthy get no unnecessary rewrites. Tables that are degrading get immediate attention. This prevents slow queries before they happen rather than reacting after users complain.

Adaptive maintenance configuration for compaction, expiry, and manifest rewrite
Adaptive Maintenance in LakeOps: compaction, snapshot expiry, manifest rewrite, and orphan cleanup — each triggered by health signals, not fixed schedules.

Table health monitoring. Every table is continuously classified as Healthy, Warning, or Critical based on its structural metrics. Warning and Critical tables are surfaced proactively, before they impact end users. You see at a glance which tables need attention and which are well-maintained — no diagnostic SQL required.

Manifest rewriting and snapshot lifecycle. LakeOps automatically consolidates fragmented manifests as part of the maintenance loop, grouping files by partition to maximize Level 1 pruning. Snapshot expiry, orphan file cleanup, and metadata compaction run on configurable retention policies, keeping the metadata layer lean without manual scheduling.

The net effect: every root cause in this guide is detected and resolved continuously, across every table in the lake, with optimization that adapts as data and query patterns change.

Path 2: The manual approach

If you prefer to manage optimization yourself, here is a production-ready maintenance script that addresses all five root causes. Schedule it with Airflow, Dagster, or any orchestrator:

sql
1-- Step 1: Compact and sort small files (run daily for streaming tables)2CALL catalog.system.rewrite_data_files(3  table => 'db.my_table',4  strategy => 'sort',5  sort_order => 'customer_id ASC NULLS LAST, event_date ASC NULLS LAST',6  options => map(7    'target-file-size-bytes', '536870912',8    'min-file-size-bytes', '67108864',9    'min-input-files', '5',10    'partial-progress.enabled', 'true',11    'partial-progress.max-commits', '10'12  )13);14 15-- Step 2: Consolidate manifests (run weekly)16CALL catalog.system.rewrite_manifests(17  table => 'db.my_table'18);19 20-- Step 3: Expire old snapshots (run daily)21CALL catalog.system.expire_snapshots(22  table => 'db.my_table',23  older_than => TIMESTAMP '2026-07-26 00:00:00',24  retain_last => 5,25  stream_results => true26);27 28-- Step 4: Clean orphan files (run weekly)29CALL catalog.system.remove_orphan_files(30  table => 'db.my_table',31  older_than => TIMESTAMP '2026-07-29 00:00:00'32);

For Trino, file compaction uses the optimize procedure:

sql
1-- Trino: file compaction per partition2ALTER TABLE "catalog"."db"."my_table" EXECUTE optimize3  WHERE partition_col = 'value';

Trino does not expose rewrite_manifests or expire_snapshots natively — those operations require Spark. If your primary engine is Trino, you will need a Spark sidecar for metadata maintenance.

Set table properties to encode retention and file size policies directly, so any engine or scheduler can respect them:

sql
1-- Set retention and file size policies as table properties2ALTER TABLE catalog.db.my_table SET TBLPROPERTIES (3  'history.expire.max-snapshot-age-ms' = '604800000',4  'history.expire.min-snapshots-to-keep' = '5',5  'write.target-file-size-bytes' = '268435456'6);

For partition evolution, Iceberg supports non-destructive changes to the partition spec:

sql
1-- Spark: evolve partition scheme2ALTER TABLE catalog.db.my_table3ADD PARTITION FIELD bucket(16, customer_id);4 5-- Replace overly granular partitioning6ALTER TABLE catalog.db.my_table7REPLACE PARTITION FIELD hour(event_ts)8WITH day(event_ts) AS event_day;

Partition evolution is a metadata-only operation — existing data retains its original layout. New writes follow the new partition spec. To apply the new layout to existing data, run a sort compaction after evolving the partition.

The manual path works for a handful of tables. The operational challenges emerge at scale: which tables actually need compaction today? What sort order should each table use? Are manifests accumulating faster than the weekly rewrite clears them? Has the partition scheme drifted from current query patterns? These are the questions that turn a simple cron job into a multi-table, cross-engine optimization system.

Measuring the impact

After applying fixes — either path — validate the improvement with the same diagnostic queries from above. Track these metrics over time:

  • Average file size: Should be 256–512 MB after compaction. Monitor for regression after streaming commits.
  • Manifest count: Should drop 5–10x after rewriting. Track weekly growth rate.
  • Planning time: Measure wall-clock time from query submission to first row returned. Expect 2–5x improvement from compaction and manifest rewriting combined.
  • Data scanned: Compare bytes scanned per query before and after sort order optimization. Expect 50–90% reduction for queries aligned with the sort order.
  • Snapshot count: Should stabilize at your retention target (5–10 snapshots for most tables).
Watch demoYouTube ↗
LakeOps walkthrough — diagnosing table health issues and running autonomous optimization.
Production benchmark results showing performance improvements
Production results: 95% faster compaction, 12x faster queries, 80% cost reduction.
Compaction benchmark charts comparing speed, cost, and query improvements
Compaction benchmarks: side-by-side comparison of speed, cost, and query performance gains.

Conclusion

Slow Iceberg queries are not a mystery. They are a structural problem with five specific root causes, each diagnosable with SQL and fixable with standard Iceberg procedures. The challenge is not knowing what to do — it is doing it continuously, across every table, as data patterns and query patterns evolve.

The diagnostic queries in this guide give you immediate visibility into which tables are degraded and why. The manual procedures give you the tools to fix them. For teams managing more than a handful of tables — or anyone who wants optimization that adapts to real query patterns rather than relying on static configurations set months ago — LakeOps provides autonomous, query-aware optimization that prevents these problems from occurring in the first place.

Compaction, sort order, manifest rewriting, snapshot lifecycle, and partition alignment are not five separate problems — they are five facets of table health. Treat them together, measure them continuously, and your Iceberg queries will be as fast as the format was designed to deliver.

Further reading

Related articles

Found this useful? Share it with your team.