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.
- What actually degrades
- Object-storage paths
- Target file size
- The three jobs
- Metadata retention, which is separate
- The catalog decision
- Properties worth setting at create time
- Automating it
- Common misconceptions
- A model worth keeping
- References
- Trademarks
TL;DR
write.object-storage.enabledchanges data paths to carry a hashed prefix — the measured table wrote to0111/0110/0011/10101101/.... That distributes keys across object-store partitions instead of concentrating them under one date prefix.write.target-file-size-bytesis 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
- Iceberg configuration for every write property and its default
- Iceberg Spark procedures for the maintenance calls above
- Iceberg AWS integration for object-storage paths and S3 behaviour
- Table maintenance for Iceberg and Hudi for the compaction-versus-expiry accounting
- CDC into Iceberg for the delete-file growth that drives most of this schedule
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.