All posts

Running Iceberg in production: file sizes, object-storage paths, and the jobs you must schedule

The settings that decide whether an Iceberg table stays fast are set at create time and enforced by jobs nobody schedules. Here is what target file size actually does, what object-storage paths look like when enabled, and the maintenance that turns a working table into one that keeps working.

8 min read Iceberg

TL;DR

  • write.object-storage.enabled changes data paths to carry a hashed prefix — the measured table wrote to 0111/0110/0011/10101101/.... That distributes keys across object-store partitions instead of concentrating them under one date prefix.
  • write.target-file-size-bytes is a target the writer aims at, not a guarantee. A 1 MiB target produced files of 124,966 and 496,738 bytes from one write.
  • Three jobs must be scheduled from day one: compaction, delete-file compaction, and snapshot expiry. Each fixes something the others do not.
  • The catalog is the production decision that is hardest to reverse. A path-based catalog cannot commit safely on plain object storage.
  • Metadata files accumulate independently of snapshots, and are bounded by their own settings.

What actually degrades

An Iceberg table rarely fails. It degrades, in four ways that have different causes and different fixes, and telling them apart is most of production operation.

Symptom Cause Fix
Scans slow, data volume flat too many small data files rewrite_data_files
Scans slow on a CDC table delete files accumulating rewrite_position_delete_files
Planning slow before any task starts manifests and metadata accumulating rewrite_manifests, metadata retention
Storage grows, row count flat snapshots never expired expire_snapshots
Throttling or 503s from object storage key prefixes concentrated write.object-storage.enabled

Notice that “the query got slow” maps to three different causes. The way to tell them apart is where the time goes: planning time before tasks are scheduled points at metadata; task time points at data files or delete files.

Object-storage paths

The default layout writes data under a directory derived from partition values:

data/pickup_day=2026-02-10/00000-2-....parquet

That is readable, and on S3 it is a problem at scale. Object stores partition their keyspace by prefix, so a table whose keys all begin data/pickup_day=2026-02-10/ concentrates a day’s requests onto one internal partition. The symptom is throttling — 503 responses under load — on a table that looks perfectly healthy.

Setting write.object-storage.enabled changes the path:

CREATE TABLE local.db.prod (id BIGINT, v STRING) USING iceberg
TBLPROPERTIES ('write.object-storage.enabled'='true')

The measured result:

0111/0110/0011/10101101/00000-66-7fc327bf-3edb-40a3-b09f-686852b...
1011/1010/0010/00000001/00000-66-7fc327bf-3edb-40a3-b09f-686852b...

Those leading binary components are a hash of the file path, inserted before the rest of the location. Two files written in the same operation landed under completely different prefixes, which is the point: requests spread across the keyspace instead of queuing behind one.

The cost is that the layout is no longer human-navigable. You cannot list a partition’s directory to see its files, because the files are not in one. The metadata knows where everything is, which is the only consumer that matters, but it does change how you debug.

Turn this on for tables on S3 or GCS that take real query concurrency. Leave it off for small tables, local filesystems, and anywhere a human browsing the layout is worth more than the throughput.

Target file size

TBLPROPERTIES ('write.target-file-size-bytes'='1048576')

Setting a 1 MiB target and writing 400,000 rows produced:

files written = 2
   124966 bytes    81000 rows
   496738 bytes   319000 rows

Neither file hit the target, and they differ from each other by a factor of four. This is worth internalising: the property is an input to the writer’s decisions, not a contract. Actual sizes depend on how the data was partitioned coming in, how well it compressed, and where the writer chose to split.

The practical reading: target file size shapes compaction more reliably than it shapes ingestion. rewrite_data_files bin-packs toward the target and gets close, because it is reading existing files and has freedom to choose boundaries. An ingesting writer is bounded by the partitions it was handed.

For production the usual target is 128 MiB to 512 MiB — large enough that per-file overhead is amortised, small enough for useful parallelism. The default is 512 MiB, and the reason to lower it is a table that is read with very selective filters, where smaller files prune better.

The three jobs

These are not optional and they are not the same job.

Compaction merges small data files:

CALL local.system.rewrite_data_files(
  table => 'db.events',
  options => map('min-input-files','5','target-file-size-bytes','536870912'));

Delete-file compaction applies and clears delete files, which data compaction does not fully do:

CALL local.system.rewrite_position_delete_files(table => 'db.events');

Expiry is the only one that reclaims storage:

