Lakehouse Iceberg & Delta skill for Codex
Right-sized files, bounded snapshots, one catalog. Codex inspects table metadata before touching an Iceberg or Delta table, then fixes layout with hidden partitioning or liquid clustering, chooses merge-on-read or copy-on-write by workload, and schedules compaction, snapshot expiry and orphan cleanup with retention confirmed by you. It reads tables through the catalog from Spark, Trino, PyIceberg or DuckDB and proves gains with before and after file counts.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- lakehouse-iceberg Installs to ~/.agents/skills/lakehouse-iceberg/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install lakehouse-iceberg The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project lakehouse-iceberg Installs to .agents/skills/lakehouse-iceberg/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/lakehouse-iceberg.zipunzip -o lakehouse-iceberg.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) lakehouse-iceberg PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $lakehouse-iceberg, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds and maintains lakehouse tables on object storage with Apache Iceberg and Delta Lake over Parquet - catalog choice (REST, Glue, Unity, Nessie), hidden partitioning and partition evolution, merge-on-read vs copy-on-write, compaction, snapshot expiry, orphan cleanup and time travel from Spark, Trino, PyIceberg or DuckDB. Use when creating Iceberg or Delta tables, queries slow down from small files, storage costs grow from old snapshots, migrating Hive or raw Parquet folders to a table format, or choosing between Iceberg and Delta.
Use it when
- Creating or redesigning Iceberg or Delta tables on S3, GCS or ADLS
- Queries slowed down because of small files or too many snapshots
- CDC upserts into a lake table need MERGE and a maintenance plan
- Converting raw Parquet folders or Hive tables to a table format
Use something else when
- You need to write or tune the Spark job itself: use pyspark-jobs
- The data fits on one machine and needs ad hoc SQL over files: use duckdb-analytics
How to check the result
Compare file count, average file size and snapshot count from the table's metadata before and after, and confirm row counts and checksums per partition after any migration.
The mistake it prevents
Running remove_orphan_files or VACUUM with a short retention, which deletes files of in-flight commits and breaks time travel promised to users.
A healthy lakehouse table has files near the target size (128 MB-1 GB), a partition layout that matches the main filters, a bounded number of snapshots, no orphaned files, and one catalog that every engine agrees on. Maintenance is scheduled, not remembered.
Inspect first
- Read
AGENTS.mdand find the engine and catalog config: Sparkspark-defaults.confor session builder, Trinoetc/catalog/*.properties,.pyiceberg.yaml, dbt profiles. - Identify the catalog (REST/Polaris, AWS Glue, Hive Metastore, Nessie, Unity Catalog)
and the table format version (Iceberg
format-version2 is the safe default; Delta protocol reader/writer versions decide which engines can read it). - Read the table’s metadata before changing anything:
-- Iceberg metadata tables (Spark or Trino; Trino quotes as "events$files")
select count(*), avg(file_size_in_bytes) / 1048576 as avg_mb from db.events.files;
select committed_at, operation, summary from db.events.snapshots order by committed_at desc limit 20;
select partition, record_count, file_count from db.events.partitions order by file_count desc limit 20;
-- Delta
describe detail events; -- numFiles, sizeInBytes, partitionColumns
describe history events limit 20; -- operations, versions, who wrote
Most of these commands need network access to object storage and catalog credentials. In a Codex sandbox with network off, write the SQL and maintenance jobs and ask the user to run them, or request approval for network access.
Iceberg or Delta
| Situation | Choose |
|---|---|
| Several engines (Spark, Trino, Flink, Snowflake, DuckDB) read and write the same tables | Iceberg with a REST catalog |
| Platform is Databricks end to end | Delta (Unity Catalog), optionally exposing Iceberg reads via UniForm |
| Existing Glue-based AWS lake with Athena | Iceberg on Glue |
| Need branch/tag style data versioning across tables | Iceberg with Nessie, or Iceberg branches and tags |
| Small single-node analytics on files | Plain Parquet plus DuckDB may be enough; adopt a table format when you need updates, deletes or concurrent writers |
Do not switch formats for a single feature; both now cover upserts, schema evolution, time travel and compaction.
Table design
-- Spark SQL, Iceberg
create table lake.analytics.events (
event_id string,
user_id bigint,
event_type string,
occurred_at timestamp,
payload string
)
using iceberg
partitioned by (days(occurred_at), bucket(16, user_id))
tblproperties (
'format-version' = '2',
'write.target-file-size-bytes' = '268435456',
'write.delete.mode' = 'merge-on-read',
'write.update.mode' = 'merge-on-read',
'write.merge.mode' = 'merge-on-read'
);
alter table lake.analytics.events write ordered by event_type, occurred_at;
Partitioning rules:
- Partition by what queries filter on, usually event date. Aim for partitions of at least a few hundred MB; daily partitions holding 5 MB each are over-partitioned, use months.
- Iceberg transforms (
days,months,hours,bucket,truncate) are hidden: users filter onoccurred_atand pruning still works. Never add a derivedevent_datecolumn just for partitioning on Iceberg. - Change layout with partition evolution, which only affects new data:
alter table ... replace partition field days(occurred_at) with hours(occurred_at). - Delta: prefer liquid clustering (
cluster by (col)) on recent runtimes overpartitioned byfor high-cardinality or shifting access patterns. Partition Delta only on low-cardinality columns with more than about 1 GB per partition.
Copy-on-write vs merge-on-read
| Workload | Mode |
|---|---|
| Append-mostly, rare large rewrites | copy-on-write (default): fastest reads |
| Frequent small updates, deletes, CDC upserts | merge-on-read: cheap writes, needs regular compaction to keep reads fast |
Writing
merge into lake.analytics.users t
using staged_users s
on t.user_id = s.user_id
when matched and s.op = 'D' then delete
when matched then update set *
when not matched and s.op <> 'D' then insert *;
- Deduplicate the source to one row per key before
MERGE; multiple source matches fail the statement (Spark raises an error rather than picking one). - Include a partition predicate in the
ONclause when the target is large and updates are recent (and t.occurred_at >= current_date - interval 7 days) so only those files are scanned. - Streaming writers produce many small files and many snapshots. Use a trigger interval of a minute or more and compact downstream.
Maintenance (schedule all of it)
Iceberg via Spark procedures:
call lake.system.rewrite_data_files(table => 'analytics.events',
where => 'occurred_at >= current_date - 2',
options => map('min-input-files', '5'));
call lake.system.rewrite_position_delete_files(table => 'analytics.events');
call lake.system.expire_snapshots(table => 'analytics.events',
older_than => timestamp '2025-10-01 00:00:00', retain_last => 20);
call lake.system.remove_orphan_files(table => 'analytics.events',
older_than => timestamp '2025-10-25 00:00:00');
call lake.system.rewrite_manifests('analytics.events');
Trino equivalents: alter table events execute optimize, execute expire_snapshots(retention_threshold => '7d'),
execute remove_orphan_files(retention_threshold => '7d').
Delta:
optimize events where occurred_at >= current_date() - interval 2 days;
vacuum events retain 168 hours;
| Job | Cadence | Notes |
|---|---|---|
| Compaction of recent partitions | hourly to daily | Scope with a where filter; full-table rewrites are expensive |
| Snapshot expiry / VACUUM | daily | Retention must exceed the longest-running query and any time-travel promise |
| Orphan file removal | weekly | older_than at least 3 days so in-flight writes are not deleted |
| Manifest rewrite | weekly or when planning is slow | Iceberg only |
Expiring snapshots, removing orphans and VACUUM permanently delete files and end time
travel to older versions. Confirm retention with the user before the first run and never
lower Delta’s 7-day retention check (spark.databricks.delta.retentionDurationCheck.enabled)
to make a VACUUM pass.
Reading from other engines
# PyIceberg (pip install "pyiceberg[pyarrow]"); catalog config in ~/.pyiceberg.yaml
from pyiceberg.catalog import load_catalog
catalog = load_catalog("lake")
table = catalog.load_table("analytics.events")
arrow = table.scan(row_filter="event_type == 'purchase'", selected_fields=("user_id", "occurred_at")).to_arrow()
-- DuckDB: install iceberg; load iceberg; (and the delta extension for Delta)
select count(*) from iceberg_scan('s3://bucket/warehouse/analytics/events');
select count(*) from delta_scan('s3://bucket/delta/events');
Reading by path skips the catalog; it can read a stale or uncommitted state. Prefer catalog-backed reads where the engine supports them.
Time travel and recovery
select * from lake.analytics.events for timestamp as of '2025-10-20 00:00:00'; -- Spark 3.3+
select * from lake.analytics.events version as of 8744736658442914487;
call lake.system.rollback_to_snapshot('analytics.events', 8744736658442914487);
Delta: select * from events version as of 42, restore table events to version as of 42.
Rollback and restore change what every reader sees; confirm with the user first.
Migrating Parquet or Hive tables
- Iceberg
migratereplaces a Hive table in place;snapshotcreates a test copy that references the same files. Runsnapshotfirst, validate counts, thenmigrate. add_filesregisters existing Parquet files without rewriting them; schemas must match.- Delta: convert in place, adding the partition schema when the folder is partitioned:
convert to delta parquet.`s3://bucket/raw/events` partitioned by (event_date date);
Gotchas
- Two catalogs pointing at the same table location will corrupt it: each thinks it owns the metadata pointer. One table, one catalog.
- Schema evolution is by column ID in Iceberg; renaming in a raw Parquet reader that maps by name will show nulls. Read through the table format, not the files.
- Timestamp types differ (
timestampvstimestamptz); Icebergtimestampwithout zone read in Trino or Snowflake can shift by the session time zone. Store UTC intimestamptz. - Concurrent writers to the same partitions conflict and retry; heavy contention means the job layout is wrong, not that retries should be raised.
remove_orphan_fileswith a shortolder_thandeletes files of commits in progress.
Verification
- After compaction: average file size in
filesmetadata rises toward the target and file count drops; a representative query’s scan bytes and planning time fall. - After expiry: snapshot count matches the retention policy; storage size trends down.
- After migration: row counts and a column checksum (
sum(hash(...))or sums of numeric columns) match the source per partition. - Record before/after numbers in the report:
**Table:** lake.analytics.events (Iceberg v2, REST catalog)
**Before:** 182,340 files, avg 3.1 MB, 4,912 snapshots, p50 dashboard query 41 s
**Actions:** compaction last 30 days, expire > 7 days keep 20, orphan cleanup > 3 days
**After:** 2,210 files, avg 256 MB, 20 snapshots, p50 query 6 s
**Scheduled:** compaction daily 02:00, expiry daily 03:00, orphans weekly (Airflow DAG lake_maintenance) What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Lakehouse Iceberg & Delta"
short_description: "Right-sized files, bounded snapshots, one catalog"
default_prompt: "$lakehouse-iceberg Our Iceberg events table has 200k tiny files - fix it and schedule maintenance" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.