CALL local.system.expire_snapshots(
  table => 'db.events',
  older_than => TIMESTAMP '2026-09-17 00:00:00',
  retain_last => 10);

Compaction without expiry grows storage while improving query time, because the pre-compaction files stay referenced by older snapshots. That accounting, measured file by file, is in table maintenance for Iceberg and Hudi.

A fourth, run when planning is slow rather than on a schedule:

CALL local.system.rewrite_manifests(table => 'db.events');

Retention is a recovery decision, not a cleanup decision. older_than sets how far back you can time travel and therefore how far back you can undo a bad write. Seven days of snapshots is seven days of insurance and seven days of storage for every file those snapshots reference. Decide it as a recovery policy and let the storage follow.

Metadata retention, which is separate

Every commit writes a new metadata.json. Expiring snapshots does not remove them; they have their own settings:

ALTER TABLE local.db.events SET TBLPROPERTIES (
  'write.metadata.delete-after-commit.enabled'='true',
  'write.metadata.previous-versions-max'='100');

Without these, a table committing every minute accumulates metadata files indefinitely — the directory grows even though snapshots are being expired on schedule. This is the specific fix for a metadata directory with tens of thousands of JSON files, and it is easy to miss because it looks like the snapshot problem and is not.

The catalog decision

The hardest thing to change later.

A catalog’s job is to answer which metadata file is current and to make the swap atomically. That atomicity is what stops two writers from losing each other’s commits.

Catalog Suitable for
Hadoop / path-based local experiments, single-writer, filesystems with atomic rename
Hive Metastore existing Hive estates, single-region
JDBC simple deployments with a database already in place
REST the standard interface; decouples engines from the metadata backend

A path-based catalog on plain object storage is the configuration to avoid. It depends on atomic rename semantics that object stores do not offer, which turns concurrent commits into lost commits — and it fails silently, not loudly.

The REST catalog is the direction the ecosystem has settled on, because it puts the commit protocol behind an API that any engine can speak, rather than each engine reimplementing filesystem conventions. A local walkthrough is in a local Iceberg playground, and a production-shaped open-source implementation in running Iceberg on Apache Polaris.

Properties worth setting at create time

Property Typical Why
format-version 2 delete files; the default in current versions
write.target-file-size-bytes 128–512 MiB amortises per-file overhead
write.object-storage.enabled true on S3/GCS spreads keys, avoids throttling
write.parquet.compression-codec zstd the default; better ratio than snappy
write.delete.mode / update / merge per workload see the COW/MOR comparison
write.metadata.delete-after-commit.enabled true bounds metadata files
write.metadata.previous-versions-max 100 how many to keep

Set these when the table is created. Several can be changed later, but the ones that affect layout only apply to data written afterwards, so a table that ran for six months with the wrong settings needs a rewrite, not an ALTER.

Automating it

Maintenance that depends on someone remembering is maintenance that stops. The shape that works:

hourly   rewrite_data_files on hot tables
         rewrite_position_delete_files on CDC tables
daily    expire_snapshots  (retention = your recovery window)
weekly   rewrite_manifests where planning is slow
monthly  remove_orphan_files with a conservative age threshold

Two operational notes. remove_orphan_files deletes files no snapshot references, and a file being written right now looks exactly like an orphan — use an age threshold of days, and never run it while a long write is in flight. And maintenance jobs are writers: they commit, they can conflict with your pipelines, and they need their own compute rather than competing with the ingestion they are meant to help.

Common misconceptions

“Target file size guarantees file size.” The measured write produced 124,966 and 496,738 bytes against a 1 MiB target.

“Compaction is maintenance.” It is one of three jobs, and the only one that reclaims storage is expiry.

“Object storage mode is just a layout preference.” It is the fix for prefix throttling, and it changes the failure mode under concurrency.

“Expiring snapshots is cleanup.” It is deleting your recovery window. Choose the retention as a recovery policy.

“The catalog can be swapped later.” It can, with a migration. It is much cheaper to choose correctly at the start.

A model worth keeping

An Iceberg table has two clocks. One counts data files and delete files, and compaction resets it. The other counts snapshots, manifests and metadata files, and expiry and manifest rewriting reset it.

Production operation is keeping both wound, choosing a catalog that can commit safely, and setting the layout properties before the first write rather than after the first incident.

References

Trademarks

Apache Iceberg, Apache Spark, Apache Hive, Apache Parquet, Apache Polaris, Apache and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation in the United States and other countries.

Found this useful?

These posts and tools are free. If one saved you an afternoon, you can buy me a coffee.

Buy me a coffee